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:
2026-03-21 09:56:34 -07:00
parent bb2c7f7ef3
commit b64177baa8
22 changed files with 5017 additions and 1 deletions

503
artifacts/artifacts_test.go Normal file
View 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)
}
}