package savedsearch_test import ( "context" "testing" "time" "go.bigb.es/auxilia/culpa" _ "modernc.org/sqlite" "sourcecraft.dev/bigbes/lethe/internal/config" "sourcecraft.dev/bigbes/lethe/internal/domain/savedsearch" "sourcecraft.dev/bigbes/lethe/internal/platform/database" ) // newTestDatabase builds a Database steward against :memory: (one DB per // test, isolated). Cleanup runs Destroy. func newTestDatabase(t *testing.T) *database.Database { t.Helper() d := &database.Database{ Cfg: config.DatabaseConfig{ Path: ":memory:", BusyTimeout: 5 * time.Second, }, } if err := d.Init(context.Background()); err != nil { t.Fatalf("database.Init: %v", err) } t.Cleanup(func() { _ = d.Destroy(context.Background()) }) return d } // newRepo wires a Repository against a fresh in-memory database. func newRepo(t *testing.T) *savedsearch.Repository { t.Helper() d := newTestDatabase(t) repo := &savedsearch.Repository{Database: d} if err := repo.Init(context.Background()); err != nil { t.Fatalf("repo.Init: %v", err) } return repo } // codeOf walks the culpa chain for a CodeDetail and returns the string code, // or "" if there isn't one. func codeOf(err error) string { var cd culpa.CodeDetail if !culpa.FindDetail(err, &cd) { return "" } s, ok := cd.Code.(string) if !ok { return "" } return s } func ptrStr(s string) *string { return &s } // TestList_EmptyDB verifies that List on an empty DB returns a non-nil, // zero-length slice (JSON [] rather than null). func TestList_EmptyDB(t *testing.T) { repo := newRepo(t) got, err := repo.List(context.Background(), "alice") if err != nil { t.Fatalf("List: %v", err) } if got == nil { t.Fatal("expected non-nil slice; got nil") } if len(got) != 0 { t.Fatalf("expected zero-length slice; got len=%d", len(got)) } } // TestCreate_ThenList verifies that a Created row is returned by List with // correct field values and that created_at == updated_at == the injected now. func TestCreate_ThenList(t *testing.T) { repo := newRepo(t) now := int64(1700000000) s := savedsearch.SavedSearch{ Owner: "alice", Name: "my search", Query: "model:gpt-4", CreatedAt: now, UpdatedAt: now, } if err := repo.Create(context.Background(), s); err != nil { t.Fatalf("Create: %v", err) } rows, err := repo.List(context.Background(), "alice") if err != nil { t.Fatalf("List: %v", err) } if len(rows) != 1 { t.Fatalf("expected 1 row; got %d", len(rows)) } got := rows[0] if got.Name != "my search" { t.Errorf("Name: got %q; want %q", got.Name, "my search") } if got.Query != "model:gpt-4" { t.Errorf("Query: got %q; want %q", got.Query, "model:gpt-4") } if got.CreatedAt != now { t.Errorf("CreatedAt: got %d; want %d", got.CreatedAt, now) } if got.UpdatedAt != now { t.Errorf("UpdatedAt: got %d; want %d", got.UpdatedAt, now) } // Owner is stored in DB but json:"-"; verify the struct still has it. if got.Owner != "alice" { t.Errorf("Owner: got %q; want %q", got.Owner, "alice") } } // TestCreate_Duplicate verifies that a second Create with the same (owner, name) // returns a CONFLICT-coded error. func TestCreate_Duplicate(t *testing.T) { repo := newRepo(t) now := int64(1700000000) s := savedsearch.SavedSearch{ Owner: "alice", Name: "dupe", Query: "q1", CreatedAt: now, UpdatedAt: now, } if err := repo.Create(context.Background(), s); err != nil { t.Fatalf("first Create: %v", err) } s.Query = "q2" err := repo.Create(context.Background(), s) if err == nil { t.Fatal("expected CONFLICT error; got nil") } if code := codeOf(err); code != "CONFLICT" { t.Fatalf("expected code CONFLICT; got %q", code) } } // TestCreate_SameNameDifferentOwner verifies that the same name can be used by // two different owners without conflict. func TestCreate_SameNameDifferentOwner(t *testing.T) { repo := newRepo(t) now := int64(1700000000) if err := repo.Create(context.Background(), savedsearch.SavedSearch{ Owner: "alice", Name: "shared", Query: "q1", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("alice Create: %v", err) } if err := repo.Create(context.Background(), savedsearch.SavedSearch{ Owner: "bob", Name: "shared", Query: "q2", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("bob Create: %v", err) } aliceRows, _ := repo.List(context.Background(), "alice") bobRows, _ := repo.List(context.Background(), "bob") if len(aliceRows) != 1 { t.Errorf("alice: expected 1 row; got %d", len(aliceRows)) } if len(bobRows) != 1 { t.Errorf("bob: expected 1 row; got %d", len(bobRows)) } } // TestUpdate_NotFound verifies that updating a non-existent (owner, name) // returns a NOT_FOUND-coded error. func TestUpdate_NotFound(t *testing.T) { repo := newRepo(t) _, err := repo.Update(context.Background(), "alice", "missing", nil, ptrStr("newq"), 1700000001) if err == nil { t.Fatal("expected NOT_FOUND error; got nil") } if code := codeOf(err); code != "NOT_FOUND" { t.Fatalf("expected code NOT_FOUND; got %q", code) } } // TestUpdate_RenameConflict verifies that renaming onto an existing name for // the same owner returns a CONFLICT-coded error. func TestUpdate_RenameConflict(t *testing.T) { repo := newRepo(t) now := int64(1700000000) if err := repo.Create(context.Background(), savedsearch.SavedSearch{ Owner: "alice", Name: "a", Query: "q1", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create a: %v", err) } if err := repo.Create(context.Background(), savedsearch.SavedSearch{ Owner: "alice", Name: "b", Query: "q2", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create b: %v", err) } // Try to rename "a" to "b" (which already exists for alice). _, err := repo.Update(context.Background(), "alice", "a", ptrStr("b"), nil, now+1) if err == nil { t.Fatal("expected CONFLICT error; got nil") } if code := codeOf(err); code != "CONFLICT" { t.Fatalf("expected code CONFLICT; got %q", code) } } // TestUpdate_QueryOnly verifies that passing newName=nil only updates the query // and updated_at, leaving the name unchanged. func TestUpdate_QueryOnly(t *testing.T) { repo := newRepo(t) now := int64(1700000000) if err := repo.Create(context.Background(), savedsearch.SavedSearch{ Owner: "alice", Name: "myq", Query: "old query", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create: %v", err) } later := now + 100 updated, err := repo.Update(context.Background(), "alice", "myq", nil, ptrStr("new query"), later) if err != nil { t.Fatalf("Update: %v", err) } if updated.Name != "myq" { t.Errorf("Name should be unchanged: got %q; want %q", updated.Name, "myq") } if updated.Query != "new query" { t.Errorf("Query: got %q; want %q", updated.Query, "new query") } if updated.UpdatedAt != later { t.Errorf("UpdatedAt: got %d; want %d", updated.UpdatedAt, later) } // created_at should remain the original. if updated.CreatedAt != now { t.Errorf("CreatedAt should be unchanged: got %d; want %d", updated.CreatedAt, now) } } // TestDelete_NotFound verifies that deleting a non-existent row returns a // NOT_FOUND-coded error. func TestDelete_NotFound(t *testing.T) { repo := newRepo(t) err := repo.Delete(context.Background(), "alice", "ghost") if err == nil { t.Fatal("expected NOT_FOUND error; got nil") } if code := codeOf(err); code != "NOT_FOUND" { t.Fatalf("expected code NOT_FOUND; got %q", code) } } // TestDelete_ExistingRow verifies that deleting an existing row succeeds and // that the row is absent on a subsequent List. func TestDelete_ExistingRow(t *testing.T) { repo := newRepo(t) now := int64(1700000000) if err := repo.Create(context.Background(), savedsearch.SavedSearch{ Owner: "alice", Name: "bye", Query: "q", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("Create: %v", err) } if err := repo.Delete(context.Background(), "alice", "bye"); err != nil { t.Fatalf("Delete: %v", err) } rows, err := repo.List(context.Background(), "alice") if err != nil { t.Fatalf("List after Delete: %v", err) } if len(rows) != 0 { t.Fatalf("expected 0 rows after delete; got %d", len(rows)) } // Subsequent delete should be NOT_FOUND. err = repo.Delete(context.Background(), "alice", "bye") if err == nil { t.Fatal("expected NOT_FOUND on second delete; got nil") } if code := codeOf(err); code != "NOT_FOUND" { t.Fatalf("expected NOT_FOUND; got %q", code) } } // TestList_OrderByUpdatedAtDesc verifies that rows are returned newest first // according to updated_at. func TestList_OrderByUpdatedAtDesc(t *testing.T) { repo := newRepo(t) base := int64(1700000000) // Insert three rows with staggered updated_at values. for i, name := range []string{"first", "second", "third"} { ts := base + int64(i)*100 // first=base, second=base+100, third=base+200 if err := repo.Create(context.Background(), savedsearch.SavedSearch{ Owner: "alice", Name: name, Query: "q", CreatedAt: ts, UpdatedAt: ts, }); err != nil { t.Fatalf("Create %s: %v", name, err) } } rows, err := repo.List(context.Background(), "alice") if err != nil { t.Fatalf("List: %v", err) } if len(rows) != 3 { t.Fatalf("expected 3 rows; got %d", len(rows)) } // Expected order: third (newest), second, first (oldest). if rows[0].Name != "third" || rows[1].Name != "second" || rows[2].Name != "first" { t.Fatalf("unexpected order: %s, %s, %s", rows[0].Name, rows[1].Name, rows[2].Name) } }