Files
silo/internal/kc/kc_test.go
Forbes dd010331c0 feat(kc): commit extraction pipeline + metadata API (Phase 1)
Implements issue #141 — .kc server-side metadata integration Phase 1.

When a .kc file is uploaded, the server extracts silo/manifest.json and
silo/metadata.json from the ZIP archive and indexes them into the
item_metadata table. Plain .fcstd files continue to work unchanged.
Extraction is best-effort: failures are logged but do not block the upload.

New packages:
- internal/kc: ZIP extraction library (Extract, Manifest, Metadata types)
- internal/db: ItemMetadataRepository (Get, Upsert, UpdateFields,
  UpdateLifecycle, SetTags)

New API endpoints under /api/items/{partNumber}:
- GET    /metadata           — read indexed metadata (viewer)
- PUT    /metadata           — merge fields into JSONB (editor)
- PATCH  /metadata/lifecycle — transition lifecycle state (editor)
- PATCH  /metadata/tags      — add/remove tags (editor)

SSE events: metadata.updated, metadata.lifecycle, metadata.tags

Lifecycle transitions (Phase 1): draft→review→released→obsolete,
review→draft (reject).

Closes #141
2026-02-18 16:37:39 -06:00

189 lines
4.9 KiB
Go

package kc
import (
"archive/zip"
"bytes"
"encoding/json"
"testing"
)
// buildZip creates a ZIP archive in memory from a map of filename → content.
func buildZip(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for name, content := range files {
f, err := w.Create(name)
if err != nil {
t.Fatalf("creating zip entry %s: %v", name, err)
}
if _, err := f.Write(content); err != nil {
t.Fatalf("writing zip entry %s: %v", name, err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("closing zip: %v", err)
}
return buf.Bytes()
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshaling JSON: %v", err)
}
return data
}
func TestExtract_PlainFCStd(t *testing.T) {
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"thumbnails/a.png": []byte("png"),
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Fatalf("expected nil result for plain .fcstd, got %+v", result)
}
}
func TestExtract_ValidKC(t *testing.T) {
manifest := Manifest{
UUID: "550e8400-e29b-41d4-a716-446655440000",
KCVersion: "1.0",
RevisionHash: "abc123",
SiloInstance: "https://silo.example.com",
}
metadata := Metadata{
SchemaName: "mechanical-part-v2",
Tags: []string{"structural", "aluminum"},
LifecycleState: "draft",
Fields: map[string]any{
"material": "6061-T6",
"weight_kg": 0.34,
},
}
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/manifest.json": mustJSON(t, manifest),
"silo/metadata.json": mustJSON(t, metadata),
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Manifest == nil {
t.Fatal("expected manifest")
}
if result.Manifest.UUID != manifest.UUID {
t.Errorf("manifest UUID = %q, want %q", result.Manifest.UUID, manifest.UUID)
}
if result.Manifest.KCVersion != manifest.KCVersion {
t.Errorf("manifest KCVersion = %q, want %q", result.Manifest.KCVersion, manifest.KCVersion)
}
if result.Manifest.RevisionHash != manifest.RevisionHash {
t.Errorf("manifest RevisionHash = %q, want %q", result.Manifest.RevisionHash, manifest.RevisionHash)
}
if result.Manifest.SiloInstance != manifest.SiloInstance {
t.Errorf("manifest SiloInstance = %q, want %q", result.Manifest.SiloInstance, manifest.SiloInstance)
}
if result.Metadata == nil {
t.Fatal("expected metadata")
}
if result.Metadata.SchemaName != metadata.SchemaName {
t.Errorf("metadata SchemaName = %q, want %q", result.Metadata.SchemaName, metadata.SchemaName)
}
if result.Metadata.LifecycleState != metadata.LifecycleState {
t.Errorf("metadata LifecycleState = %q, want %q", result.Metadata.LifecycleState, metadata.LifecycleState)
}
if len(result.Metadata.Tags) != 2 {
t.Errorf("metadata Tags len = %d, want 2", len(result.Metadata.Tags))
}
if result.Metadata.Fields["material"] != "6061-T6" {
t.Errorf("metadata Fields[material] = %v, want 6061-T6", result.Metadata.Fields["material"])
}
}
func TestExtract_ManifestOnly(t *testing.T) {
manifest := Manifest{
UUID: "550e8400-e29b-41d4-a716-446655440000",
KCVersion: "1.0",
}
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/manifest.json": mustJSON(t, manifest),
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Manifest == nil {
t.Fatal("expected manifest")
}
if result.Metadata != nil {
t.Errorf("expected nil metadata, got %+v", result.Metadata)
}
}
func TestExtract_InvalidJSON(t *testing.T) {
data := buildZip(t, map[string][]byte{
"silo/manifest.json": []byte("{not valid json"),
})
result, err := Extract(data)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if result != nil {
t.Errorf("expected nil result on error, got %+v", result)
}
}
func TestExtract_NotAZip(t *testing.T) {
result, err := Extract([]byte("this is not a zip file"))
if err == nil {
t.Fatal("expected error for non-ZIP data")
}
if result != nil {
t.Errorf("expected nil result on error, got %+v", result)
}
}
func TestExtract_EmptySiloDir(t *testing.T) {
// silo/ directory entry exists but no manifest or metadata files
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/": {},
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result for silo/ dir")
}
if result.Manifest != nil {
t.Errorf("expected nil manifest, got %+v", result.Manifest)
}
if result.Metadata != nil {
t.Errorf("expected nil metadata, got %+v", result.Metadata)
}
}