diff --git a/internal/api/handlers.go b/internal/api/handlers.go index f7666d5..bdcceb6 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -53,6 +53,7 @@ type Server struct { modules *modules.Registry cfg *config.Config settings *db.SettingsRepository + metadata *db.ItemMetadataRepository } // NewServer creates a new API server. @@ -81,6 +82,7 @@ func NewServer( jobs := db.NewJobRepository(database) settings := db.NewSettingsRepository(database) locations := db.NewLocationRepository(database) + metadata := db.NewItemMetadataRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -109,6 +111,7 @@ func NewServer( modules: registry, cfg: cfg, settings: settings, + metadata: metadata, } } @@ -1652,6 +1655,9 @@ func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) { Int64("size", result.Size). Msg("file uploaded") + // .kc metadata extraction (best-effort) + s.extractKCMetadata(ctx, item, fileKey, rev) + writeJSON(w, http.StatusCreated, revisionToResponse(rev)) } diff --git a/internal/api/metadata_handlers.go b/internal/api/metadata_handlers.go new file mode 100644 index 0000000..82850e4 --- /dev/null +++ b/internal/api/metadata_handlers.go @@ -0,0 +1,419 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/kc" +) + +// validTransitions defines allowed lifecycle state transitions for Phase 1. +var validTransitions = map[string][]string{ + "draft": {"review"}, + "review": {"draft", "released"}, + "released": {"obsolete"}, + "obsolete": {}, +} + +// MetadataResponse is the JSON representation returned by GET /metadata. +type MetadataResponse struct { + SchemaName *string `json:"schema_name"` + LifecycleState string `json:"lifecycle_state"` + Tags []string `json:"tags"` + Fields map[string]any `json:"fields"` + Manifest *ManifestInfo `json:"manifest,omitempty"` + UpdatedAt string `json:"updated_at"` + UpdatedBy *string `json:"updated_by,omitempty"` +} + +// ManifestInfo is the manifest subset included in MetadataResponse. +type ManifestInfo struct { + UUID *string `json:"uuid,omitempty"` + SiloInstance *string `json:"silo_instance,omitempty"` + RevisionHash *string `json:"revision_hash,omitempty"` + KCVersion *string `json:"kc_version,omitempty"` +} + +func metadataToResponse(m *db.ItemMetadata) MetadataResponse { + resp := MetadataResponse{ + SchemaName: m.SchemaName, + LifecycleState: m.LifecycleState, + Tags: m.Tags, + Fields: m.Fields, + UpdatedAt: m.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedBy: m.UpdatedBy, + } + if m.ManifestUUID != nil || m.SiloInstance != nil || m.RevisionHash != nil || m.KCVersion != nil { + resp.Manifest = &ManifestInfo{ + UUID: m.ManifestUUID, + SiloInstance: m.SiloInstance, + RevisionHash: m.RevisionHash, + KCVersion: m.KCVersion, + } + } + return resp +} + +// HandleGetMetadata returns indexed metadata for an item. +// GET /api/items/{partNumber}/metadata +func (s *Server) HandleGetMetadata(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + meta, err := s.metadata.Get(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get metadata") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata") + return + } + if meta == nil { + writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item") + return + } + + writeJSON(w, http.StatusOK, metadataToResponse(meta)) +} + +// HandleUpdateMetadata merges fields into the metadata JSONB. +// PUT /api/items/{partNumber}/metadata +func (s *Server) HandleUpdateMetadata(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req struct { + Fields map[string]any `json:"fields"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + if len(req.Fields) == 0 { + writeError(w, http.StatusBadRequest, "invalid_body", "Fields must not be empty") + return + } + + username := "" + if user := auth.UserFromContext(ctx); user != nil { + username = user.Username + } + + if err := s.metadata.UpdateFields(ctx, item.ID, req.Fields, username); err != nil { + s.logger.Error().Err(err).Msg("failed to update metadata fields") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata") + return + } + + meta, err := s.metadata.Get(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to read back metadata") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read metadata") + return + } + + s.broker.Publish("metadata.updated", mustMarshal(map[string]any{ + "part_number": partNumber, + "changed_fields": fieldKeys(req.Fields), + "lifecycle_state": meta.LifecycleState, + "updated_by": username, + })) + + writeJSON(w, http.StatusOK, metadataToResponse(meta)) +} + +// HandleUpdateLifecycle transitions the lifecycle state. +// PATCH /api/items/{partNumber}/metadata/lifecycle +func (s *Server) HandleUpdateLifecycle(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req struct { + State string `json:"state"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + if req.State == "" { + writeError(w, http.StatusBadRequest, "invalid_body", "State is required") + return + } + + meta, err := s.metadata.Get(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get metadata") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata") + return + } + if meta == nil { + writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item") + return + } + + // Validate transition + allowed := validTransitions[meta.LifecycleState] + valid := false + for _, s := range allowed { + if s == req.State { + valid = true + break + } + } + if !valid { + writeError(w, http.StatusUnprocessableEntity, "invalid_transition", + "Cannot transition from '"+meta.LifecycleState+"' to '"+req.State+"'") + return + } + + username := "" + if user := auth.UserFromContext(ctx); user != nil { + username = user.Username + } + + fromState := meta.LifecycleState + if err := s.metadata.UpdateLifecycle(ctx, item.ID, req.State, username); err != nil { + s.logger.Error().Err(err).Msg("failed to update lifecycle") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update lifecycle") + return + } + + s.broker.Publish("metadata.lifecycle", mustMarshal(map[string]any{ + "part_number": partNumber, + "from_state": fromState, + "to_state": req.State, + "updated_by": username, + })) + + writeJSON(w, http.StatusOK, map[string]string{"lifecycle_state": req.State}) +} + +// HandleUpdateTags adds/removes tags. +// PATCH /api/items/{partNumber}/metadata/tags +func (s *Server) HandleUpdateTags(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req struct { + Add []string `json:"add"` + Remove []string `json:"remove"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + if len(req.Add) == 0 && len(req.Remove) == 0 { + writeError(w, http.StatusBadRequest, "invalid_body", "Must provide 'add' or 'remove'") + return + } + + meta, err := s.metadata.Get(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get metadata") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata") + return + } + if meta == nil { + writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item") + return + } + + // Compute new tag set: (existing + add) - remove + tagSet := make(map[string]struct{}) + for _, t := range meta.Tags { + tagSet[t] = struct{}{} + } + for _, t := range req.Add { + tagSet[t] = struct{}{} + } + removeSet := make(map[string]struct{}) + for _, t := range req.Remove { + removeSet[t] = struct{}{} + } + var newTags []string + for t := range tagSet { + if _, removed := removeSet[t]; !removed { + newTags = append(newTags, t) + } + } + if newTags == nil { + newTags = []string{} + } + + username := "" + if user := auth.UserFromContext(ctx); user != nil { + username = user.Username + } + + if err := s.metadata.SetTags(ctx, item.ID, newTags, username); err != nil { + s.logger.Error().Err(err).Msg("failed to update tags") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update tags") + return + } + + s.broker.Publish("metadata.tags", mustMarshal(map[string]any{ + "part_number": partNumber, + "added": req.Add, + "removed": req.Remove, + })) + + writeJSON(w, http.StatusOK, map[string]any{"tags": newTags}) +} + +// extractKCMetadata attempts to extract and index silo/ metadata from an +// uploaded .kc file. Failures are logged but non-fatal for Phase 1. +func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey string, rev *db.Revision) { + if s.storage == nil { + return + } + + reader, err := s.storage.Get(ctx, fileKey) + if err != nil { + s.logger.Warn().Err(err).Str("file_key", fileKey).Msg("kc: failed to read back file for extraction") + return + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + s.logger.Warn().Err(err).Msg("kc: failed to read file bytes") + return + } + + result, err := kc.Extract(data) + if err != nil { + s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: extraction failed") + return + } + if result == nil { + return // plain .fcstd, no silo/ directory + } + + // Validate manifest UUID matches item + if result.Manifest != nil && result.Manifest.UUID != "" && result.Manifest.UUID != item.ID { + s.logger.Warn(). + Str("manifest_uuid", result.Manifest.UUID). + Str("item_id", item.ID). + Msg("kc: manifest UUID does not match item, skipping indexing") + return + } + + // Check for no-op (revision_hash unchanged) + if result.Manifest != nil && result.Manifest.RevisionHash != "" { + existing, _ := s.metadata.Get(ctx, item.ID) + if existing != nil && existing.RevisionHash != nil && *existing.RevisionHash == result.Manifest.RevisionHash { + s.logger.Debug().Str("part_number", item.PartNumber).Msg("kc: revision_hash unchanged, skipping") + return + } + } + + username := "" + if rev.CreatedBy != nil { + username = *rev.CreatedBy + } + + meta := &db.ItemMetadata{ + ItemID: item.ID, + LifecycleState: "draft", + Fields: make(map[string]any), + Tags: []string{}, + UpdatedBy: strPtr(username), + } + + if result.Manifest != nil { + meta.KCVersion = strPtr(result.Manifest.KCVersion) + meta.ManifestUUID = strPtr(result.Manifest.UUID) + meta.SiloInstance = strPtr(result.Manifest.SiloInstance) + meta.RevisionHash = strPtr(result.Manifest.RevisionHash) + } + + if result.Metadata != nil { + meta.SchemaName = strPtr(result.Metadata.SchemaName) + if result.Metadata.Tags != nil { + meta.Tags = result.Metadata.Tags + } + if result.Metadata.LifecycleState != "" { + meta.LifecycleState = result.Metadata.LifecycleState + } + if result.Metadata.Fields != nil { + meta.Fields = result.Metadata.Fields + } + } + + if err := s.metadata.Upsert(ctx, meta); err != nil { + s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to upsert metadata") + return + } + + s.broker.Publish("metadata.updated", mustMarshal(map[string]any{ + "part_number": item.PartNumber, + "lifecycle_state": meta.LifecycleState, + "updated_by": username, + })) + + s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully") +} + +// strPtr returns a pointer to s, or nil if s is empty. +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +// fieldKeys returns the keys from a map. +func fieldKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 178999d..14f3b3d 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -172,6 +172,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/bom/where-used", server.HandleGetWhereUsed) r.Get("/bom/export.csv", server.HandleExportBOMCSV) r.Get("/bom/export.ods", server.HandleExportBOMODS) + r.Get("/metadata", server.HandleGetMetadata) // DAG (gated by dag module) r.Route("/dag", func(r chi.Router) { @@ -209,6 +210,9 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Post("/bom/merge", server.HandleMergeBOM) r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry) + r.Put("/metadata", server.HandleUpdateMetadata) + r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle) + r.Patch("/metadata/tags", server.HandleUpdateTags) }) }) }) diff --git a/internal/db/item_metadata.go b/internal/db/item_metadata.go new file mode 100644 index 0000000..41d107b --- /dev/null +++ b/internal/db/item_metadata.go @@ -0,0 +1,161 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ItemMetadata represents a row in the item_metadata table. +type ItemMetadata struct { + ItemID string + SchemaName *string + Tags []string + LifecycleState string + Fields map[string]any + KCVersion *string + ManifestUUID *string + SiloInstance *string + RevisionHash *string + UpdatedAt time.Time + UpdatedBy *string +} + +// ItemMetadataRepository provides item_metadata database operations. +type ItemMetadataRepository struct { + db *DB +} + +// NewItemMetadataRepository creates a new item metadata repository. +func NewItemMetadataRepository(db *DB) *ItemMetadataRepository { + return &ItemMetadataRepository{db: db} +} + +// Get returns metadata for an item, or nil if none exists. +func (r *ItemMetadataRepository) Get(ctx context.Context, itemID string) (*ItemMetadata, error) { + m := &ItemMetadata{} + var fieldsJSON []byte + err := r.db.pool.QueryRow(ctx, ` + SELECT item_id, schema_name, tags, lifecycle_state, fields, + kc_version, manifest_uuid, silo_instance, revision_hash, + updated_at, updated_by + FROM item_metadata + WHERE item_id = $1 + `, itemID).Scan( + &m.ItemID, &m.SchemaName, &m.Tags, &m.LifecycleState, &fieldsJSON, + &m.KCVersion, &m.ManifestUUID, &m.SiloInstance, &m.RevisionHash, + &m.UpdatedAt, &m.UpdatedBy, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting item metadata: %w", err) + } + if fieldsJSON != nil { + if err := json.Unmarshal(fieldsJSON, &m.Fields); err != nil { + return nil, fmt.Errorf("unmarshaling fields: %w", err) + } + } + if m.Fields == nil { + m.Fields = make(map[string]any) + } + if m.Tags == nil { + m.Tags = []string{} + } + return m, nil +} + +// Upsert inserts or updates the metadata row for an item. +// Used by the commit extraction pipeline. +func (r *ItemMetadataRepository) Upsert(ctx context.Context, m *ItemMetadata) error { + fieldsJSON, err := json.Marshal(m.Fields) + if err != nil { + return fmt.Errorf("marshaling fields: %w", err) + } + _, err = r.db.pool.Exec(ctx, ` + INSERT INTO item_metadata + (item_id, schema_name, tags, lifecycle_state, fields, + kc_version, manifest_uuid, silo_instance, revision_hash, + updated_at, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), $10) + ON CONFLICT (item_id) DO UPDATE SET + schema_name = EXCLUDED.schema_name, + tags = EXCLUDED.tags, + lifecycle_state = EXCLUDED.lifecycle_state, + fields = EXCLUDED.fields, + kc_version = EXCLUDED.kc_version, + manifest_uuid = EXCLUDED.manifest_uuid, + silo_instance = EXCLUDED.silo_instance, + revision_hash = EXCLUDED.revision_hash, + updated_at = now(), + updated_by = EXCLUDED.updated_by + `, m.ItemID, m.SchemaName, m.Tags, m.LifecycleState, fieldsJSON, + m.KCVersion, m.ManifestUUID, m.SiloInstance, m.RevisionHash, + m.UpdatedBy) + if err != nil { + return fmt.Errorf("upserting item metadata: %w", err) + } + return nil +} + +// UpdateFields merges the given fields into the existing JSONB fields column. +func (r *ItemMetadataRepository) UpdateFields(ctx context.Context, itemID string, fields map[string]any, updatedBy string) error { + fieldsJSON, err := json.Marshal(fields) + if err != nil { + return fmt.Errorf("marshaling fields: %w", err) + } + tag, err := r.db.pool.Exec(ctx, ` + UPDATE item_metadata + SET fields = fields || $2::jsonb, + updated_at = now(), + updated_by = $3 + WHERE item_id = $1 + `, itemID, fieldsJSON, updatedBy) + if err != nil { + return fmt.Errorf("updating metadata fields: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("item metadata not found") + } + return nil +} + +// UpdateLifecycle sets the lifecycle_state column. +func (r *ItemMetadataRepository) UpdateLifecycle(ctx context.Context, itemID, state, updatedBy string) error { + tag, err := r.db.pool.Exec(ctx, ` + UPDATE item_metadata + SET lifecycle_state = $2, + updated_at = now(), + updated_by = $3 + WHERE item_id = $1 + `, itemID, state, updatedBy) + if err != nil { + return fmt.Errorf("updating lifecycle state: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("item metadata not found") + } + return nil +} + +// SetTags replaces the tags array. +func (r *ItemMetadataRepository) SetTags(ctx context.Context, itemID string, tags []string, updatedBy string) error { + tag, err := r.db.pool.Exec(ctx, ` + UPDATE item_metadata + SET tags = $2, + updated_at = now(), + updated_by = $3 + WHERE item_id = $1 + `, itemID, tags, updatedBy) + if err != nil { + return fmt.Errorf("updating tags: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("item metadata not found") + } + return nil +} diff --git a/internal/kc/kc.go b/internal/kc/kc.go new file mode 100644 index 0000000..f1d02ec --- /dev/null +++ b/internal/kc/kc.go @@ -0,0 +1,106 @@ +// Package kc extracts and parses the silo/ metadata directory from .kc files. +// +// A .kc file is a ZIP archive (superset of .fcstd) that contains a silo/ +// directory with JSON metadata entries. This package handles extraction only — +// no database or HTTP dependencies. +package kc + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "strings" +) + +// Manifest represents the contents of silo/manifest.json. +type Manifest struct { + UUID string `json:"uuid"` + KCVersion string `json:"kc_version"` + RevisionHash string `json:"revision_hash"` + SiloInstance string `json:"silo_instance"` +} + +// Metadata represents the contents of silo/metadata.json. +type Metadata struct { + SchemaName string `json:"schema_name"` + Tags []string `json:"tags"` + LifecycleState string `json:"lifecycle_state"` + Fields map[string]any `json:"fields"` +} + +// ExtractResult holds the parsed silo/ directory contents from a .kc file. +type ExtractResult struct { + Manifest *Manifest + Metadata *Metadata +} + +// Extract opens a ZIP archive from data and parses the silo/ directory. +// Returns nil, nil if no silo/ directory is found (plain .fcstd file). +// Returns nil, error if silo/ entries exist but fail to parse. +func Extract(data []byte) (*ExtractResult, error) { + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("kc: open zip: %w", err) + } + + var manifestFile, metadataFile *zip.File + hasSiloDir := false + + for _, f := range r.File { + if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") { + hasSiloDir = true + } + switch f.Name { + case "silo/manifest.json": + manifestFile = f + case "silo/metadata.json": + metadataFile = f + } + } + + if !hasSiloDir { + return nil, nil // plain .fcstd, no extraction + } + + result := &ExtractResult{} + + if manifestFile != nil { + m, err := readJSON[Manifest](manifestFile) + if err != nil { + return nil, fmt.Errorf("kc: parse manifest.json: %w", err) + } + result.Manifest = m + } + + if metadataFile != nil { + m, err := readJSON[Metadata](metadataFile) + if err != nil { + return nil, fmt.Errorf("kc: parse metadata.json: %w", err) + } + result.Metadata = m + } + + return result, nil +} + +// readJSON opens a zip.File and decodes its contents as JSON into T. +func readJSON[T any](f *zip.File) (*T, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + var v T + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return &v, nil +} diff --git a/internal/kc/kc_test.go b/internal/kc/kc_test.go new file mode 100644 index 0000000..9c740d4 --- /dev/null +++ b/internal/kc/kc_test.go @@ -0,0 +1,188 @@ +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(""), + "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(""), + "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(""), + "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(""), + "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) + } +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 4029cdc..6381a1c 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -79,6 +79,7 @@ func TruncateAll(t *testing.T, pool *pgxpool.Pool) { _, err := pool.Exec(context.Background(), ` TRUNCATE + item_metadata, item_dependencies, approval_signatures, item_approvals, item_macros, settings_overrides, module_state, job_log, jobs, job_definitions, runners, dag_cross_edges, dag_edges, dag_nodes,