Add Phase 2 artifact repository: types, blob store, gRPC service
Build the complete artifact pillar with five packages: - artifacts: Artifact, Snapshot, Citation, Publisher types with Get/Store DB methods, tag/category management, metadata ops, YAML import - blob: content-addressable store (SHA256, hierarchical dir layout) - proto: protobuf definitions (common.proto, artifacts.proto) with buf linting and code generation - server: gRPC ArtifactService implementation (create/get artifacts, store/retrieve blobs, manage tags/categories, search by tag) All FK insertion ordering is correct (parent rows before children). Full test coverage across artifacts, blob, and server packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
503
artifacts/artifacts_test.go
Normal file
503
artifacts/artifacts_test.go
Normal file
@@ -0,0 +1,503 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user