package state import ( "encoding/json" "strings" "testing" "time" "gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/config" ) func TestParseSharedRootState(t *testing.T) { state, err := ParseSharedRoot([]byte(validSharedRootStateJSON(t))) if err != nil { t.Fatalf("ParseSharedRoot() error = %v", err) } if got, want := state.SchemaVersion, SharedRootSchemaVersion; got != want { t.Fatalf("schema version = %d, want %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, StateModeSharedRoot; got != want { t.Fatalf("state mode = %q, want %q", got, want) } if got, want := len(state.Owners), 2; got != want { t.Fatalf("owner count = %d, want %d", got, want) } if got, want := len(state.Outputs), 2; got != want { t.Fatalf("output count = %d, want %d", got, want) } scope := CurrentOwnerScope("reports", "archive") manifest, ok := state.SourceManifest(scope) if !ok { t.Fatal("SourceManifest() ok = false, want true") } if got, want := manifest.ID, validManifest(t).ID; got != want { t.Fatalf("source manifest id = %q, want %q", got, want) } } func TestParseSharedRootRejectsInvalidMetadata(t *testing.T) { tests := map[string]func(string) string{ "schema": func(body string) string { return strings.Replace(body, `"schema_version": 3`, `"schema_version": 2`, 1) }, "state mode": func(body string) string { return strings.Replace(body, `"mode": "shared_root"`, `"mode": "single_owner"`, 1) }, "duplicate owner": func(body string) string { return strings.Replace(body, `"destination_id": "html"`, `"destination_id": "archive"`, 1) }, "unknown output owner": func(body string) string { return strings.Replace(body, `"destination_id": "html",`, `"destination_id": "missing",`, 1) }, "duplicate output": func(body string) string { return strings.Replace(body, `"path": "report.html"`, `"path": "report.md"`, 1) }, "invalid source digest": func(body string) string { return strings.Replace(body, `"source_digest": "sha256:`, `"source_digest": "SHA256:`, 1) }, "invalid owner link": func(body string) string { return strings.Replace(body, `"primary_url": "https://reports.example.com/archive/report.md"`, `"primary_url": "file:///tmp/report.md"`, 1) }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { _, err := ParseSharedRoot([]byte(mutate(validSharedRootStateJSON(t)))) if err == nil { t.Fatal("ParseSharedRoot() error = nil, want error") } }) } } func TestSharedRootMarshalNormalizesTimestamps(t *testing.T) { state := validSharedRootState(t) state.CreatedAt = time.Date(2026, 5, 30, 13, 12, 0, 0, time.FixedZone("offset", 2*60*60)) state.UpdatedAt = state.CreatedAt state.Outputs[0].SourceCreated = state.CreatedAt state.Outputs[0].CreatedAt = state.CreatedAt state.Outputs[0].UpdatedAt = state.CreatedAt data, err := json.Marshal(state) if err != nil { t.Fatalf("Marshal() error = %v", err) } for _, want := range []string{ `"created_at":"2026-05-30T11:12:00Z"`, `"updated_at":"2026-05-30T11:12:00Z"`, `"source_created":"2026-05-30T11:12:00Z"`, } { if !strings.Contains(string(data), want) { t.Fatalf("json = %s, want %s", data, want) } } } func TestSharedRootOutputHelpers(t *testing.T) { state := validSharedRootState(t) archive := CurrentOwnerScope("reports", "archive") html := CurrentOwnerScope("reports", "html") if got, want := state.ManagedOutputPaths(archive), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("archive paths = %#v, want %#v", got, want) } if got, want := state.AllManagedOutputPaths(), []string{"report.md", "report.html"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("all paths = %#v, want %#v", got, want) } conflict, ok := state.PathOwnershipConflict(archive, []string{"report.html"}) if !ok || conflict.Owner != html { t.Fatalf("conflict = %#v ok=%t, want html owner conflict", conflict, ok) } if conflict.Detail.Kind != ComparisonDetailSharedRootOutputOwner { t.Fatalf("conflict detail kind = %q, want %q", conflict.Detail.Kind, ComparisonDetailSharedRootOutputOwner) } if conflict.Detail.Path != "report.html" || conflict.Detail.CurrentOwner != archive || conflict.Detail.ConflictingOwner != html { t.Fatalf("conflict detail = %#v, want path and owners", conflict.Detail) } } func TestRemoveMissingSharedRootOwnerOutputs(t *testing.T) { state := validSharedRootState(t) archive := CurrentOwnerScope("reports", "archive") next, changed := RemoveMissingSharedRootOwnerOutputs(state, archive, []string{"report.md", "report.html"}) if !changed { t.Fatal("RemoveMissingSharedRootOwnerOutputs() changed = false, want true") } if got, want := next.AllManagedOutputPaths(), []string{"report.html"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("paths = %#v, want %#v", got, want) } if _, ok := next.OutputOwner("report.html"); !ok { t.Fatal("report.html owner missing, want unrelated owner preserved") } } func TestRemoveMissingSharedRootOutputs(t *testing.T) { state := validSharedRootState(t) next, changed := RemoveMissingSharedRootOutputs(state, []string{"report.md", "report.html"}) if !changed { t.Fatal("RemoveMissingSharedRootOutputs() changed = false, want true") } if got := next.AllManagedOutputPaths(); len(got) != 0 { t.Fatalf("paths = %#v, want none", got) } } func TestSharedRootProjectAndMergeOwnerOutputs(t *testing.T) { state := validSharedRootState(t) archive := CurrentOwnerScope("reports", "archive") owner, ok := state.Owner(archive) if !ok { t.Fatal("Owner() ok = false, want true") } now := time.Date(2026, 5, 30, 12, 30, 0, 0, time.UTC) planned := ProjectSharedRootOutputs([]OutputProjection{{ Path: "report.md", Kind: OutputKindSource, SourcePath: "report.md", SHA256: validManifest(t).Files[0].SHA256, Size: validManifest(t).Files[0].Size, }, { Path: "summary.txt", Kind: OutputKindSource, SourcePath: "summary.txt", SHA256: validManifest(t).Files[1].SHA256, Size: validManifest(t).Files[1].Size, }}, state.Outputs, archive, validManifest(t), now) merged, err := MergeOwnerOutputs(state, archive, owner, planned) if err != nil { t.Fatalf("MergeOwnerOutputs() error = %v", err) } if got, want := merged.AllManagedOutputPaths(), []string{"report.md", "report.html", "summary.txt"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("merged paths = %#v, want %#v", got, want) } if merged.Outputs[0].CreatedAt.Equal(now) { t.Fatalf("merged updated output created_at = %s, want preserved timestamp", merged.Outputs[0].CreatedAt) } if !merged.Outputs[0].UpdatedAt.Equal(now) { t.Fatalf("merged updated output updated_at = %s, want %s", merged.Outputs[0].UpdatedAt, now) } replaced, err := ReplaceOwnerOutputs(state, archive, owner, planned) if err != nil { t.Fatalf("ReplaceOwnerOutputs() error = %v", err) } if got, want := replaced.AllManagedOutputPaths(), []string{"report.html", "report.md", "summary.txt"}; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("replaced paths = %#v, want %#v", got, want) } } func TestSharedRootOwnerOutputHelpersRejectConflicts(t *testing.T) { state := validSharedRootState(t) archive := CurrentOwnerScope("reports", "archive") owner, ok := state.Owner(archive) if !ok { t.Fatal("Owner() ok = false, want true") } planned := []SharedRootOutputFile{{ Path: "report.html", Kind: OutputKindSource, Owner: archive, CreatedAt: time.Date(2026, 5, 30, 12, 30, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 5, 30, 12, 30, 0, 0, time.UTC), }} if _, err := MergeOwnerOutputs(state, archive, owner, planned); err == nil { t.Fatal("MergeOwnerOutputs() error = nil, want owner conflict") } if _, err := ReplaceOwnerOutputs(state, archive, owner, planned); err == nil { t.Fatal("ReplaceOwnerOutputs() error = nil, want owner conflict") } } func TestCompareSharedRootOwnerScopesCurrentOwner(t *testing.T) { state := validSharedRootState(t) source := validManifest(t) source.Created = source.Created.Add(time.Hour) scope := CurrentOwnerScope("reports", "archive") comparison := CompareSharedRootOwner(source, scope, DestinationStatus{SharedRoot: &state, HasContents: true}) if comparison.Outcome != OutcomeDestinationOlder { t.Fatalf("comparison = %#v, want destination older for current owner", comparison) } missing := CompareSharedRootOwner(source, CurrentOwnerScope("missing", "archive"), DestinationStatus{SharedRoot: &state, HasContents: true}) if missing.Outcome != OutcomeDestinationAbsent { t.Fatalf("missing owner comparison = %#v, want destination absent", missing) } if missing.Detail.Kind != ComparisonDetailSharedRootOwnerAbsent { t.Fatalf("missing owner detail kind = %q, want %q", missing.Detail.Kind, ComparisonDetailSharedRootOwnerAbsent) } if missing.Detail.CurrentOwner != CurrentOwnerScope("missing", "archive") { t.Fatalf("missing owner detail = %#v, want missing/archive", missing.Detail) } } func TestCompareSharedRootOwnerAcceptsMatchingSingleOwnerState(t *testing.T) { singleOwner, err := Parse([]byte(validStateJSON(t))) if err != nil { t.Fatalf("Parse() error = %v", err) } source := singleOwner.Source.Manifest source.Created = source.Created.Add(time.Hour) scope := CurrentOwnerScope(singleOwner.PipelineID, singleOwner.DestinationID) comparison := CompareSharedRootOwner(source, scope, DestinationStatus{State: &singleOwner, HasContents: true}) if comparison.Outcome != OutcomeDestinationOlder { t.Fatalf("comparison = %#v, want destination older", comparison) } } func validSharedRootStateJSON(t *testing.T) string { t.Helper() data, err := json.MarshalIndent(validSharedRootState(t), "", " ") if err != nil { t.Fatalf("marshal shared root state: %v", err) } return string(data) } func validSharedRootState(t *testing.T) SharedRootState { t.Helper() source := validManifest(t) htmlSource := source htmlSource.Files = append([]bundle.ManifestFile(nil), source.Files...) createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC) return SharedRootState{ SchemaVersion: SharedRootSchemaVersion, DistributorVersion: "dev", CreatedAt: createdAt, UpdatedAt: createdAt, State: StatePolicy{Mode: StateModeSharedRoot}, Owners: []OwnerRecord{{ Scope: CurrentOwnerScope("reports", "archive"), Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeReplace}, Source: SourceState{Manifest: source}, Links: &LinkState{PrimaryURL: "https://reports.example.com/archive/report.md"}, }, { Scope: CurrentOwnerScope("reports", "html"), Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeMerge}, Source: SourceState{Manifest: htmlSource}, }}, Outputs: []SharedRootOutputFile{{ Path: "report.md", Kind: OutputKindSource, SourcePath: "report.md", SHA256: source.Files[0].SHA256, Size: source.Files[0].Size, Owner: CurrentOwnerScope("reports", "archive"), SourceID: source.ID, SourceDigest: source.Digest, SourceCreated: source.Created, CreatedAt: createdAt, UpdatedAt: createdAt, }, { Path: "report.html", Kind: OutputKindGenerated, SourcePath: "report.md", Transform: "markdown_to_html", URL: "https://reports.example.com/html/report.html", SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 128, Owner: CurrentOwnerScope("reports", "html"), SourceID: htmlSource.ID, SourceDigest: htmlSource.Digest, SourceCreated: htmlSource.Created, CreatedAt: createdAt, UpdatedAt: createdAt, }}, } }