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