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}