Implements issue #142 — .kc checkout pipeline that repacks silo/ entries with current DB state before serving downloads. When a client downloads a .kc file via GET /api/items/{pn}/file/{rev}, the server now: 1. Reads the file from storage into memory 2. Checks for silo/ directory (plain .fcstd files bypass packing) 3. Repacks silo/ entries with current item_metadata + revision history 4. Streams the repacked ZIP to the client New files: - internal/kc/pack.go: Pack() replaces silo/ entries in ZIP, preserving all non-silo entries (FreeCAD files, thumbnails) with original compression and timestamps. HasSiloDir() for lightweight detection. - internal/api/pack_handlers.go: packKCFile server helper, computeETag, canSkipRepack lazy optimization. ETag caching: - ETag computed from revision_number + metadata.updated_at - If-None-Match support returns 304 Not Modified before reading storage - Cache-Control: private, must-revalidate Lazy packing optimization: - Skips repack if revision_hash matches and metadata unchanged since upload Phase 2 packs: manifest.json, metadata.json, history.json, dependencies.json (empty []). Approvals, macros, jobs deferred to Phase 3-5. Closes #142
230 lines
6.5 KiB
Go
230 lines
6.5 KiB
Go
package kc
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"testing"
|
|
)
|
|
|
|
func TestHasSiloDir_PlainFCStd(t *testing.T) {
|
|
data := buildZip(t, map[string][]byte{
|
|
"Document.xml": []byte("<xml/>"),
|
|
})
|
|
has, err := HasSiloDir(data)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("expected false for plain .fcstd")
|
|
}
|
|
}
|
|
|
|
func TestHasSiloDir_KC(t *testing.T) {
|
|
data := buildZip(t, map[string][]byte{
|
|
"Document.xml": []byte("<xml/>"),
|
|
"silo/manifest.json": []byte("{}"),
|
|
})
|
|
has, err := HasSiloDir(data)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !has {
|
|
t.Fatal("expected true for .kc with silo/ dir")
|
|
}
|
|
}
|
|
|
|
func TestHasSiloDir_NotAZip(t *testing.T) {
|
|
_, err := HasSiloDir([]byte("not a zip"))
|
|
if err == nil {
|
|
t.Fatal("expected error for non-ZIP data")
|
|
}
|
|
}
|
|
|
|
func TestPack_PlainFCStd_Passthrough(t *testing.T) {
|
|
original := buildZip(t, map[string][]byte{
|
|
"Document.xml": []byte("<xml/>"),
|
|
"thumbnails/a.png": []byte("png-data"),
|
|
})
|
|
|
|
result, err := Pack(original, &PackInput{
|
|
Manifest: &Manifest{UUID: "test"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !bytes.Equal(result, original) {
|
|
t.Fatal("expected original bytes returned unchanged for plain .fcstd")
|
|
}
|
|
}
|
|
|
|
func TestPack_RoundTrip(t *testing.T) {
|
|
// Build a .kc with old silo/ data
|
|
oldManifest := Manifest{UUID: "old-uuid", KCVersion: "0.9", RevisionHash: "old-hash"}
|
|
oldMetadata := Metadata{SchemaName: "old-schema", Tags: []string{"old"}, LifecycleState: "draft"}
|
|
|
|
original := buildZip(t, map[string][]byte{
|
|
"Document.xml": []byte("<freecad/>"),
|
|
"thumbnails/t.png": []byte("thumb-data"),
|
|
"silo/manifest.json": mustJSON(t, oldManifest),
|
|
"silo/metadata.json": mustJSON(t, oldMetadata),
|
|
})
|
|
|
|
// Pack with new data
|
|
newManifest := &Manifest{UUID: "new-uuid", KCVersion: "1.0", RevisionHash: "new-hash", SiloInstance: "https://silo.test"}
|
|
newMetadata := &Metadata{SchemaName: "mechanical-part-v2", Tags: []string{"aluminum", "structural"}, LifecycleState: "review", Fields: map[string]any{"material": "7075-T6"}}
|
|
comment := "initial commit"
|
|
history := []HistoryEntry{
|
|
{RevisionNumber: 1, CreatedAt: "2026-01-01T00:00:00Z", Comment: &comment, Status: "draft", Labels: []string{}},
|
|
}
|
|
|
|
packed, err := Pack(original, &PackInput{
|
|
Manifest: newManifest,
|
|
Metadata: newMetadata,
|
|
History: history,
|
|
Dependencies: []any{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Pack error: %v", err)
|
|
}
|
|
|
|
// Extract and verify new silo/ data
|
|
result, err := Extract(packed)
|
|
if err != nil {
|
|
t.Fatalf("Extract error: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil extract result")
|
|
}
|
|
if result.Manifest.UUID != "new-uuid" {
|
|
t.Errorf("manifest UUID = %q, want %q", result.Manifest.UUID, "new-uuid")
|
|
}
|
|
if result.Manifest.KCVersion != "1.0" {
|
|
t.Errorf("manifest KCVersion = %q, want %q", result.Manifest.KCVersion, "1.0")
|
|
}
|
|
if result.Manifest.SiloInstance != "https://silo.test" {
|
|
t.Errorf("manifest SiloInstance = %q, want %q", result.Manifest.SiloInstance, "https://silo.test")
|
|
}
|
|
if result.Metadata.SchemaName != "mechanical-part-v2" {
|
|
t.Errorf("metadata SchemaName = %q, want %q", result.Metadata.SchemaName, "mechanical-part-v2")
|
|
}
|
|
if result.Metadata.LifecycleState != "review" {
|
|
t.Errorf("metadata LifecycleState = %q, want %q", result.Metadata.LifecycleState, "review")
|
|
}
|
|
if len(result.Metadata.Tags) != 2 {
|
|
t.Errorf("metadata Tags len = %d, want 2", len(result.Metadata.Tags))
|
|
}
|
|
if result.Metadata.Fields["material"] != "7075-T6" {
|
|
t.Errorf("metadata Fields[material] = %v, want 7075-T6", result.Metadata.Fields["material"])
|
|
}
|
|
|
|
// Verify non-silo entries are preserved
|
|
r, err := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
|
|
if err != nil {
|
|
t.Fatalf("opening packed ZIP: %v", err)
|
|
}
|
|
entryMap := make(map[string]bool)
|
|
for _, f := range r.File {
|
|
entryMap[f.Name] = true
|
|
}
|
|
if !entryMap["Document.xml"] {
|
|
t.Error("Document.xml missing from packed ZIP")
|
|
}
|
|
if !entryMap["thumbnails/t.png"] {
|
|
t.Error("thumbnails/t.png missing from packed ZIP")
|
|
}
|
|
|
|
// Verify non-silo content is byte-identical
|
|
for _, f := range r.File {
|
|
if f.Name == "Document.xml" {
|
|
content := readZipEntry(t, f)
|
|
if string(content) != "<freecad/>" {
|
|
t.Errorf("Document.xml content = %q, want %q", content, "<freecad/>")
|
|
}
|
|
}
|
|
if f.Name == "thumbnails/t.png" {
|
|
content := readZipEntry(t, f)
|
|
if string(content) != "thumb-data" {
|
|
t.Errorf("thumbnails/t.png content = %q, want %q", content, "thumb-data")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPack_NilFields(t *testing.T) {
|
|
original := buildZip(t, map[string][]byte{
|
|
"Document.xml": []byte("<xml/>"),
|
|
"silo/manifest.json": []byte(`{"uuid":"x"}`),
|
|
})
|
|
|
|
// Pack with only manifest, nil metadata/history/deps
|
|
packed, err := Pack(original, &PackInput{
|
|
Manifest: &Manifest{UUID: "updated"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Pack error: %v", err)
|
|
}
|
|
|
|
// Extract — should have manifest but no metadata
|
|
result, err := Extract(packed)
|
|
if err != nil {
|
|
t.Fatalf("Extract error: %v", err)
|
|
}
|
|
if result.Manifest == nil || result.Manifest.UUID != "updated" {
|
|
t.Errorf("manifest UUID = %v, want updated", result.Manifest)
|
|
}
|
|
if result.Metadata != nil {
|
|
t.Errorf("expected nil metadata, got %+v", result.Metadata)
|
|
}
|
|
|
|
// Verify no old silo/ entries leaked through
|
|
r, _ := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
|
|
for _, f := range r.File {
|
|
if f.Name == "silo/metadata.json" {
|
|
t.Error("old silo/metadata.json should have been removed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPack_EmptyDependencies(t *testing.T) {
|
|
original := buildZip(t, map[string][]byte{
|
|
"silo/manifest.json": []byte(`{"uuid":"x"}`),
|
|
})
|
|
|
|
packed, err := Pack(original, &PackInput{
|
|
Manifest: &Manifest{UUID: "x"},
|
|
Dependencies: []any{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Pack error: %v", err)
|
|
}
|
|
|
|
// Verify dependencies.json exists and is []
|
|
r, _ := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
|
|
for _, f := range r.File {
|
|
if f.Name == "silo/dependencies.json" {
|
|
content := readZipEntry(t, f)
|
|
if string(content) != "[]" {
|
|
t.Errorf("dependencies.json = %q, want %q", content, "[]")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Error("silo/dependencies.json not found in packed ZIP")
|
|
}
|
|
|
|
// readZipEntry reads the full contents of a zip.File.
|
|
func readZipEntry(t *testing.T, f *zip.File) []byte {
|
|
t.Helper()
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("opening zip entry %s: %v", f.Name, err)
|
|
}
|
|
defer rc.Close()
|
|
data, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("reading zip entry %s: %v", f.Name, err)
|
|
}
|
|
return data
|
|
}
|