weiro/providers/db/provider_test.go
2026-03-18 22:19:26 +11:00

474 lines
13 KiB
Go

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)
}