main pico / pkg / db / postgres / storage_test.go
Antonio Mika  ·  2026-10-11
   1package postgres
   2
   3import (
   4	"errors"
   5	"log/slog"
   6	"os"
   7	"slices"
   8	"testing"
   9	"time"
  10
  11	"github.com/picosh/pico/pkg/db"
  12	"github.com/picosh/pico/pkg/db/postgres/pgtest"
  13)
  14
  15var testDB *PsqlDB
  16
  17func TestMain(m *testing.M) {
  18	pgtest.Main(m)
  19}
  20
  21func cleanupTestData(t *testing.T) {
  22	t.Helper()
  23	if testDB == nil {
  24		testDB = NewDB(pgtest.URL(t), slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})))
  25	}
  26	pgtest.Clean(t, testDB.Db)
  27}
  28
  29func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
  30	t.Helper()
  31	return pgtest.InsertPost(t, testDB, post)
  32}
  33
  34func TestRegisterUser_Success(t *testing.T) {
  35	cleanupTestData(t)
  36
  37	user, err := testDB.RegisterUser("testuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI testkey", "test comment", "")
  38	if err != nil {
  39		t.Fatalf("RegisterUser failed: %v", err)
  40		return
  41	}
  42	if user == nil {
  43		t.Fatal("expected user, got nil")
  44		return
  45	}
  46	if user.Name != "testuser" {
  47		t.Errorf("expected name 'testuser', got '%s'", user.Name)
  48	}
  49	if user.PublicKey == nil {
  50		t.Fatal("expected public key, got nil")
  51	}
  52}
  53
  54func TestRegisterUser_DuplicateName(t *testing.T) {
  55	cleanupTestData(t)
  56
  57	_, err := testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key1", "comment1", "")
  58	if err != nil {
  59		t.Fatalf("first RegisterUser failed: %v", err)
  60	}
  61
  62	_, err = testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key2", "comment2", "")
  63	if err == nil {
  64		t.Error("expected error for duplicate name, got nil")
  65	}
  66}
  67
  68func TestRegisterUser_DeniedName(t *testing.T) {
  69	cleanupTestData(t)
  70
  71	_, err := testDB.RegisterUser("admin", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI adminkey", "comment", "")
  72	if err == nil {
  73		t.Error("expected error for denied name 'admin', got nil")
  74	}
  75}
  76
  77func TestRegisterUser_InvalidName(t *testing.T) {
  78	cleanupTestData(t)
  79
  80	_, err := testDB.RegisterUser("user@invalid", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invalidkey", "comment", "")
  81	if err == nil {
  82		t.Error("expected error for invalid name, got nil")
  83	}
  84}
  85
  86func TestFindUser(t *testing.T) {
  87	cleanupTestData(t)
  88
  89	created, err := testDB.RegisterUser("findme", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findmekey", "comment", "")
  90	if err != nil {
  91		t.Fatalf("RegisterUser failed: %v", err)
  92	}
  93
  94	found, err := testDB.FindUser(created.ID)
  95	if err != nil {
  96		t.Fatalf("FindUser failed: %v", err)
  97	}
  98	if found.Name != "findme" {
  99		t.Errorf("expected name 'findme', got '%s'", found.Name)
 100	}
 101}
 102
 103func TestFindUserByName(t *testing.T) {
 104	cleanupTestData(t)
 105
 106	_, err := testDB.RegisterUser("nameduser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI namedkey", "comment", "")
 107	if err != nil {
 108		t.Fatalf("RegisterUser failed: %v", err)
 109	}
 110
 111	found, err := testDB.FindUserByName("nameduser")
 112	if err != nil {
 113		t.Fatalf("FindUserByName failed: %v", err)
 114	}
 115	if found.Name != "nameduser" {
 116		t.Errorf("expected name 'nameduser', got '%s'", found.Name)
 117	}
 118}
 119
 120func TestFindUserByPubkey(t *testing.T) {
 121	cleanupTestData(t)
 122
 123	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeyuser"
 124	_, err := testDB.RegisterUser("pubkeyuser", pubkey, "comment", "")
 125	if err != nil {
 126		t.Fatalf("RegisterUser failed: %v", err)
 127	}
 128
 129	found, err := testDB.FindUserByPubkey(pubkey)
 130	if err != nil {
 131		t.Fatalf("FindUserByPubkey failed: %v", err)
 132	}
 133	if found.Name != "pubkeyuser" {
 134		t.Errorf("expected name 'pubkeyuser', got '%s'", found.Name)
 135	}
 136}
 137
 138func TestFindUserByKey(t *testing.T) {
 139	cleanupTestData(t)
 140
 141	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI forkeyfind"
 142	_, err := testDB.RegisterUser("forkeyfind", pubkey, "comment", "")
 143	if err != nil {
 144		t.Fatalf("RegisterUser failed: %v", err)
 145	}
 146
 147	found, err := testDB.FindUserByKey("forkeyfind", pubkey)
 148	if err != nil {
 149		t.Fatalf("FindUserByKey failed: %v", err)
 150	}
 151	if found.Name != "forkeyfind" {
 152		t.Errorf("expected name 'forkeyfind', got '%s'", found.Name)
 153	}
 154}
 155
 156func TestFindUsers(t *testing.T) {
 157	cleanupTestData(t)
 158
 159	_, _ = testDB.RegisterUser("user1", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user1key", "comment", "")
 160	_, _ = testDB.RegisterUser("user2", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user2key", "comment", "")
 161
 162	users, err := testDB.FindUsers()
 163	if err != nil {
 164		t.Fatalf("FindUsers failed: %v", err)
 165	}
 166	if len(users) != 2 {
 167		t.Errorf("expected 2 users, got %d", len(users))
 168	}
 169}
 170
 171// ============ Public Key Management Tests ============
 172
 173func TestInsertPublicKey_Success(t *testing.T) {
 174	cleanupTestData(t)
 175
 176	user, err := testDB.RegisterUser("keyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI keyowner1", "comment", "")
 177	if err != nil {
 178		t.Fatalf("RegisterUser failed: %v", err)
 179	}
 180
 181	err = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI secondkey", "second key")
 182	if err != nil {
 183		t.Fatalf("InsertPublicKey failed: %v", err)
 184	}
 185
 186	keys, err := testDB.FindKeysByUser(user)
 187	if err != nil {
 188		t.Fatalf("FindKeysByUser failed: %v", err)
 189	}
 190	if len(keys) != 2 {
 191		t.Errorf("expected 2 keys, got %d", len(keys))
 192	}
 193}
 194
 195func TestInsertPublicKey_Duplicate(t *testing.T) {
 196	cleanupTestData(t)
 197
 198	user, _ := testDB.RegisterUser("dupkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI dupkey", "comment", "")
 199
 200	err := testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI dupkey", "same key")
 201	if err == nil {
 202		t.Error("expected error for duplicate key, got nil")
 203	}
 204}
 205
 206func TestUpdatePublicKey(t *testing.T) {
 207	cleanupTestData(t)
 208
 209	user, _ := testDB.RegisterUser("updatekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatekeyowner", "original", "")
 210
 211	updated, err := testDB.UpdatePublicKey(user.PublicKey.ID, "new-name")
 212	if err != nil {
 213		t.Fatalf("UpdatePublicKey failed: %v", err)
 214	}
 215	if updated.Name != "new-name" {
 216		t.Errorf("expected name 'new-name', got '%s'", updated.Name)
 217	}
 218}
 219
 220func TestFindKeysByUser(t *testing.T) {
 221	cleanupTestData(t)
 222
 223	user, _ := testDB.RegisterUser("multikeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI multikeyowner1", "key1", "")
 224	_ = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI multikeyowner2", "key2")
 225
 226	keys, err := testDB.FindKeysByUser(user)
 227	if err != nil {
 228		t.Fatalf("FindKeysByUser failed: %v", err)
 229	}
 230	if len(keys) != 2 {
 231		t.Errorf("expected 2 keys, got %d", len(keys))
 232	}
 233}
 234
 235func TestRemoveKeys(t *testing.T) {
 236	cleanupTestData(t)
 237
 238	user, _ := testDB.RegisterUser("removekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removekeyowner", "key1", "")
 239	_ = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removekeyowner2", "key2")
 240
 241	keys, _ := testDB.FindKeysByUser(user)
 242	if len(keys) != 2 {
 243		t.Fatalf("expected 2 keys before removal, got %d", len(keys))
 244	}
 245
 246	err := testDB.RemoveKeys([]string{keys[1].ID})
 247	if err != nil {
 248		t.Fatalf("RemoveKeys failed: %v", err)
 249	}
 250
 251	keys, _ = testDB.FindKeysByUser(user)
 252	if len(keys) != 1 {
 253		t.Errorf("expected 1 key after removal, got %d", len(keys))
 254	}
 255}
 256
 257// ============ Token Management Tests ============
 258
 259func TestInsertToken(t *testing.T) {
 260	cleanupTestData(t)
 261
 262	user, _ := testDB.RegisterUser("tokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokenowner", "comment", "")
 263
 264	token, err := testDB.InsertToken(user.ID, "my-token")
 265	if err != nil {
 266		t.Fatalf("InsertToken failed: %v", err)
 267	}
 268	if token == "" {
 269		t.Error("expected token string, got empty")
 270	}
 271}
 272
 273func TestUpsertToken(t *testing.T) {
 274	cleanupTestData(t)
 275
 276	user, _ := testDB.RegisterUser("upserttokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI upserttokenowner", "comment", "")
 277
 278	token1, err := testDB.UpsertToken(user.ID, "upsert-token")
 279	if err != nil {
 280		t.Fatalf("first UpsertToken failed: %v", err)
 281	}
 282
 283	token2, err := testDB.UpsertToken(user.ID, "upsert-token")
 284	if err != nil {
 285		t.Fatalf("second UpsertToken failed: %v", err)
 286	}
 287
 288	if token1 != token2 {
 289		t.Errorf("expected same token, got different: %s vs %s", token1, token2)
 290	}
 291}
 292
 293func TestFindTokensByUser(t *testing.T) {
 294	cleanupTestData(t)
 295
 296	user, _ := testDB.RegisterUser("tokensowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokensowner", "comment", "")
 297	_, _ = testDB.InsertToken(user.ID, "token1")
 298	_, _ = testDB.InsertToken(user.ID, "token2")
 299
 300	tokens, err := testDB.FindTokensByUser(user.ID)
 301	if err != nil {
 302		t.Fatalf("FindTokensByUser failed: %v", err)
 303	}
 304	if len(tokens) != 2 {
 305		t.Errorf("expected 2 tokens, got %d", len(tokens))
 306	}
 307}
 308
 309func TestFindUserByToken_Valid(t *testing.T) {
 310	cleanupTestData(t)
 311
 312	user, _ := testDB.RegisterUser("validtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI validtokenowner", "comment", "")
 313	token, _ := testDB.InsertToken(user.ID, "valid-token")
 314
 315	found, err := testDB.FindUserByToken(token)
 316	if err != nil {
 317		t.Fatalf("FindUserByToken failed: %v", err)
 318	}
 319	if found.Name != "validtokenowner" {
 320		t.Errorf("expected name 'validtokenowner', got '%s'", found.Name)
 321	}
 322}
 323
 324func TestFindUserByToken_Expired(t *testing.T) {
 325	cleanupTestData(t)
 326
 327	user, _ := testDB.RegisterUser("expiredtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredtokenowner", "comment", "")
 328	token, _ := testDB.InsertToken(user.ID, "expired-token")
 329
 330	_, err := testDB.Db.Exec("UPDATE tokens SET expires_at = NOW() - INTERVAL '1 day' WHERE token = $1", token)
 331	if err != nil {
 332		t.Fatalf("failed to expire token: %v", err)
 333	}
 334
 335	_, err = testDB.FindUserByToken(token)
 336	if err == nil {
 337		t.Error("expected error for expired token, got nil")
 338	}
 339}
 340
 341func TestRemoveToken(t *testing.T) {
 342	cleanupTestData(t)
 343
 344	user, _ := testDB.RegisterUser("removetokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removetokenowner", "comment", "")
 345	_, _ = testDB.InsertToken(user.ID, "remove-token")
 346
 347	tokens, _ := testDB.FindTokensByUser(user.ID)
 348	if len(tokens) != 1 {
 349		t.Fatalf("expected 1 token, got %d", len(tokens))
 350	}
 351
 352	err := testDB.RemoveToken(tokens[0].ID)
 353	if err != nil {
 354		t.Fatalf("RemoveToken failed: %v", err)
 355	}
 356
 357	tokens, _ = testDB.FindTokensByUser(user.ID)
 358	if len(tokens) != 0 {
 359		t.Errorf("expected 0 tokens after removal, got %d", len(tokens))
 360	}
 361}
 362
 363// ============ Post CRUD Tests ============
 364
 365func TestInsertPost(t *testing.T) {
 366	cleanupTestData(t)
 367
 368	user, _ := testDB.RegisterUser("postowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postowner", "comment", "")
 369
 370	now := time.Now()
 371	post := &db.Post{
 372		UserID:      user.ID,
 373		Filename:    "test.md",
 374		Slug:        "test-post",
 375		Title:       "Test Post",
 376		Text:        "Post content",
 377		Description: "A test post",
 378		PublishAt:   &now,
 379		UpdatedAt:   &now,
 380		Space:       "prose",
 381		MimeType:    "text/markdown",
 382	}
 383
 384	created, err := testDB.InsertPost(post)
 385	if err != nil {
 386		t.Fatalf("InsertPost failed: %v", err)
 387	}
 388	if created.ID == "" {
 389		t.Error("expected post ID, got empty")
 390	}
 391	if created.Title != "Test Post" {
 392		t.Errorf("expected title 'Test Post', got '%s'", created.Title)
 393	}
 394}
 395
 396func TestUpdatePost(t *testing.T) {
 397	cleanupTestData(t)
 398
 399	user, _ := testDB.RegisterUser("updatepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatepostowner", "comment", "")
 400
 401	now := time.Now()
 402	post := mustInsertPost(t, &db.Post{
 403		UserID:   user.ID,
 404		Filename: "update.md",
 405		Slug:     "update-post",
 406		Title:    "Original Title",
 407		Text:     "Original content",
 408		Space:    "prose",
 409	})
 410
 411	post.Title = "Updated Title"
 412	post.Text = "Updated content"
 413	post.UpdatedAt = &now
 414
 415	updated, err := testDB.UpdatePost(post)
 416	if err != nil {
 417		t.Fatalf("UpdatePost failed: %v", err)
 418	}
 419	if updated.Title != "Updated Title" {
 420		t.Errorf("expected title 'Updated Title', got '%s'", updated.Title)
 421	}
 422}
 423
 424func TestRenamePost(t *testing.T) {
 425	cleanupTestData(t)
 426
 427	user, _ := testDB.RegisterUser("renamepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI renamepostowner", "comment", "")
 428	mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "a.md", Slug: "a", Text: "a", Space: "prose", Hidden: true})
 429	mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "b.md", Slug: "b", Text: "b", Space: "prose"})
 430	third := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "c.md", Slug: "c", Text: "c", Space: "prose"})
 431	// Posts made before data was written on insert have the column default.
 432	if _, err := testDB.Db.Exec(`UPDATE posts SET data = '{}'::jsonb`); err != nil {
 433		t.Fatal(err)
 434	}
 435	find := func(filename string) *db.Post {
 436		t.Helper()
 437		p, err := testDB.FindPostWithFilename(filename, user.ID, "prose")
 438		if err != nil {
 439			t.Fatalf("%s: %v", filename, err)
 440		}
 441		return p
 442	}
 443	renamed := func(cur *db.Post, filename, slug string) *db.Post {
 444		next := *cur
 445		next.Filename, next.Slug, next.MimeType = filename, slug, "text/plain"
 446		return &next
 447	}
 448
 449	a := find("a.md")
 450	got, err := testDB.RenamePost(a, renamed(a, "a.lxt", "a"), nil, []string{"x", "y"}, []string{"old-a"})
 451	if err != nil {
 452		t.Fatalf("RenamePost failed: %v", err)
 453	}
 454	if got.ID != a.ID || got.Filename != "a.lxt" || got.MimeType != "text/plain" || !got.Hidden {
 455		t.Errorf("unexpected post after rename: %+v", got)
 456	}
 457	a = find("a.lxt")
 458	if slices.Sort(a.Tags); !slices.Equal(a.Tags, []string{"x", "y"}) {
 459		t.Errorf("tags = %q", a.Tags)
 460	}
 461	if p, err := testDB.FindPostWithSlug("old-a", user.ID, "prose"); err != nil || p.ID != a.ID {
 462		t.Errorf("alias old-a = %+v, %v", p, err)
 463	}
 464
 465	// A save after the post was read stops the rename and is kept.
 466	stale := *a
 467	saved := *a
 468	saved.Text = "saved"
 469	if _, err := testDB.UpdatePost(&saved); err != nil {
 470		t.Fatal(err)
 471	}
 472	if _, err := testDB.RenamePost(&stale, renamed(&stale, "d.md", "d"), nil, nil, nil); !errors.Is(err, db.ErrPostChanged) {
 473		t.Fatalf("rename of a changed post: %v", err)
 474	}
 475	if p := find("a.lxt"); p.Text != "saved" {
 476		t.Errorf("a.lxt text = %q, want the save", p.Text)
 477	}
 478
 479	// So does a save to the post it replaces.
 480	a = find("a.lxt")
 481	b := find("b.md")
 482	bSaved := *b
 483	bSaved.Text = "b saved"
 484	if _, err := testDB.UpdatePost(&bSaved); err != nil {
 485		t.Fatal(err)
 486	}
 487	if _, err := testDB.RenamePost(a, renamed(a, "b.md", "b"), b, nil, nil); !errors.Is(err, db.ErrPostChanged) {
 488		t.Fatalf("rename over a changed post: %v", err)
 489	}
 490	if p := find("b.md"); p.Text != "b saved" {
 491		t.Errorf("b.md text = %q, want the save", p.Text)
 492	}
 493	if p := find("a.lxt"); p.ID != a.ID {
 494		t.Errorf("a.lxt moved by a failed rename")
 495	}
 496
 497	// Taking another post's slug fails and keeps the post it would replace.
 498	b = find("b.md")
 499	if _, err := testDB.RenamePost(a, renamed(a, "c2.md", "c"), b, nil, nil); err == nil {
 500		t.Fatal("expected a slug conflict")
 501	}
 502	find("b.md")
 503
 504	if _, err := testDB.RenamePost(a, renamed(a, "b.md", "b"), b, nil, nil); err != nil {
 505		t.Fatalf("RenamePost over b.md failed: %v", err)
 506	}
 507	if p := find("b.md"); p.ID != a.ID || p.Text != "saved" {
 508		t.Errorf("b.md = %+v", p)
 509	}
 510	if _, err := testDB.FindPost(b.ID); err == nil {
 511		t.Error("replaced post still exists")
 512	}
 513	if _, err := testDB.FindPost(third.ID); err != nil {
 514		t.Errorf("unrelated post removed: %v", err)
 515	}
 516
 517	// Data written after the post was read, like a feed digest, is kept.
 518	moved := find("b.md")
 519	if _, err := testDB.Db.Exec(`UPDATE posts SET data = '{"attempts": 2}'::jsonb WHERE id = $1`, moved.ID); err != nil {
 520		t.Fatal(err)
 521	}
 522	if _, err := testDB.RenamePost(moved, renamed(moved, "e.md", "e"), nil, nil, nil); err != nil {
 523		t.Fatalf("rename after a data change: %v", err)
 524	}
 525	if p := find("e.md"); p.Data.Attempts != 2 {
 526		t.Errorf("data = %+v, want the later write", p.Data)
 527	}
 528
 529	// A post moved to another name after it was read isn't replaced.
 530	e := find("e.md")
 531	c := find("c.md")
 532	if _, err := testDB.Db.Exec(`UPDATE posts SET filename = 'f.md' WHERE id = $1`, c.ID); err != nil {
 533		t.Fatal(err)
 534	}
 535	if _, err := testDB.RenamePost(e, renamed(e, "c.md", "c"), c, nil, nil); !errors.Is(err, db.ErrPostChanged) {
 536		t.Fatalf("rename over a moved post: %v", err)
 537	}
 538	if p := find("f.md"); p.ID != c.ID {
 539		t.Errorf("f.md = %+v", p)
 540	}
 541}
 542
 543func TestRemovePosts(t *testing.T) {
 544	cleanupTestData(t)
 545
 546	user, _ := testDB.RegisterUser("removepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removepostowner", "comment", "")
 547
 548	post := mustInsertPost(t, &db.Post{
 549		UserID:   user.ID,
 550		Filename: "remove.md",
 551		Slug:     "remove-post",
 552		Title:    "To Remove",
 553		Space:    "prose",
 554	})
 555
 556	err := testDB.RemovePosts([]string{post.ID})
 557	if err != nil {
 558		t.Fatalf("RemovePosts failed: %v", err)
 559	}
 560
 561	_, err = testDB.FindPost(post.ID)
 562	if err == nil {
 563		t.Error("expected error finding removed post, got nil")
 564	}
 565}
 566
 567func TestFindPost(t *testing.T) {
 568	cleanupTestData(t)
 569
 570	user, _ := testDB.RegisterUser("findpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findpostowner", "comment", "")
 571
 572	created := mustInsertPost(t, &db.Post{
 573		UserID:   user.ID,
 574		Filename: "find.md",
 575		Slug:     "find-post",
 576		Title:    "Find Me",
 577		Space:    "prose",
 578	})
 579
 580	found, err := testDB.FindPost(created.ID)
 581	if err != nil {
 582		t.Fatalf("FindPost failed: %v", err)
 583	}
 584	if found.Title != "Find Me" {
 585		t.Errorf("expected title 'Find Me', got '%s'", found.Title)
 586	}
 587}
 588
 589func TestFindPostWithFilename(t *testing.T) {
 590	cleanupTestData(t)
 591
 592	user, _ := testDB.RegisterUser("filenamepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI filenamepostowner", "comment", "")
 593
 594	_ = mustInsertPost(t, &db.Post{
 595		UserID:   user.ID,
 596		Filename: "byfilename.md",
 597		Slug:     "byfilename-post",
 598		Title:    "By Filename",
 599		Space:    "prose",
 600	})
 601
 602	found, err := testDB.FindPostWithFilename("byfilename.md", user.ID, "prose")
 603	if err != nil {
 604		t.Fatalf("FindPostWithFilename failed: %v", err)
 605	}
 606	if found.Title != "By Filename" {
 607		t.Errorf("expected title 'By Filename', got '%s'", found.Title)
 608	}
 609}
 610
 611func TestFindPostWithSlug(t *testing.T) {
 612	cleanupTestData(t)
 613
 614	user, _ := testDB.RegisterUser("slugpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI slugpostowner", "comment", "")
 615
 616	_ = mustInsertPost(t, &db.Post{
 617		UserID:   user.ID,
 618		Filename: "byslug.md",
 619		Slug:     "byslug-post",
 620		Title:    "By Slug",
 621		Space:    "prose",
 622	})
 623
 624	found, err := testDB.FindPostWithSlug("byslug-post", user.ID, "prose")
 625	if err != nil {
 626		t.Fatalf("FindPostWithSlug failed: %v", err)
 627	}
 628	if found.Title != "By Slug" {
 629		t.Errorf("expected title 'By Slug', got '%s'", found.Title)
 630	}
 631}
 632
 633func TestFindPosts(t *testing.T) {
 634	cleanupTestData(t)
 635
 636	user, _ := testDB.RegisterUser("postsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postsowner", "comment", "")
 637
 638	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "post1.md", Slug: "post1", Title: "Post 1", Space: "prose"})
 639	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "post2.md", Slug: "post2", Title: "Post 2", Space: "prose"})
 640
 641	posts, err := testDB.FindPosts()
 642	if err != nil {
 643		t.Fatalf("FindPosts failed: %v", err)
 644	}
 645	if len(posts) != 2 {
 646		t.Errorf("expected 2 posts, got %d", len(posts))
 647	}
 648}
 649
 650func TestFindPostsByUser(t *testing.T) {
 651	cleanupTestData(t)
 652
 653	user, _ := testDB.RegisterUser("userpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI userpostsowner", "comment", "")
 654	now := time.Now()
 655
 656	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "userpost1.md", Slug: "userpost1", Title: "User Post 1", Space: "prose", PublishAt: &now})
 657	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "userpost2.md", Slug: "userpost2", Title: "User Post 2", Space: "prose", PublishAt: &now})
 658
 659	pager := &db.Pager{Num: 10, Page: 0}
 660	result, err := testDB.FindPostsByUser(pager, user.ID, "prose")
 661	if err != nil {
 662		t.Fatalf("FindPostsByUser failed: %v", err)
 663	}
 664	if len(result.Data) != 2 {
 665		t.Errorf("expected 2 posts, got %d", len(result.Data))
 666	}
 667}
 668
 669func TestFindAllPostsByUser(t *testing.T) {
 670	cleanupTestData(t)
 671
 672	user, _ := testDB.RegisterUser("allpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI allpostsowner", "comment", "")
 673
 674	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "allpost1.md", Slug: "allpost1", Title: "All Post 1", Space: "prose"})
 675	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "allpost2.md", Slug: "allpost2", Title: "All Post 2", Space: "prose"})
 676
 677	posts, err := testDB.FindAllPostsByUser(user.ID, "prose")
 678	if err != nil {
 679		t.Fatalf("FindAllPostsByUser failed: %v", err)
 680	}
 681	if len(posts) != 2 {
 682		t.Errorf("expected 2 posts, got %d", len(posts))
 683	}
 684}
 685
 686func TestReplaceTagsByPost(t *testing.T) {
 687	cleanupTestData(t)
 688
 689	user, _ := testDB.RegisterUser("tagspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagspostowner", "comment", "")
 690	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagged.md", Slug: "tagged", Title: "Tagged", Space: "prose"})
 691
 692	err := testDB.ReplaceTagsByPost([]string{"tag1", "tag2"}, post.ID)
 693	if err != nil {
 694		t.Fatalf("ReplaceTagsByPost failed: %v", err)
 695	}
 696
 697	found, _ := testDB.FindPostWithFilename("tagged.md", user.ID, "prose")
 698	if len(found.Tags) != 2 {
 699		t.Errorf("expected 2 tags, got %d", len(found.Tags))
 700	}
 701}
 702
 703func TestReplaceAliasesByPost(t *testing.T) {
 704	cleanupTestData(t)
 705
 706	user, _ := testDB.RegisterUser("aliasowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliasowner", "comment", "")
 707	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "aliased.md", Slug: "aliased", Title: "Aliased", Space: "prose"})
 708
 709	err := testDB.ReplaceAliasesByPost([]string{"alias1", "alias2"}, post.ID)
 710	if err != nil {
 711		t.Fatalf("ReplaceAliasesByPost failed: %v", err)
 712	}
 713}
 714
 715func TestFindPostWithSlug_Alias(t *testing.T) {
 716	cleanupTestData(t)
 717
 718	user, _ := testDB.RegisterUser("aliassearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliassearchowner", "comment", "")
 719	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "original.md", Slug: "original", Title: "Original", Space: "prose"})
 720	_ = testDB.ReplaceAliasesByPost([]string{"my-alias"}, post.ID)
 721
 722	found, err := testDB.FindPostWithSlug("my-alias", user.ID, "prose")
 723	if err != nil {
 724		t.Fatalf("FindPostWithSlug for alias failed: %v", err)
 725	}
 726	if found.Title != "Original" {
 727		t.Errorf("expected title 'Original', got '%s'", found.Title)
 728	}
 729}
 730
 731// ============ Analytics Tests ============
 732
 733func TestInsertVisit(t *testing.T) {
 734	cleanupTestData(t)
 735
 736	user, _ := testDB.RegisterUser("visitowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI visitowner", "comment", "")
 737
 738	visit := &db.AnalyticsVisits{
 739		UserID:    user.ID,
 740		Host:      "example.com",
 741		Path:      "/test",
 742		IpAddress: "192.168.1.1",
 743		UserAgent: "TestAgent/1.0",
 744		Referer:   "https://referrer.com",
 745		Status:    200,
 746	}
 747
 748	err := testDB.InsertVisit(visit)
 749	if err != nil {
 750		t.Fatalf("InsertVisit failed: %v", err)
 751	}
 752}
 753
 754func TestVisitSummary(t *testing.T) {
 755	cleanupTestData(t)
 756
 757	user, _ := testDB.RegisterUser("summaryowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI summaryowner", "comment", "")
 758
 759	visit := &db.AnalyticsVisits{
 760		UserID:    user.ID,
 761		Host:      "summary.com",
 762		Path:      "/page",
 763		IpAddress: "192.168.1.2",
 764		Status:    200,
 765	}
 766	_ = testDB.InsertVisit(visit)
 767
 768	opts := &db.SummaryOpts{
 769		Interval: "day",
 770		Origin:   time.Now().Add(-24 * time.Hour),
 771		Host:     "summary.com",
 772		UserID:   user.ID,
 773	}
 774
 775	summary, err := testDB.VisitSummary(opts)
 776	if err != nil {
 777		t.Fatalf("VisitSummary failed: %v", err)
 778	}
 779	if summary == nil {
 780		t.Error("expected summary, got nil")
 781	}
 782}
 783
 784func TestFindVisitSiteList(t *testing.T) {
 785	cleanupTestData(t)
 786
 787	user, _ := testDB.RegisterUser("sitelistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI sitelistowner", "comment", "")
 788
 789	_ = testDB.InsertVisit(&db.AnalyticsVisits{
 790		UserID:    user.ID,
 791		Host:      "site1.com",
 792		Path:      "/",
 793		IpAddress: "192.168.1.3",
 794		Status:    200,
 795	})
 796
 797	opts := &db.SummaryOpts{UserID: user.ID}
 798	sites, err := testDB.FindVisitSiteList(opts)
 799	if err != nil {
 800		t.Fatalf("FindVisitSiteList failed: %v", err)
 801	}
 802	if len(sites) < 1 {
 803		t.Errorf("expected at least 1 site, got %d", len(sites))
 804	}
 805}
 806
 807func TestVisitUrlNotFound(t *testing.T) {
 808	cleanupTestData(t)
 809
 810	user, _ := testDB.RegisterUser("notfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI notfoundowner", "comment", "")
 811
 812	_ = testDB.InsertVisit(&db.AnalyticsVisits{
 813		UserID:    user.ID,
 814		Host:      "notfound.com",
 815		Path:      "/missing",
 816		IpAddress: "192.168.1.4",
 817		Status:    404,
 818	})
 819
 820	opts := &db.SummaryOpts{
 821		Origin: time.Now().Add(-24 * time.Hour),
 822		Host:   "notfound.com",
 823		UserID: user.ID,
 824	}
 825	notFound, err := testDB.VisitUrlNotFound(opts)
 826	if err != nil {
 827		t.Fatalf("VisitUrlNotFound failed: %v", err)
 828	}
 829	if len(notFound) < 1 {
 830		t.Errorf("expected at least 1 not found URL, got %d", len(notFound))
 831	}
 832}
 833
 834// ============ Features Tests ============
 835
 836func TestInsertFeature(t *testing.T) {
 837	cleanupTestData(t)
 838
 839	user, _ := testDB.RegisterUser("featureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featureowner", "comment", "")
 840
 841	expiresAt := time.Now().Add(365 * 24 * time.Hour)
 842	feature, err := testDB.InsertFeature(user.ID, "plus", expiresAt)
 843	if err != nil {
 844		t.Fatalf("InsertFeature failed: %v", err)
 845	}
 846	if feature.Name != "plus" {
 847		t.Errorf("expected feature name 'plus', got '%s'", feature.Name)
 848	}
 849}
 850
 851func TestFindFeature(t *testing.T) {
 852	cleanupTestData(t)
 853
 854	user, _ := testDB.RegisterUser("findfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeatureowner", "comment", "")
 855	expiresAt := time.Now().Add(365 * 24 * time.Hour)
 856	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
 857
 858	feature, err := testDB.FindFeature(user.ID, "plus")
 859	if err != nil {
 860		t.Fatalf("FindFeature failed: %v", err)
 861	}
 862	if feature.Name != "plus" {
 863		t.Errorf("expected feature name 'plus', got '%s'", feature.Name)
 864	}
 865}
 866
 867func TestFindFeaturesByUser(t *testing.T) {
 868	cleanupTestData(t)
 869
 870	user, _ := testDB.RegisterUser("featuresowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featuresowner", "comment", "")
 871	expiresAt := time.Now().Add(365 * 24 * time.Hour)
 872	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
 873	_, _ = testDB.InsertFeature(user.ID, "pro", expiresAt)
 874
 875	features, err := testDB.FindFeaturesByUser(user.ID)
 876	if err != nil {
 877		t.Fatalf("FindFeaturesByUser failed: %v", err)
 878	}
 879	if len(features) != 2 {
 880		t.Errorf("expected 2 features, got %d", len(features))
 881	}
 882}
 883
 884func TestHasFeature(t *testing.T) {
 885	cleanupTestData(t)
 886
 887	user, _ := testDB.RegisterUser("hasfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI hasfeatureowner", "comment", "")
 888	expiresAt := time.Now().Add(365 * 24 * time.Hour)
 889	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
 890
 891	has := db.HasFeature(testDB, user.ID, "plus")
 892	if !has {
 893		t.Error("expected HasFeature to return true")
 894	}
 895
 896	hasNot := db.HasFeature(testDB, user.ID, "nonexistent")
 897	if hasNot {
 898		t.Error("expected HasFeature to return false for nonexistent feature")
 899	}
 900}
 901
 902func TestRemoveFeature(t *testing.T) {
 903	cleanupTestData(t)
 904
 905	user, _ := testDB.RegisterUser("removefeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removefeatureowner", "comment", "")
 906	expiresAt := time.Now().Add(365 * 24 * time.Hour)
 907	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
 908
 909	err := testDB.RemoveFeature(user.ID, "plus")
 910	if err != nil {
 911		t.Fatalf("RemoveFeature failed: %v", err)
 912	}
 913
 914	_, err = testDB.FindFeature(user.ID, "plus")
 915	if err == nil {
 916		t.Error("expected error finding removed feature, got nil")
 917	}
 918}
 919
 920func TestAddPicoPlusUser(t *testing.T) {
 921	cleanupTestData(t)
 922
 923	_, _ = testDB.RegisterUser("picoplusowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI picoplusowner", "comment", "")
 924
 925	err := testDB.AddPicoPlusUser("picoplusowner", "test@example.com", "stripe", "tx123")
 926	if err != nil {
 927		t.Fatalf("AddPicoPlusUser failed: %v", err)
 928	}
 929}
 930
 931func TestAddFeatureUser(t *testing.T) {
 932	cleanupTestData(t)
 933
 934	user, _ := testDB.RegisterUser("featureuserowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featureuserowner", "comment", "")
 935
 936	err := testDB.AddFeatureUser("featureuserowner", "pgs")
 937	if err != nil {
 938		t.Fatalf("AddFeatureUser failed: %v", err)
 939	}
 940
 941	ff, err := testDB.FindFeature(user.ID, "pgs")
 942	if err != nil {
 943		t.Fatalf("FindFeature failed: %v", err)
 944	}
 945	if ff.Name != "pgs" {
 946		t.Errorf("expected feature name pgs, got %s", ff.Name)
 947	}
 948	if !ff.IsValid() {
 949		t.Errorf("expected feature to be valid")
 950	}
 951}
 952
 953func TestUpsertProject(t *testing.T) {
 954	cleanupTestData(t)
 955
 956	user, _ := testDB.RegisterUser("projectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI projectowner", "comment", "")
 957
 958	project, err := testDB.UpsertProject(user.ID, "my-project", "my-project")
 959	if err != nil {
 960		t.Fatalf("UpsertProject failed: %v", err)
 961	}
 962	if project.Name != "my-project" {
 963		t.Errorf("expected project name 'my-project', got '%s'", project.Name)
 964	}
 965
 966	project2, err := testDB.UpsertProject(user.ID, "my-project", "my-project")
 967	if err != nil {
 968		t.Fatalf("UpsertProject (update) failed: %v", err)
 969	}
 970	if project2.ID != project.ID {
 971		t.Error("expected same project ID on upsert")
 972	}
 973}
 974
 975func TestFindProjectByName(t *testing.T) {
 976	cleanupTestData(t)
 977
 978	user, _ := testDB.RegisterUser("findprojectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findprojectowner", "comment", "")
 979	_, _ = testDB.UpsertProject(user.ID, "findme-project", "findme-project")
 980
 981	project, err := testDB.FindProjectByName(user.ID, "findme-project")
 982	if err != nil {
 983		t.Fatalf("FindProjectByName failed: %v", err)
 984	}
 985	if project.Name != "findme-project" {
 986		t.Errorf("expected project name 'findme-project', got '%s'", project.Name)
 987	}
 988}
 989
 990// ============ User Stats Tests ============
 991
 992func TestFindUserStats(t *testing.T) {
 993	cleanupTestData(t)
 994
 995	user, _ := testDB.RegisterUser("statsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI statsowner", "comment", "")
 996	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "stat.md", Slug: "stat", Title: "Stat", Space: "prose"})
 997	_, _ = testDB.UpsertProject(user.ID, "stat-project", "stat-project")
 998
 999	stats, err := testDB.FindUserStats(user.ID)
