package artifacts import ( "context" "database/sql" "path/filepath" "testing" "time" "git.wntrmute.dev/kyle/exo/blob" "git.wntrmute.dev/kyle/exo/core" "git.wntrmute.dev/kyle/exo/db" ) func mustOpenAndMigrate(t *testing.T) *sql.DB { t.Helper() path := filepath.Join(t.TempDir(), "test.db") database, err := db.Open(path) if err != nil { t.Fatalf("Open failed: %v", err) } t.Cleanup(func() { _ = database.Close() }) if err := db.Migrate(database); err != nil { t.Fatalf("Migrate failed: %v", err) } return database } func mustTX(t *testing.T, database *sql.DB) (*sql.Tx, context.Context) { t.Helper() ctx := context.Background() tx, err := db.StartTX(ctx, database) if err != nil { t.Fatalf("StartTX failed: %v", err) } return tx, ctx } // --- Tag tests --- func TestCreateAndGetTag(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) if err := CreateTag(ctx, tx, "golang"); err != nil { t.Fatalf("CreateTag failed: %v", err) } id, err := GetTag(ctx, tx, "golang") if err != nil { t.Fatalf("GetTag failed: %v", err) } if id == "" { t.Fatal("tag should exist after creation") } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestCreateTagIdempotent(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) if err := CreateTag(ctx, tx, "dup"); err != nil { t.Fatalf("first CreateTag failed: %v", err) } if err := CreateTag(ctx, tx, "dup"); err != nil { t.Fatalf("second CreateTag should be idempotent: %v", err) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestGetAllTags(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) for _, tag := range []string{"zebra", "alpha", "mid"} { if err := CreateTag(ctx, tx, tag); err != nil { t.Fatalf("CreateTag %q failed: %v", tag, err) } } tags, err := GetAllTags(ctx, tx) if err != nil { t.Fatalf("GetAllTags failed: %v", err) } if len(tags) != 3 { t.Fatalf("expected 3 tags, got %d", len(tags)) } // Should be sorted. if tags[0] != "alpha" || tags[1] != "mid" || tags[2] != "zebra" { t.Fatalf("tags not sorted: %v", tags) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestGetTagMissing(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) id, err := GetTag(ctx, tx, "nonexistent") if err != nil { t.Fatalf("GetTag should not error for missing tag: %v", err) } if id != "" { t.Fatalf("missing tag should return empty ID, got %q", id) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Category tests --- func TestCreateAndGetCategory(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) if err := CreateCategory(ctx, tx, "cs/systems"); err != nil { t.Fatalf("CreateCategory failed: %v", err) } id, err := GetCategory(ctx, tx, "cs/systems") if err != nil { t.Fatalf("GetCategory failed: %v", err) } if id == "" { t.Fatal("category should exist after creation") } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestGetAllCategories(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) for _, cat := range []string{"z/last", "a/first", "m/mid"} { if err := CreateCategory(ctx, tx, cat); err != nil { t.Fatalf("CreateCategory %q failed: %v", cat, err) } } cats, err := GetAllCategories(ctx, tx) if err != nil { t.Fatalf("GetAllCategories failed: %v", err) } if len(cats) != 3 { t.Fatalf("expected 3 categories, got %d", len(cats)) } if cats[0] != "a/first" { t.Fatalf("categories not sorted: %v", cats) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Publisher tests --- func TestPublisherStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) pub := &Publisher{Name: "MIT Press", Address: "Cambridge, MA"} if err := pub.Store(ctx, tx); err != nil { t.Fatalf("Publisher.Store failed: %v", err) } if pub.ID == "" { t.Fatal("publisher ID should be set after store") } got := &Publisher{ID: pub.ID} ok, err := got.Get(ctx, tx) if err != nil { t.Fatalf("Publisher.Get failed: %v", err) } if !ok { t.Fatal("publisher should be found") } if got.Name != "MIT Press" || got.Address != "Cambridge, MA" { t.Fatalf("publisher data mismatch: %+v", got) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestPublisherStoreDedup(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) pub1 := &Publisher{Name: "ACM", Address: "New York"} if err := pub1.Store(ctx, tx); err != nil { t.Fatalf("first Store failed: %v", err) } pub2 := &Publisher{Name: "ACM", Address: "New York"} if err := pub2.Store(ctx, tx); err != nil { t.Fatalf("second Store failed: %v", err) } if pub1.ID != pub2.ID { t.Fatalf("duplicate publisher should reuse ID: %q vs %q", pub1.ID, pub2.ID) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Citation tests --- func TestCitationStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) cite := &Citation{ Title: "The Art of Computer Programming", DOI: "10.1234/test", Year: 1968, Published: time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC), Authors: []string{"Donald Knuth"}, Publisher: &Publisher{Name: "Addison-Wesley", Address: "Reading, MA"}, Source: "https://example.com", Abstract: "A comprehensive monograph on algorithms.", Metadata: core.Metadata{"edition": core.Val("3rd")}, } if err := cite.Store(ctx, tx); err != nil { t.Fatalf("Citation.Store failed: %v", err) } if cite.ID == "" { t.Fatal("citation ID should be set after store") } got := &Citation{ID: cite.ID} if err := got.Get(ctx, tx); err != nil { t.Fatalf("Citation.Get failed: %v", err) } if got.Title != cite.Title { t.Fatalf("title mismatch: got %q, want %q", got.Title, cite.Title) } if got.DOI != cite.DOI { t.Fatalf("DOI mismatch: got %q, want %q", got.DOI, cite.DOI) } if got.Year != cite.Year { t.Fatalf("year mismatch: got %d, want %d", got.Year, cite.Year) } if len(got.Authors) != 1 || got.Authors[0] != "Donald Knuth" { t.Fatalf("authors mismatch: %v", got.Authors) } if got.Publisher.Name != "Addison-Wesley" { t.Fatalf("publisher mismatch: %+v", got.Publisher) } if got.Metadata["edition"].Contents != "3rd" { t.Fatalf("metadata mismatch: %v", got.Metadata) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Metadata tests --- func TestMetadataStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) id := core.NewUUID() meta := core.Metadata{ "key1": core.Val("value1"), "key2": core.Vals("value2"), } if err := StoreMetadata(ctx, tx, id, meta); err != nil { t.Fatalf("StoreMetadata failed: %v", err) } got, err := GetMetadata(ctx, tx, id) if err != nil { t.Fatalf("GetMetadata failed: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 metadata entries, got %d", len(got)) } if got["key1"].Contents != "value1" { t.Fatalf("key1 mismatch: %+v", got["key1"]) } if got["key2"].Type != core.ValueTypeString { t.Fatalf("key2 type mismatch: %+v", got["key2"]) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Full artifact round-trip --- func TestArtifactStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) blobStore := blob.NewStore(t.TempDir()) // Create tags and categories first. for _, tag := range []string{"algorithms", "textbook"} { if err := CreateTag(ctx, tx, tag); err != nil { t.Fatalf("CreateTag failed: %v", err) } } if err := CreateCategory(ctx, tx, "cs/fundamentals"); err != nil { t.Fatalf("CreateCategory failed: %v", err) } snapID := core.NewUUID() artID := core.NewUUID() now := time.Now().UTC().Truncate(time.Second) art := &Artifact{ ID: artID, Type: ArtifactTypeBook, Citation: &Citation{ Title: "TAOCP", Year: 1968, Published: time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC), Authors: []string{"Donald Knuth"}, Publisher: &Publisher{Name: "Addison-Wesley", Address: "Boston"}, Source: "https://example.com/taocp", }, Latest: now, History: map[time.Time]string{now: snapID}, Tags: map[string]bool{"algorithms": true, "textbook": true}, Categories: map[string]bool{"cs/fundamentals": true}, Metadata: core.Metadata{"volume": core.Val("1")}, } if err := art.Store(ctx, tx); err != nil { t.Fatalf("Artifact.Store failed: %v", err) } // Store a snapshot. snap := &Snapshot{ ArtifactID: artID, ID: snapID, StoreDate: now, Datetime: now, Citation: art.Citation, Source: "local import", Blobs: map[MIME]*BlobRef{ "application/pdf": { Format: "application/pdf", Data: []byte("fake PDF content"), }, }, Metadata: core.Metadata{}, } if err := snap.Store(ctx, tx, blobStore); err != nil { t.Fatalf("Snapshot.Store failed: %v", err) } // Retrieve and verify the artifact. got := &Artifact{ID: artID} if err := got.Get(ctx, tx); err != nil { t.Fatalf("Artifact.Get failed: %v", err) } if got.Type != ArtifactTypeBook { t.Fatalf("type mismatch: got %q, want %q", got.Type, ArtifactTypeBook) } if got.Citation.Title != "TAOCP" { t.Fatalf("citation title mismatch: %q", got.Citation.Title) } if !got.Tags["algorithms"] || !got.Tags["textbook"] { t.Fatalf("tags mismatch: %v", got.Tags) } if !got.Categories["cs/fundamentals"] { t.Fatalf("categories mismatch: %v", got.Categories) } if len(got.History) != 1 { t.Fatalf("expected 1 history entry, got %d", len(got.History)) } // Retrieve and verify the snapshot. gotSnap := &Snapshot{ID: snapID} if err := gotSnap.Get(ctx, tx); err != nil { t.Fatalf("Snapshot.Get failed: %v", err) } if gotSnap.ArtifactID != artID { t.Fatalf("snapshot artifact ID mismatch: %q", gotSnap.ArtifactID) } if len(gotSnap.Blobs) != 1 { t.Fatalf("expected 1 blob, got %d", len(gotSnap.Blobs)) } pdfBlob, ok := gotSnap.Blobs["application/pdf"] if !ok { t.Fatal("missing PDF blob reference") } if pdfBlob.ID == "" { t.Fatal("blob ID should be set") } // Verify blob content via store. data, err := blobStore.Read(pdfBlob.ID) if err != nil { t.Fatalf("blob store Read failed: %v", err) } if string(data) != "fake PDF content" { t.Fatalf("blob content mismatch: %q", data) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestGetArtifactIDsForTag(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) if err := CreateTag(ctx, tx, "search-tag"); err != nil { t.Fatalf("CreateTag failed: %v", err) } artID := core.NewUUID() art := &Artifact{ ID: artID, Type: ArtifactTypeArticle, Citation: &Citation{ Title: "Test Article", Year: 2024, Published: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), Publisher: &Publisher{Name: "Test", Address: ""}, Source: "test", }, Latest: time.Now().UTC().Truncate(time.Second), History: map[time.Time]string{}, Tags: map[string]bool{"search-tag": true}, Categories: map[string]bool{}, Metadata: core.Metadata{}, } if err := art.Store(ctx, tx); err != nil { t.Fatalf("Artifact.Store failed: %v", err) } ids, err := GetArtifactIDsForTag(ctx, tx, "search-tag") if err != nil { t.Fatalf("GetArtifactIDsForTag failed: %v", err) } if len(ids) != 1 || ids[0] != artID { t.Fatalf("expected [%s], got %v", artID, ids) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestCitationUpdate(t *testing.T) { base := &Citation{ DOI: "10.1234/base", Title: "Base Title", Year: 2020, Authors: []string{"Author A"}, Metadata: core.Metadata{"key": core.Val("base-val")}, } c := &Citation{ Title: "Override Title", Metadata: core.Metadata{}, } c.Update(base) if c.DOI != "10.1234/base" { t.Fatalf("DOI should inherit from base: %q", c.DOI) } if c.Title != "Override Title" { t.Fatalf("Title should not be overridden: %q", c.Title) } if c.Year != 2020 { t.Fatalf("Year should inherit from base: %d", c.Year) } if c.Metadata["key"].Contents != "base-val" { t.Fatalf("Metadata should inherit from base: %v", c.Metadata) } }