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
189 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|