1000	if err != nil {
1001		t.Fatalf("FindUserStats failed: %v", err)
1002	}
1003	if stats.Prose.Num != 1 {
1004		t.Errorf("expected 1 prose post, got %d", stats.Prose.Num)
1005	}
1006	if stats.Pages.Num != 1 {
1007		t.Errorf("expected 1 project, got %d", stats.Pages.Num)
1008	}
1009}
1010
1011func TestFindUserStats_NoProjects(t *testing.T) {
1012	cleanupTestData(t)
1013
1014	user, _ := testDB.RegisterUser("statsempty", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI statsempty", "comment", "")
1015
1016	stats, err := testDB.FindUserStats(user.ID)
1017	if err != nil {
1018		t.Fatalf("FindUserStats failed: %v", err)
1019	}
1020	if stats.Pages.Num != 0 {
1021		t.Errorf("expected 0 projects, got %d", stats.Pages.Num)
1022	}
1023	if !stats.Pages.FirstCreatedAt.IsZero() {
1024		t.Errorf("expected zero first created at, got %s", stats.Pages.FirstCreatedAt)
1025	}
1026}
1027
1028// ============ Tuns Event Logs Tests ============
1029
1030func TestInsertTunsEventLog(t *testing.T) {
1031	cleanupTestData(t)
1032
1033	user, _ := testDB.RegisterUser("tunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsowner", "comment", "")
1034
1035	log := &db.TunsEventLog{
1036		UserId:         user.ID,
1037		ServerID:       "server-1",
1038		RemoteAddr:     "192.168.1.1:1234",
1039		EventType:      "connect",
1040		TunnelType:     "http",
1041		ConnectionType: "tcp",
1042		TunnelID:       "tunnel-123",
1043	}
1044
1045	err := testDB.InsertTunsEventLog(log)
1046	if err != nil {
1047		t.Fatalf("InsertTunsEventLog failed: %v", err)
1048	}
1049}
1050
1051func TestFindTunsEventLogs(t *testing.T) {
1052	cleanupTestData(t)
1053
1054	user, _ := testDB.RegisterUser("findtunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findtunsowner", "comment", "")
1055	_ = testDB.InsertTunsEventLog(&db.TunsEventLog{
1056		UserId:         user.ID,
1057		ServerID:       "server-1",
1058		RemoteAddr:     "192.168.1.1:1234",
1059		EventType:      "connect",
1060		TunnelType:     "http",
1061		ConnectionType: "tcp",
1062		TunnelID:       "tunnel-456",
1063	})
1064
1065	logs, err := testDB.FindTunsEventLogs(user.ID)
1066	if err != nil {
1067		t.Fatalf("FindTunsEventLogs failed: %v", err)
1068	}
1069	if len(logs) != 1 {
1070		t.Errorf("expected 1 log, got %d", len(logs))
1071	}
1072}
1073
1074func TestFindTunsEventLogsByAddr(t *testing.T) {
1075	cleanupTestData(t)
1076
1077	user, _ := testDB.RegisterUser("tunsaddrowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsaddrowner", "comment", "")
1078	_ = testDB.InsertTunsEventLog(&db.TunsEventLog{
1079		UserId:         user.ID,
1080		ServerID:       "server-1",
1081		RemoteAddr:     "192.168.1.1:1234",
1082		EventType:      "connect",
1083		TunnelType:     "http",
1084		ConnectionType: "tcp",
1085		TunnelID:       "tunnel-789",
1086	})
1087
1088	logs, err := testDB.FindTunsEventLogsByAddr(user.ID, "tunnel-789")
1089	if err != nil {
1090		t.Fatalf("FindTunsEventLogsByAddr failed: %v", err)
1091	}
1092	if len(logs) != 1 {
1093		t.Errorf("expected 1 log, got %d", len(logs))
1094	}
1095}
1096
1097// ============ Access Logs Tests ============
1098
1099func TestInsertAccessLog(t *testing.T) {
1100	cleanupTestData(t)
1101
1102	user, _ := testDB.RegisterUser("accesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accesslogowner", "comment", "")
1103
1104	log := &db.AccessLog{
1105		UserID:   user.ID,
1106		Service:  "pgs",
1107		Pubkey:   "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accesslogowner",
1108		Identity: "accesslogowner",
1109	}
1110
1111	err := testDB.InsertAccessLog(log)
1112	if err != nil {
1113		t.Fatalf("InsertAccessLog failed: %v", err)
1114	}
1115}
1116
1117func TestFindAccessLogs(t *testing.T) {
1118	cleanupTestData(t)
1119
1120	user, _ := testDB.RegisterUser("findaccesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findaccesslogowner", "comment", "")
1121	_ = testDB.InsertAccessLog(&db.AccessLog{
1122		UserID:   user.ID,
1123		Service:  "pgs",
1124		Pubkey:   "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findaccesslogowner",
1125		Identity: "findaccesslogowner",
1126	})
1127
1128	fromDate := time.Now().Add(-24 * time.Hour)
1129	logs, err := testDB.FindAccessLogs(user.ID, &fromDate)
1130	if err != nil {
1131		t.Fatalf("FindAccessLogs failed: %v", err)
1132	}
1133	if len(logs) != 1 {
1134		t.Errorf("expected 1 log, got %d", len(logs))
1135	}
1136}
1137
1138func TestFindPubkeysInAccessLogs(t *testing.T) {
1139	cleanupTestData(t)
1140
1141	user, _ := testDB.RegisterUser("pubkeylogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeylogowner", "comment", "")
1142	_ = testDB.InsertAccessLog(&db.AccessLog{
1143		UserID:   user.ID,
1144		Service:  "pgs",
1145		Pubkey:   "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeylogowner",
1146		Identity: "pubkeylogowner",
1147	})
1148
1149	pubkeys, err := testDB.FindPubkeysInAccessLogs(user.ID)
1150	if err != nil {
1151		t.Fatalf("FindPubkeysInAccessLogs failed: %v", err)
1152	}
1153	if len(pubkeys) != 1 {
1154		t.Errorf("expected 1 pubkey, got %d", len(pubkeys))
1155	}
1156}
1157
1158func TestFindAccessLogsByPubkey(t *testing.T) {
1159	cleanupTestData(t)
1160
1161	user, _ := testDB.RegisterUser("accessbypubkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accessbypubkeyowner", "comment", "")
1162	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accessbypubkeyowner"
1163	_ = testDB.InsertAccessLog(&db.AccessLog{
1164		UserID:   user.ID,
1165		Service:  "pgs",
1166		Pubkey:   pubkey,
1167		Identity: "accessbypubkeyowner",
1168	})
1169
1170	fromDate := time.Now().Add(-24 * time.Hour)
1171	logs, err := testDB.FindAccessLogsByPubkey(pubkey, &fromDate)
1172	if err != nil {
1173		t.Fatalf("FindAccessLogsByPubkey failed: %v", err)
1174	}
1175	if len(logs) != 1 {
1176		t.Errorf("expected 1 log, got %d", len(logs))
1177	}
1178}
1179
1180// ============ JSONB Roundtrip Tests ============
1181
1182func TestPostData_JSONBRoundtrip(t *testing.T) {
1183	cleanupTestData(t)
1184
1185	user, _ := testDB.RegisterUser("postdataowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postdataowner", "comment", "")
1186
1187	now := time.Now().Truncate(time.Second)
1188	postData := db.PostData{
1189		ImgPath:    "/images/test.png",
1190		LastDigest: &now,
1191		Attempts:   5,
1192	}
1193
1194	post := mustInsertPost(t, &db.Post{
1195		UserID:   user.ID,
1196		Filename: "jsonb.md",
1197		Slug:     "jsonb",
1198		Title:    "JSONB Test",
1199		Space:    "prose",
1200		Data:     postData,
1201	})
1202
1203	found, err := testDB.FindPost(post.ID)
1204	if err != nil {
1205		t.Fatalf("FindPost failed: %v", err)
1206	}
1207	if found.Data.ImgPath != "/images/test.png" {
1208		t.Errorf("expected ImgPath '/images/test.png', got '%s'", found.Data.ImgPath)
1209	}
1210	if found.Data.Attempts != 5 {
1211		t.Errorf("expected Attempts 5, got %d", found.Data.Attempts)
1212	}
1213}
1214
1215func TestProjectAcl_JSONBRoundtrip(t *testing.T) {
1216	cleanupTestData(t)
1217
1218	user, _ := testDB.RegisterUser("aclowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aclowner", "comment", "")
1219
1220	project, err := testDB.UpsertProject(user.ID, "acl-project", "acl-project")
1221	if err != nil {
1222		t.Fatalf("UpsertProject failed: %v", err)
1223	}
1224
1225	found, err := testDB.FindProjectByName(user.ID, "acl-project")
1226	if err != nil {
1227		t.Fatalf("FindProjectByName failed: %v", err)
1228	}
1229	if found.Acl.Type != "public" {
1230		t.Errorf("expected Acl.Type 'public', got '%s'", found.Acl.Type)
1231	}
1232	_ = project
1233}
1234
1235func TestFeatureFlagData_JSONBRoundtrip(t *testing.T) {
1236	cleanupTestData(t)
1237
1238	user, _ := testDB.RegisterUser("featurejsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featurejsonbowner", "comment", "")
1239
1240	err := testDB.AddPicoPlusUser("featurejsonbowner", "test@example.com", "stripe", "tx456")
1241	if err != nil {
1242		t.Fatalf("AddPicoPlusUser failed: %v", err)
1243	}
1244
1245	feature, err := testDB.FindFeature(user.ID, "plus")
1246	if err != nil {
1247		t.Fatalf("FindFeature failed: %v", err)
1248	}
1249	if feature.Data.StorageMax != 5000000000 {
1250		t.Errorf("expected StorageMax 5000000000, got %d", feature.Data.StorageMax)
1251	}
1252	if feature.Data.FileMax != 100000000 {
1253		t.Errorf("expected FileMax 100000000, got %d", feature.Data.FileMax)
1254	}
1255}
1256
1257func TestPaymentHistoryData_JSONBRoundtrip(t *testing.T) {
1258	cleanupTestData(t)
1259
1260	user, _ := testDB.RegisterUser("paymentjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI paymentjsonbowner", "comment", "")
1261
1262	err := testDB.AddPicoPlusUser("paymentjsonbowner", "payment@example.com", "stripe", "tx789")
1263	if err != nil {
1264		t.Fatalf("AddPicoPlusUser failed: %v", err)
1265	}
1266
1267	var txId string
1268	err = testDB.Db.QueryRow("SELECT data->>'tx_id' FROM payment_history WHERE user_id = $1", user.ID).Scan(&txId)
1269	if err != nil {
1270		t.Fatalf("failed to query payment history: %v", err)
1271	}
1272	if txId != "tx789" {
1273		t.Errorf("expected tx_id 'tx789', got '%s'", txId)
1274	}
1275}
1276
1277func TestInviteUser_PlusMember(t *testing.T) {
1278	cleanupTestData(t)
1279
1280	from, _ := testDB.RegisterUser("inviteplus", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteplus", "comment", "")
1281	to, _ := testDB.RegisterUser("inviteplusto", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteplusto", "comment", "")
1282	_, _ = testDB.InsertFeature(from.ID, "plus", time.Now().Add(365*24*time.Hour))
1283
1284	if err := testDB.InviteUser(from.ID, to.ID); err != nil {
1285		t.Fatalf("InviteUser failed: %v", err)
1286	}
1287
1288	for _, name := range []string{"prose", "pgs", "pastes"} {
1289		if !db.HasFeature(testDB, to.ID, name) {
1290			t.Errorf("expected invitee to have %s", name)
1291		}
1292	}
1293
1294	inviter, err := testDB.FindWhoInvitedUser(to.ID)
1295	if err != nil {
1296		t.Fatalf("FindWhoInvitedUser failed: %v", err)
1297	}
1298	if inviter != "inviteplus" {
1299		t.Errorf("expected inviter inviteplus, got %s", inviter)
1300	}
1301}
1302
1303func TestInviteUser_InvitedUserCanInvite(t *testing.T) {
1304	cleanupTestData(t)
1305
1306	plus, _ := testDB.RegisterUser("invitechaina", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechaina", "comment", "")
1307	mid, _ := testDB.RegisterUser("invitechainb", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechainb", "comment", "")
1308	last, _ := testDB.RegisterUser("invitechainc", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechainc", "comment", "")
1309	_, _ = testDB.InsertFeature(plus.ID, "plus", time.Now().Add(365*24*time.Hour))
1310
1311	if err := testDB.InviteUser(plus.ID, mid.ID); err != nil {
1312		t.Fatalf("InviteUser from plus failed: %v", err)
1313	}
1314	if err := testDB.InviteUser(mid.ID, last.ID); err != nil {
1315		t.Fatalf("InviteUser from invited user failed: %v", err)
1316	}
1317	if !db.HasFeature(testDB, last.ID, "pastes") {
1318		t.Error("expected second invitee to have pastes")
1319	}
1320}
1321
1322func TestInviteUser_Unauthorized(t *testing.T) {
1323	cleanupTestData(t)
1324
1325	from, _ := testDB.RegisterUser("invitenone", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitenone", "comment", "")
1326	to, _ := testDB.RegisterUser("invitenoneto", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitenoneto", "comment", "")
1327
1328	if err := testDB.InviteUser(from.ID, to.ID); err == nil {
1329		t.Fatal("expected error for user without pico+ or invite")
1330	}
1331	if db.HasFeature(testDB, to.ID, "prose") {
1332		t.Error("expected invitee not to have prose")
1333	}
1334}
1335
1336func TestInviteUser_ExpiredInvite(t *testing.T) {
1337	cleanupTestData(t)
1338
1339	plus, _ := testDB.RegisterUser("inviteexpa", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpa", "comment", "")
1340	expired, _ := testDB.RegisterUser("inviteexpb", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpb", "comment", "")
1341	to, _ := testDB.RegisterUser("inviteexpc", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpc", "comment", "")
1342
1343	_, err := testDB.Db.Exec(`INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`, plus.ID, expired.ID)
1344	if err != nil {
1345		t.Fatalf("insert invite failed: %v", err)
1346	}
1347	past := time.Now().Add(-24 * time.Hour)
1348	for _, name := range []string{"prose", "pgs", "pastes"} {
1349		_, _ = testDB.InsertFeature(expired.ID, name, past)
1350	}
1351
1352	if err := testDB.InviteUser(expired.ID, to.ID); err == nil {
1353		t.Fatal("expected error for user whose invite access expired")
1354	}
1355}