package db_test import ( "context" "encoding/base64" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" _ "modernc.org/sqlite" ) func newTestDB(t *testing.T) *db.Provider { t.Helper() dbFile := filepath.Join(t.TempDir(), "test.db") p, err := db.New(dbFile) require.NoError(t, err) t.Cleanup(func() { p.Close() }) return p } func TestProvider_Users(t *testing.T) { ctx := context.Background() p := newTestDB(t) t.Run("save and select user", func(t *testing.T) { user := &models.User{ Username: "alice", PasswordHashed: []byte("hashed-password"), } err := p.SaveUser(ctx, user) require.NoError(t, err) assert.NotZero(t, user.ID) got, err := p.SelectUserByUsername(ctx, "alice") require.NoError(t, err) assert.Equal(t, user.ID, got.ID) assert.Equal(t, "alice", got.Username) assert.Equal(t, []byte("hashed-password"), got.PasswordHashed) }) t.Run("update user", func(t *testing.T) { user := &models.User{ Username: "bob", PasswordHashed: []byte("old-password"), } err := p.SaveUser(ctx, user) require.NoError(t, err) user.Username = "bob" user.PasswordHashed = []byte("new-password") err = p.SaveUser(ctx, user) require.NoError(t, err) got, err := p.SelectUserByUsername(ctx, "bob") require.NoError(t, err) assert.Equal(t, []byte("new-password"), got.PasswordHashed) }) } func TestProvider_Sites(t *testing.T) { ctx := context.Background() p := newTestDB(t) // Create a user first (sites need an owner) user := &models.User{ Username: "testuser", PasswordHashed: []byte("password"), } require.NoError(t, p.SaveUser(ctx, user)) t.Run("save and select sites", func(t *testing.T) { site := &models.Site{ OwnerID: user.ID, Title: "My Blog", Tagline: "A test blog", } err := p.SaveSite(ctx, site) require.NoError(t, err) assert.NotZero(t, site.ID) sites, err := p.SelectSitesOwnedByUser(ctx, user.ID) require.NoError(t, err) require.Len(t, sites, 1) assert.Equal(t, site.ID, sites[0].ID) assert.Equal(t, user.ID, sites[0].OwnerID) assert.Equal(t, "My Blog", sites[0].Title) assert.Equal(t, "A test blog", sites[0].Tagline) }) t.Run("select site by id", func(t *testing.T) { site := &models.Site{ OwnerID: user.ID, GUID: models.NewNanoID(), Title: "Lookup Blog", Tagline: "Find me by ID", } require.NoError(t, p.SaveSite(ctx, site)) got, err := p.SelectSiteByID(ctx, site.ID) require.NoError(t, err) assert.Equal(t, site.ID, got.ID) assert.Equal(t, user.ID, got.OwnerID) assert.Equal(t, "Lookup Blog", got.Title) assert.Equal(t, "Find me by ID", got.Tagline) }) t.Run("select sites for user with no sites", func(t *testing.T) { otherUser := &models.User{ Username: "otheruser", PasswordHashed: []byte("password"), } require.NoError(t, p.SaveUser(ctx, otherUser)) sites, err := p.SelectSitesOwnedByUser(ctx, otherUser.ID) require.NoError(t, err) assert.Empty(t, sites) }) } func TestProvider_Posts(t *testing.T) { ctx := context.Background() p := newTestDB(t) // Create user and site user := &models.User{ Username: "testuser", PasswordHashed: []byte("password"), } require.NoError(t, p.SaveUser(ctx, user)) site := &models.Site{ OwnerID: user.ID, Title: "My Blog", Tagline: "A test blog", } require.NoError(t, p.SaveSite(ctx, site)) t.Run("save and select posts", func(t *testing.T) { guid := models.NewNanoID() now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.UTC) post := &models.Post{ SiteID: site.ID, GUID: guid, Title: "First Post", Body: "Hello world", Slug: "/2026/02/19/first-post", CreatedAt: now, PublishedAt: now, } err := p.SavePost(ctx, post) require.NoError(t, err) assert.NotZero(t, post.ID) posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{Limit: 10, Offset: 0}) require.NoError(t, err) require.Len(t, posts, 1) assert.Equal(t, post.ID, posts[0].ID) assert.Equal(t, site.ID, posts[0].SiteID) assert.Equal(t, guid, posts[0].GUID) assert.Equal(t, "First Post", posts[0].Title) assert.Equal(t, "Hello world", posts[0].Body) assert.Equal(t, "/2026/02/19/first-post", posts[0].Slug) assert.Equal(t, now, posts[0].CreatedAt) assert.Equal(t, now, posts[0].PublishedAt) }) t.Run("posts ordered by created_at desc", func(t *testing.T) { // Create a second site to isolate this test guid := models.NewNanoID() site2 := &models.Site{ OwnerID: user.ID, GUID: models.NewNanoID(), Title: "Second Blog", Tagline: "", } require.NoError(t, p.SaveSite(ctx, site2)) earlier := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) later := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) post1 := &models.Post{ SiteID: site2.ID, GUID: guid, Title: "Old Post", Body: "old", Slug: "/old", CreatedAt: earlier, PublishedAt: earlier, } post2 := &models.Post{ SiteID: site2.ID, GUID: models.NewNanoID(), Title: "New Post", Body: "new", Slug: "/new", CreatedAt: later, PublishedAt: later, } require.NoError(t, p.SavePost(ctx, post1)) require.NoError(t, p.SavePost(ctx, post2)) posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{Limit: 10, Offset: 0}) require.NoError(t, err) require.Len(t, posts, 2) assert.Equal(t, "New Post", posts[0].Title) assert.Equal(t, "Old Post", posts[1].Title) }) t.Run("select posts for site with no posts", func(t *testing.T) { emptySite := &models.Site{ OwnerID: user.ID, GUID: models.NewNanoID(), Title: "Empty Blog", Tagline: "", } require.NoError(t, p.SaveSite(ctx, emptySite)) posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false, db.PagingParams{}) require.NoError(t, err) assert.Empty(t, posts) }) } func TestProvider_PublishTargets(t *testing.T) { ctx := context.Background() p := newTestDB(t) // Create user and site user := &models.User{ Username: "testuser", PasswordHashed: []byte("password"), } require.NoError(t, p.SaveUser(ctx, user)) site := &models.Site{ OwnerID: user.ID, GUID: models.NewNanoID(), Title: "My Blog", Tagline: "A test blog", } require.NoError(t, p.SaveSite(ctx, site)) t.Run("save and select publish targets", func(t *testing.T) { target := &models.SitePublishTarget{ SiteID: site.ID, TargetType: "netlify", GUID: "target-001", BaseURL: "https://example.netlify.app", TargetRef: "netlify-site-123", TargetKey: "secret-key", } err := p.SavePublishTarget(ctx, target) require.NoError(t, err) assert.NotZero(t, target.ID) targets, err := p.SelectPublishTargetsOfSite(ctx, site.ID) require.NoError(t, err) require.Len(t, targets, 1) assert.Equal(t, target.ID, targets[0].ID) assert.Equal(t, site.ID, targets[0].SiteID) assert.Equal(t, "netlify", targets[0].TargetType) assert.Equal(t, "https://example.netlify.app", targets[0].BaseURL) assert.Equal(t, "netlify-site-123", targets[0].TargetRef) assert.Equal(t, "secret-key", targets[0].TargetKey) }) t.Run("select targets for site with no targets", func(t *testing.T) { emptySite := &models.Site{ OwnerID: user.ID, GUID: models.NewNanoID(), Title: "No Targets", Tagline: "", } require.NoError(t, p.SaveSite(ctx, emptySite)) targets, err := p.SelectPublishTargetsOfSite(ctx, emptySite.ID) require.NoError(t, err) assert.Empty(t, targets) }) } func TestProvider_Categories(t *testing.T) { ctx := context.Background() p := newTestDB(t) user := &models.User{Username: "testuser", PasswordHashed: []byte("password")} require.NoError(t, p.SaveUser(ctx, user)) site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"} require.NoError(t, p.SaveSite(ctx, site)) t.Run("save and select categories", func(t *testing.T) { now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) cat := &models.Category{ SiteID: site.ID, GUID: "cat-001", Name: "Go Programming", Slug: "go-programming", Description: "Posts about Go", CreatedAt: now, UpdatedAt: now, } err := p.SaveCategory(ctx, cat) require.NoError(t, err) assert.NotZero(t, cat.ID) cats, err := p.SelectCategoriesOfSite(ctx, site.ID) require.NoError(t, err) require.Len(t, cats, 1) assert.Equal(t, "Go Programming", cats[0].Name) assert.Equal(t, "go-programming", cats[0].Slug) assert.Equal(t, "Posts about Go", cats[0].Description) }) t.Run("update category", func(t *testing.T) { now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) cat := &models.Category{ SiteID: site.ID, GUID: "cat-002", Name: "Original", Slug: "original", CreatedAt: now, UpdatedAt: now, } require.NoError(t, p.SaveCategory(ctx, cat)) cat.Name = "Updated" cat.Slug = "updated" cat.UpdatedAt = now.Add(time.Hour) require.NoError(t, p.SaveCategory(ctx, cat)) got, err := p.SelectCategory(ctx, cat.ID) require.NoError(t, err) assert.Equal(t, "Updated", got.Name) assert.Equal(t, "updated", got.Slug) }) t.Run("delete category", func(t *testing.T) { now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) cat := &models.Category{ SiteID: site.ID, GUID: "cat-003", Name: "ToDelete", Slug: "to-delete", CreatedAt: now, UpdatedAt: now, } require.NoError(t, p.SaveCategory(ctx, cat)) err := p.DeleteCategory(ctx, cat.ID) require.NoError(t, err) _, err = p.SelectCategory(ctx, cat.ID) assert.Error(t, err) }) } func TestProvider_PostCategories(t *testing.T) { ctx := context.Background() p := newTestDB(t) user := &models.User{Username: "testuser", PasswordHashed: []byte("password")} require.NoError(t, p.SaveUser(ctx, user)) site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"} require.NoError(t, p.SaveSite(ctx, site)) now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) post := &models.Post{ SiteID: site.ID, GUID: "post-pc-001", Title: "Test Post", Body: "body", Slug: "/test", CreatedAt: now, } require.NoError(t, p.SavePost(ctx, post)) cat1 := &models.Category{SiteID: site.ID, GUID: "cat-pc-1", Name: "Alpha", Slug: "alpha", CreatedAt: now, UpdatedAt: now} cat2 := &models.Category{SiteID: site.ID, GUID: "cat-pc-2", Name: "Beta", Slug: "beta", CreatedAt: now, UpdatedAt: now} require.NoError(t, p.SaveCategory(ctx, cat1)) require.NoError(t, p.SaveCategory(ctx, cat2)) t.Run("set and get post categories", func(t *testing.T) { err := p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID}) require.NoError(t, err) cats, err := p.SelectCategoriesOfPost(ctx, post.ID) require.NoError(t, err) require.Len(t, cats, 2) assert.Equal(t, "Alpha", cats[0].Name) assert.Equal(t, "Beta", cats[1].Name) }) t.Run("replace post categories", func(t *testing.T) { err := p.SetPostCategories(ctx, post.ID, []int64{cat2.ID}) require.NoError(t, err) cats, err := p.SelectCategoriesOfPost(ctx, post.ID) require.NoError(t, err) require.Len(t, cats, 1) assert.Equal(t, "Beta", cats[0].Name) }) t.Run("clear post categories", func(t *testing.T) { err := p.SetPostCategories(ctx, post.ID, []int64{}) require.NoError(t, err) cats, err := p.SelectCategoriesOfPost(ctx, post.ID) require.NoError(t, err) assert.Empty(t, cats) }) t.Run("count posts of category", func(t *testing.T) { post.State = models.StatePublished post.PublishedAt = now require.NoError(t, p.SavePost(ctx, post)) require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID})) count, err := p.CountPostsOfCategory(ctx, cat1.ID) require.NoError(t, err) assert.Equal(t, int64(1), count) count, err = p.CountPostsOfCategory(ctx, cat2.ID) require.NoError(t, err) assert.Equal(t, int64(0), count) }) t.Run("cascade delete category removes associations", func(t *testing.T) { require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID})) require.NoError(t, p.DeleteCategory(ctx, cat1.ID)) cats, err := p.SelectCategoriesOfPost(ctx, post.ID) require.NoError(t, err) require.Len(t, cats, 1) assert.Equal(t, "Beta", cats[0].Name) }) } // Verify that password encoding roundtrips correctly through base64 func TestProvider_UserPasswordEncoding(t *testing.T) { ctx := context.Background() p := newTestDB(t) // Use bytes that aren't valid UTF-8 to verify binary safety rawPassword := []byte{0x00, 0xff, 0x80, 0x7f, 0x01} user := &models.User{ Username: "binuser", PasswordHashed: rawPassword, } require.NoError(t, p.SaveUser(ctx, user)) got, err := p.SelectUserByUsername(ctx, "binuser") require.NoError(t, err) assert.Equal(t, rawPassword, got.PasswordHashed) // Verify it's stored as base64 (not raw bytes) - this is implicit // from the implementation but good to confirm the roundtrip works _ = base64.StdEncoding.EncodeToString(rawPassword) }