package state import ( "encoding/json" "strings" "testing" "time" ) func TestParseCatalogState(t *testing.T) { state, err := ParseCatalog([]byte(validCatalogStateJSON(t))) if err != nil { t.Fatalf("ParseCatalog() error = %v", err) } if got, want := state.SchemaVersion, CatalogSchemaVersion; got != want { t.Fatalf("schema version = %d, want %d", got, want) } if got, want := state.CreatedAtString(), "2026-06-19T12:00:00Z"; got != want { t.Fatalf("created_at = %q, want %q", got, want) } if got, want := state.UpdatedAtString(), "2026-06-19T12:05:00Z"; got != want { t.Fatalf("updated_at = %q, want %q", got, want) } if got, want := state.State.Mode, StateModeCatalog; got != want { t.Fatalf("state mode = %q, want %q", got, want) } if got, want := len(state.Outputs), 2; got != want { t.Fatalf("output count = %d, want %d", got, want) } sourceOutput := state.Outputs[0] if sourceOutput.SourcePath != "" || sourceOutput.Transform != "" { t.Fatalf("source output source_path=%q transform=%q, want omitted", sourceOutput.SourcePath, sourceOutput.Transform) } generatedOutput := state.Outputs[1] if generatedOutput.SourcePath != "report.md" || generatedOutput.Transform != "markdown_to_html" { t.Fatalf("generated output source_path=%q transform=%q", generatedOutput.SourcePath, generatedOutput.Transform) } if got, want := generatedOutput.Source.CreatedString(), "2026-05-30T11:10:00Z"; got != want { t.Fatalf("source created = %q, want %q", got, want) } } func TestCatalogMarshalIsDeterministic(t *testing.T) { data, err := json.Marshal(validCatalogState(t)) if err != nil { t.Fatalf("Marshal() error = %v", err) } want := `{"schema_version":4,"distributor_version":"dev","created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z","state":{"mode":"catalog"},"outputs":[{"path":"report.md","pipeline_id":"reports","destination_id":"archive","source":{"id":"weather.daily.brentwood.2026-05-30","digest":"sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe","created":"2026-05-30T11:10:00Z"},"kind":"source","sha256":"sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6","size":16,"created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z"},{"path":"report.html","pipeline_id":"reports","destination_id":"html","source":{"id":"weather.daily.brentwood.2026-05-30","digest":"sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe","created":"2026-05-30T11:10:00Z"},"kind":"generated","source_path":"report.md","transform":"markdown_to_html","url":"https://reports.example.com/report.html","sha256":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":128,"created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z"}]}` if string(data) != want { t.Fatalf("json = %s, want %s", data, want) } } func TestParseDocumentHandlesCatalogAndSupersededLegacy(t *testing.T) { catalog, err := ParseDocument([]byte(validCatalogStateJSON(t))) if err != nil { t.Fatalf("ParseDocument(catalog) error = %v", err) } if catalog.Catalog == nil || catalog.SingleOwner != nil || catalog.SharedRoot != nil || catalog.SupersededLegacy != nil { t.Fatalf("catalog document = %#v", catalog) } tests := map[string]string{ "schema 1": legacyStateJSON(t), "schema 2": validStateJSON(t), "schema 3": validSharedRootStateJSON(t), } for name, body := range tests { t.Run(name, func(t *testing.T) { document, err := ParseDocument([]byte(body)) if err != nil { t.Fatalf("ParseDocument() error = %v", err) } if document.SupersededLegacy == nil || document.Catalog != nil || document.SingleOwner != nil || document.SharedRoot != nil { t.Fatalf("document = %#v, want superseded legacy only", document) } if document.SupersededLegacy.SchemaVersion < legacySchemaVersion || document.SupersededLegacy.SchemaVersion >= CatalogSchemaVersion { t.Fatalf("legacy schema version = %d, want 1 through 3", document.SupersededLegacy.SchemaVersion) } }) } } func TestParseDocumentRejectsUnsupportedFutureSchema(t *testing.T) { body := strings.Replace(validCatalogStateJSON(t), `"schema_version": 4`, `"schema_version": 5`, 1) _, err := ParseDocument([]byte(body)) assertStateErrorContains(t, err, "schema_version 5 is unsupported") } func TestParseDocumentRejectsTrailingData(t *testing.T) { _, err := ParseDocument([]byte(validCatalogStateJSON(t) + "\n{}")) assertStateErrorContains(t, err, "trailing data") } func TestParseCatalogRejectsMissingFields(t *testing.T) { tests := map[string]func(map[string]any){ "schema_version": func(document map[string]any) { delete(document, "schema_version") }, "created_at": func(document map[string]any) { delete(document, "created_at") }, "updated_at": func(document map[string]any) { delete(document, "updated_at") }, "state": func(document map[string]any) { delete(document, "state") }, "outputs": func(document map[string]any) { delete(document, "outputs") }, "path": func(document map[string]any) { delete(firstCatalogOutput(document), "path") }, "pipeline_id": func(document map[string]any) { delete(firstCatalogOutput(document), "pipeline_id") }, "destination_id": func(document map[string]any) { delete(firstCatalogOutput(document), "destination_id") }, "source": func(document map[string]any) { delete(firstCatalogOutput(document), "source") }, "source id": func(document map[string]any) { delete(firstCatalogSource(document), "id") }, "source digest": func(document map[string]any) { delete(firstCatalogSource(document), "digest") }, "source created": func(document map[string]any) { delete(firstCatalogSource(document), "created") }, "kind": func(document map[string]any) { delete(firstCatalogOutput(document), "kind") }, "sha256": func(document map[string]any) { delete(firstCatalogOutput(document), "sha256") }, "size": func(document map[string]any) { delete(firstCatalogOutput(document), "size") }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { document := catalogStateObject(t) mutate(document) _, err := ParseCatalog(mustMarshalCatalogObject(t, document)) assertStateErrorContains(t, err, "required") }) } } func TestParseCatalogRejectsMalformedTimestamps(t *testing.T) { tests := map[string]func(string) string{ "created_at": func(body string) string { return strings.Replace(body, `"created_at": "2026-06-19T12:00:00Z"`, `"created_at": "June 19"`, 1) }, "updated_at": func(body string) string { return strings.Replace(body, `"updated_at": "2026-06-19T12:05:00Z"`, `"updated_at": "June 19"`, 1) }, "source created": func(body string) string { return strings.Replace(body, `"created": "2026-05-30T11:10:00Z"`, `"created": "May 30"`, 1) }, "output created_at": func(body string) string { return strings.Replace(body, ` "created_at": "2026-06-19T12:00:00Z"`, ` "created_at": "June 19"`, 1) }, "output updated_at": func(body string) string { return strings.Replace(body, ` "updated_at": "2026-06-19T12:05:00Z"`, ` "updated_at": "June 19"`, 1) }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { _, err := ParseCatalog([]byte(mutate(validCatalogStateJSON(t)))) assertStateErrorContains(t, err, "RFC3339") }) } } func TestValidateCatalogRejectsInvalidOutputRecords(t *testing.T) { tests := map[string]func(*CatalogState){ "duplicate path": func(s *CatalogState) { s.Outputs[1].Path = s.Outputs[0].Path }, "invalid output path": func(s *CatalogState) { s.Outputs[0].Path = "../report.md" }, "invalid pipeline id": func(s *CatalogState) { s.Outputs[0].PipelineID = ".reports" }, "invalid destination id": func(s *CatalogState) { s.Outputs[0].DestinationID = ".archive" }, "missing source id": func(s *CatalogState) { s.Outputs[0].Source.ID = "" }, "invalid source digest": func(s *CatalogState) { s.Outputs[0].Source.Digest = "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe" }, "missing source created": func(s *CatalogState) { s.Outputs[0].Source.Created = time.Time{} }, "invalid kind": func(s *CatalogState) { s.Outputs[0].Kind = "document" }, "generated missing source path": func(s *CatalogState) { s.Outputs[1].SourcePath = "" }, "generated invalid source path": func(s *CatalogState) { s.Outputs[1].SourcePath = "../report.md" }, "generated missing transform": func(s *CatalogState) { s.Outputs[1].Transform = "" }, "source output source path": func(s *CatalogState) { s.Outputs[0].SourcePath = "report.md" }, "source output transform": func(s *CatalogState) { s.Outputs[0].Transform = "markdown_to_html" }, "invalid output digest": func(s *CatalogState) { s.Outputs[0].SHA256 = "SHA256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6" }, "negative size": func(s *CatalogState) { s.Outputs[0].Size = -1 }, "invalid url": func(s *CatalogState) { s.Outputs[1].URL = "file:///tmp/report.html" }, "missing created at": func(s *CatalogState) { s.Outputs[0].CreatedAt = time.Time{} }, "missing updated at": func(s *CatalogState) { s.Outputs[0].UpdatedAt = time.Time{} }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { state := validCatalogState(t) mutate(&state) if err := ValidateCatalog(state); err == nil { t.Fatal("ValidateCatalog() error = nil, want error") } }) } } func TestParseCatalogRejectsForbiddenFields(t *testing.T) { tests := map[string]string{ "owners": `"owners": [],`, "sources": `"sources": [],`, "workflow": `"workflow": "additive",`, "source": `"source": {"manifest": {}},`, "manifest": `"manifest": {},`, "pipeline_id": `"pipeline_id": "reports",`, "destination_id": `"destination_id": "archive",`, "published_at": `"published_at": "2026-06-19T12:00:00Z",`, } for name, field := range tests { t.Run(name, func(t *testing.T) { body := strings.Replace(validCatalogStateJSON(t), `"created_at":`, field+"\n "+`"created_at":`, 1) _, err := ParseCatalog([]byte(body)) assertStateErrorContains(t, err, "unknown field") }) } } func TestParseCatalogRejectsForbiddenOutputFieldsForSourceOutput(t *testing.T) { tests := map[string]string{ "source_path": `"source_path": "report.md",`, "transform": `"transform": "markdown_to_html",`, "empty url": `"url": "",`, } for name, field := range tests { t.Run(name, func(t *testing.T) { body := strings.Replace(validCatalogStateJSON(t), `"kind": "source",`, `"kind": "source",`+"\n "+field, 1) _, err := ParseCatalog([]byte(body)) if err == nil { t.Fatal("ParseCatalog() error = nil, want error") } }) } } func validCatalogStateJSON(t *testing.T) string { t.Helper() data, err := json.MarshalIndent(validCatalogState(t), "", " ") if err != nil { t.Fatalf("marshal catalog state: %v", err) } return string(data) } func catalogStateObject(t *testing.T) map[string]any { t.Helper() var document map[string]any if err := json.Unmarshal([]byte(validCatalogStateJSON(t)), &document); err != nil { t.Fatalf("unmarshal catalog state: %v", err) } return document } func firstCatalogOutput(document map[string]any) map[string]any { outputs := document["outputs"].([]any) return outputs[0].(map[string]any) } func firstCatalogSource(document map[string]any) map[string]any { return firstCatalogOutput(document)["source"].(map[string]any) } func mustMarshalCatalogObject(t *testing.T, document map[string]any) []byte { t.Helper() data, err := json.Marshal(document) if err != nil { t.Fatalf("marshal catalog object: %v", err) } return data } func validCatalogState(t *testing.T) CatalogState { t.Helper() manifest := validManifest(t) createdAt := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC) updatedAt := time.Date(2026, 6, 19, 12, 5, 0, 0, time.UTC) source := CatalogSourceIdentity{ ID: manifest.ID, Digest: manifest.Digest, Created: manifest.Created, } return CatalogState{ SchemaVersion: CatalogSchemaVersion, DistributorVersion: "dev", CreatedAt: createdAt, UpdatedAt: updatedAt, State: StatePolicy{Mode: StateModeCatalog}, Outputs: []CatalogOutputFile{{ Path: "report.md", PipelineID: "reports", DestinationID: "archive", Source: source, Kind: OutputKindSource, SHA256: manifest.Files[0].SHA256, Size: manifest.Files[0].Size, CreatedAt: createdAt, UpdatedAt: updatedAt, }, { Path: "report.html", PipelineID: "reports", DestinationID: "html", Source: source, Kind: OutputKindGenerated, SourcePath: "report.md", Transform: "markdown_to_html", URL: "https://reports.example.com/report.html", SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 128, CreatedAt: createdAt, UpdatedAt: updatedAt, }}, } }