package state import ( "encoding/json" "os" "strings" "testing" "time" "gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/config" ) func TestParseValidState(t *testing.T) { state, err := Parse([]byte(validStateJSON(t))) if err != nil { t.Fatalf("Parse() error = %v", err) } if state.SchemaVersion != SchemaVersion { t.Fatalf("schema version = %d, want %d", state.SchemaVersion, SchemaVersion) } if state.PipelineID != "reports" || state.DestinationID != "archive" { t.Fatalf("identity = %q/%q", state.PipelineID, state.DestinationID) } if got, want := state.PublishedAtString(), "2026-05-30T11:12:00Z"; got != want { t.Fatalf("PublishedAtString() = %q, want %q", got, want) } if got, want := state.CreatedAtString(), "2026-05-30T11:12:00Z"; got != want { t.Fatalf("CreatedAtString() = %q, want %q", got, want) } if got, want := state.UpdatedAtString(), "2026-05-30T11:12:00Z"; got != want { t.Fatalf("UpdatedAtString() = %q, want %q", got, want) } if got, want := state.State.Mode, StateModeSingleOwner; got != want { t.Fatalf("state mode = %q, want %q", got, want) } if got, want := state.Reconciliation.Mode, config.ReconciliationModeReplace; got != want { t.Fatalf("reconciliation mode = %q, want %q", got, want) } if got, want := len(state.Outputs), 1; got != want { t.Fatalf("output count = %d, want %d", got, want) } if got, want := state.Outputs[0].CreatedAtString(), "2026-05-30T11:12:00Z"; got != want { t.Fatalf("output CreatedAtString() = %q, want %q", got, want) } } func TestParseValidStateWithLinks(t *testing.T) { body := strings.Replace(validStateJSON(t), `"outputs": [`, `"links": {"primary_url": "https://reports.example.com/archive/report.md"},`+"\n "+`"outputs": [`, 1) body = strings.Replace(body, `"source_path": "report.md",`, `"source_path": "report.md",`+"\n "+`"url": "https://reports.example.com/archive/report.md",`, 1) state, err := Parse([]byte(body)) if err != nil { t.Fatalf("Parse() error = %v", err) } if state.Links == nil || state.Links.PrimaryURL != "https://reports.example.com/archive/report.md" { t.Fatalf("links = %#v, want primary URL", state.Links) } if state.Outputs[0].URL != "https://reports.example.com/archive/report.md" { t.Fatalf("output URL = %q", state.Outputs[0].URL) } } func TestRemoveMissingOutputs(t *testing.T) { state, err := Parse([]byte(validStateJSON(t))) if err != nil { t.Fatalf("Parse() error = %v", err) } state.Outputs = append(state.Outputs, OutputFile{ Path: "summary.txt", Kind: OutputKindSource, SourcePath: "summary.txt", SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 10, CreatedAt: state.CreatedAt, UpdatedAt: state.UpdatedAt, }) next, changed := RemoveMissingOutputs(state, []string{"summary.txt"}) if !changed { t.Fatal("RemoveMissingOutputs() changed = false, want true") } if got, want := ManagedOutputPaths(next), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("paths = %#v, want %#v", got, want) } unchanged, changed := RemoveMissingOutputs(next, []string{"missing.txt"}) if changed { t.Fatal("RemoveMissingOutputs() changed = true, want false") } if got, want := ManagedOutputPaths(unchanged), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("unchanged paths = %#v, want %#v", got, want) } } func TestParseNormalizesPublishedAtOffset(t *testing.T) { body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "2026-05-30T13:12:00+02:00"`, 1) state, err := Parse([]byte(body)) if err != nil { t.Fatalf("Parse() error = %v", err) } if got, want := state.PublishedAtString(), "2026-05-30T11:12:00Z"; got != want { t.Fatalf("PublishedAtString() = %q, want %q", got, want) } } func TestParseRejectsMissingFields(t *testing.T) { tests := map[string]string{ "schema_version": `"schema_version"`, "pipeline_id": `"pipeline_id"`, "destination_id": `"destination_id"`, "published_at": `"published_at"`, "created_at": `"created_at"`, "updated_at": `"updated_at"`, "state": `"state"`, "reconciliation": `"reconciliation"`, "source": `"source"`, "outputs": `"outputs"`, } for name, field := range tests { t.Run(name, func(t *testing.T) { body := strings.Replace(validStateJSON(t), field, `"missing_`+name+`"`, 1) _, err := Parse([]byte(body)) assertStateErrorContains(t, err, "required") }) } } func TestParseRejectsInvalidSchemaVersion(t *testing.T) { body := strings.Replace(validStateJSON(t), `"schema_version": 2`, `"schema_version": 3`, 1) _, err := Parse([]byte(body)) assertStateErrorContains(t, err, "schema_version must be 1 or 2") } func TestParseLegacyStateInfersSingleOwnerDefaults(t *testing.T) { state, err := Parse([]byte(legacyStateJSON(t))) if err != nil { t.Fatalf("Parse() error = %v", err) } if got, want := state.SchemaVersion, SchemaVersion; got != want { t.Fatalf("schema version = %d, want normalized %d", got, want) } if got, want := state.CreatedAtString(), "2026-05-30T11:12:00Z"; got != want { t.Fatalf("created_at = %q, want %q", got, want) } if got, want := state.State.Mode, StateModeSingleOwner; got != want { t.Fatalf("state mode = %q, want %q", got, want) } if got, want := state.Reconciliation.Mode, config.ReconciliationModeReplace; got != want { t.Fatalf("reconciliation mode = %q, want %q", got, want) } if got, want := state.Outputs[0].UpdatedAtString(), "2026-05-30T11:12:00Z"; got != want { t.Fatalf("output updated_at = %q, want %q", got, want) } } func TestParseRejectsInvalidEmbeddedManifest(t *testing.T) { body := validStateWithManifestJSON(t, strings.Replace(manifestJSON(t), `"schema_version": 1`, `"schema_version": 2`, 1)) _, err := Parse([]byte(body)) assertStateErrorContains(t, err, "source.manifest") } func TestValidateRejectsInvalidEmbeddedManifest(t *testing.T) { tests := map[string]func(bundle.Manifest) bundle.Manifest{ "schema version": func(manifest bundle.Manifest) bundle.Manifest { manifest.SchemaVersion = 2 return manifest }, "empty id": func(manifest bundle.Manifest) bundle.Manifest { manifest.ID = "" return manifest }, "bad digest": func(manifest bundle.Manifest) bundle.Manifest { manifest.Digest = "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe" return manifest }, "zero created": func(manifest bundle.Manifest) bundle.Manifest { manifest.Created = time.Time{} return manifest }, "empty files": func(manifest bundle.Manifest) bundle.Manifest { manifest.Files = nil manifest.Digest = bundle.BundleDigest(manifest.Files) return manifest }, "unsafe path": func(manifest bundle.Manifest) bundle.Manifest { manifest.Files[0].Path = "../report.md" manifest.Digest = bundle.BundleDigest(manifest.Files) return manifest }, "duplicate path": func(manifest bundle.Manifest) bundle.Manifest { manifest.Files[1].Path = manifest.Files[0].Path manifest.Digest = bundle.BundleDigest(manifest.Files) return manifest }, "negative size": func(manifest bundle.Manifest) bundle.Manifest { manifest.Files[0].Size = -1 manifest.Digest = bundle.BundleDigest(manifest.Files) return manifest }, "digest mismatch": func(manifest bundle.Manifest) bundle.Manifest { manifest.Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" return manifest }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { source := mutate(validManifest(t)) state := *withState(t, validManifest(t), func(*DistributorState) {}) state.Source.Manifest = source err := Validate(state) assertStateErrorContains(t, err, "source.manifest") }) } } func TestParseRejectsInvalidOutputMetadata(t *testing.T) { source := validManifest(t) tests := map[string]func(*DistributorState){ "unsafe path": func(s *DistributorState) { s.Outputs[0].Path = "../report.md" }, "invalid kind": func(s *DistributorState) { s.Outputs[0].Kind = "other" }, "invalid source": func(s *DistributorState) { s.Outputs[0].SourcePath = "../report.md" }, "generated missing": func(s *DistributorState) { s.Outputs[0].Kind = OutputKindGenerated }, "invalid digest": func(s *DistributorState) { s.Outputs[0].SHA256 = "SHA256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6" }, "negative size": func(s *DistributorState) { s.Outputs[0].Size = -1 }, "missing output created_at": func(s *DistributorState) { s.Outputs[0].CreatedAt = time.Time{} }, "missing output updated_at": func(s *DistributorState) { s.Outputs[0].UpdatedAt = time.Time{} }, "invalid output url": func(s *DistributorState) { s.Outputs[0].URL = "file:///tmp/report.md" }, "invalid primary url": func(s *DistributorState) { s.Links = &LinkState{PrimaryURL: "file:///tmp/report.md"} }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { state := *withState(t, source, mutate) err := Validate(state) if err == nil { t.Fatal("Validate() error = nil, want error") } }) } } func TestValidateReportsURLFieldContext(t *testing.T) { source := validManifest(t) state := *withState(t, source, func(s *DistributorState) { s.Links = &LinkState{PrimaryURL: "https://reports.example.com/archive#top"} }) err := Validate(state) assertStateErrorContains(t, err, "state links.primary_url") assertStateErrorContains(t, err, "must not include a fragment") state = *withState(t, source, func(s *DistributorState) { s.Outputs[0].URL = "https://reports.example.com/archive?preview=1" }) err = Validate(state) assertStateErrorContains(t, err, "state outputs[0].url") assertStateErrorContains(t, err, "must not include a query string") } func TestParseRejectsMalformedPublishedTimestamp(t *testing.T) { body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "May 30"`, 1) _, err := Parse([]byte(body)) assertStateErrorContains(t, err, "published_at must be RFC3339") } func TestParseRejectsMalformedCreatedTimestamp(t *testing.T) { body := strings.Replace(validStateJSON(t), `"created_at": "2026-05-30T11:12:00Z"`, `"created_at": "May 30"`, 1) _, err := Parse([]byte(body)) assertStateErrorContains(t, err, "created_at must be RFC3339") } func TestMarshalNormalizesPublishedAtUTC(t *testing.T) { source := validManifest(t) publishedAt := time.Date(2026, 5, 30, 13, 12, 0, 0, time.FixedZone("offset", 2*60*60)) state := DistributorState{ SchemaVersion: SchemaVersion, PipelineID: "reports", DestinationID: "archive", PublishedAt: publishedAt, CreatedAt: publishedAt, UpdatedAt: publishedAt, State: StatePolicy{Mode: StateModeSingleOwner}, Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeReplace}, Source: SourceState{Manifest: source}, Outputs: []OutputFile{{ Path: "report.md", Kind: OutputKindSource, SourcePath: "report.md", SHA256: source.Files[0].SHA256, Size: source.Files[0].Size, CreatedAt: publishedAt, UpdatedAt: publishedAt, }}, } data, err := json.Marshal(state) if err != nil { t.Fatalf("Marshal() error = %v", err) } if !strings.Contains(string(data), `"published_at":"2026-05-30T11:12:00Z"`) { t.Fatalf("json = %s, want UTC RFC3339 published_at", data) } } func TestMarshalIncludesLinksWhenPresent(t *testing.T) { source := validManifest(t) publishedAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC) state := DistributorState{ SchemaVersion: SchemaVersion, PipelineID: "reports", DestinationID: "archive", PublishedAt: publishedAt, CreatedAt: publishedAt, UpdatedAt: publishedAt, State: StatePolicy{Mode: StateModeSingleOwner}, Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeReplace}, Source: SourceState{Manifest: source}, Links: &LinkState{PrimaryURL: "https://reports.example.com/archive/report.md"}, Outputs: []OutputFile{{ Path: "report.md", Kind: OutputKindSource, SourcePath: "report.md", URL: "https://reports.example.com/archive/report.md", SHA256: source.Files[0].SHA256, Size: source.Files[0].Size, CreatedAt: publishedAt, UpdatedAt: publishedAt, }}, } data, err := json.Marshal(state) if err != nil { t.Fatalf("Marshal() error = %v", err) } if !strings.Contains(string(data), `"links":{"primary_url":"https://reports.example.com/archive/report.md"}`) { t.Fatalf("json = %s, want links primary URL", data) } if !strings.Contains(string(data), `"url":"https://reports.example.com/archive/report.md"`) { t.Fatalf("json = %s, want output URL", data) } } func TestOutputHelpers(t *testing.T) { createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC) updatedAt := createdAt.Add(time.Hour) retained := []OutputFile{{ Path: "old.md", Kind: OutputKindSource, CreatedAt: createdAt, UpdatedAt: createdAt, }, { Path: "report.md", Kind: OutputKindSource, CreatedAt: createdAt, UpdatedAt: createdAt, }} projected := ProjectOutputs([]OutputProjection{{ Path: "report.md", Kind: OutputKindSource, }, { Path: "new.md", Kind: OutputKindSource, }}, retained, updatedAt) if got, ok := FindOutputByPath(projected, "report.md"); !ok || !got.CreatedAt.Equal(createdAt) || !got.UpdatedAt.Equal(updatedAt) { t.Fatalf("projected report.md = %#v, want preserved created_at and updated updated_at", got) } merged, err := MergeOutputFiles(retained, projected) if err != nil { t.Fatalf("MergeOutputFiles() error = %v", err) } if got, want := len(merged), 3; got != want { t.Fatalf("merged count = %d, want %d", got, want) } if got, want := ManagedOutputPaths(DistributorState{Outputs: merged}), []string{"old.md", "report.md", "new.md"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("managed paths = %#v, want %#v", got, want) } } func validStateJSON(t *testing.T) string { t.Helper() return validStateWithManifestJSON(t, manifestJSON(t)) } func validStateWithManifestJSON(t *testing.T, manifest string) string { t.Helper() return `{ "schema_version": 2, "distributor_version": "dev", "pipeline_id": "reports", "destination_id": "archive", "published_at": "2026-05-30T11:12:00Z", "created_at": "2026-05-30T11:12:00Z", "updated_at": "2026-05-30T11:12:00Z", "state": {"mode": "single_owner"}, "reconciliation": {"mode": "replace"}, "source": { "manifest": ` + manifest + ` }, "outputs": [ { "path": "report.md", "kind": "source", "source_path": "report.md", "sha256": "sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6", "size": 16, "created_at": "2026-05-30T11:12:00Z", "updated_at": "2026-05-30T11:12:00Z" } ] }` } func legacyStateJSON(t *testing.T) string { t.Helper() return strings.Replace(strings.Replace(strings.Replace(strings.Replace(strings.Replace(strings.Replace(validStateJSON(t), `"schema_version": 2`, `"schema_version": 1`, 1), ` "created_at": "2026-05-30T11:12:00Z", `, "", 1), ` "updated_at": "2026-05-30T11:12:00Z", `, "", 1), ` "state": {"mode": "single_owner"}, `, "", 1), ` "reconciliation": {"mode": "replace"}, `, "", 1), `, "created_at": "2026-05-30T11:12:00Z", "updated_at": "2026-05-30T11:12:00Z"`, "", 1) } func manifestJSON(t *testing.T) string { t.Helper() data, err := os.ReadFile("../bundle/testdata/valid_bundle/manifest.json") if err != nil { t.Fatalf("read manifest fixture: %v", err) } return string(data) } func validManifest(t *testing.T) bundle.Manifest { t.Helper() manifest, err := bundle.ParseManifest([]byte(manifestJSON(t))) if err != nil { t.Fatalf("ParseManifest() error = %v", err) } return manifest } func assertStateErrorContains(t *testing.T, err error, want string) { t.Helper() if err == nil { t.Fatalf("error = nil, want substring %q", want) } if !strings.Contains(err.Error(), want) { t.Fatalf("error = %q, want substring %q", err.Error(), want) } }