package app import ( "context" "encoding/json" "strings" "testing" "time" "gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/state" "gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage/fake" "gitea.maximumdirect.net/eric/distributor/internal/testutil" ) func TestReconcileStateDryRunReportsMissingManagedOutputsWithoutRewrite(t *testing.T) { backend := fake.New() cfg := reconcileStateS3Config(t) manifest := testutil.ValidManifest(testutil.BundleOptions{}) testutil.WriteFakeDestinationState(t, backend, "", manifest, testutil.DestinationStateOptions{}) if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"summary.txt"}, storage.DeleteOptions{}); err != nil { t.Fatalf("delete managed output: %v", err) } testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged") report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{ PipelineID: "reports", DestinationID: "archive", DryRun: true, }, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend})) if err != nil { t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err) } if !report.WouldChange || report.Changed { t.Fatalf("report changed=%t would_change=%t, want dry-run pending change", report.Changed, report.WouldChange) } if got := reportPathList(report.MissingManagedOutputs); got != "summary.txt" { t.Fatalf("missing outputs = %q, want summary.txt", got) } if got := entryPathList(report.UnmanagedEntries); got != "extra.txt" { t.Fatalf("unmanaged entries = %q, want extra.txt", got) } destinationState := readFakeSingleOwnerState(t, backend) if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md,summary.txt" { t.Fatalf("state outputs = %q, want original outputs", got) } } func TestReconcileStateApplyRemovesMissingRecordsAndPreservesUnmanagedFiles(t *testing.T) { backend := fake.New() cfg := reconcileStateS3Config(t) manifest := testutil.ValidManifest(testutil.BundleOptions{}) testutil.WriteFakeDestinationState(t, backend, "", manifest, testutil.DestinationStateOptions{}) if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"summary.txt"}, storage.DeleteOptions{}); err != nil { t.Fatalf("delete managed output: %v", err) } testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged") report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{ PipelineID: "reports", DestinationID: "archive", }, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend})) if err != nil { t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err) } if !report.Changed || report.WouldChange { t.Fatalf("report changed=%t would_change=%t, want applied change", report.Changed, report.WouldChange) } destinationState := readFakeSingleOwnerState(t, backend) if err := state.Validate(destinationState); err != nil { t.Fatalf("Validate() repaired state error = %v", err) } if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md" { t.Fatalf("state outputs = %q, want report.md", got) } testutil.AssertFakeFile(t, backend, "extra.txt", "unmanaged") } func TestReconcileStateInvalidStateFailsWithoutRewrite(t *testing.T) { backend := fake.New() cfg := reconcileStateS3Config(t) invalid := `{"schema_version":2,"pipeline_id":"reports"}` testutil.WriteFakeFile(t, backend, storage.StateFileName, invalid) _, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{ PipelineID: "reports", DestinationID: "archive", }, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend})) if err == nil { t.Fatal("reconcileStateConfigWithBackendFactory() error = nil, want invalid state error") } data, readErr := backend.ReadFile(context.Background(), storage.StateFileName) if readErr != nil { t.Fatalf("read invalid state: %v", readErr) } if string(data) != invalid { t.Fatalf("state data = %q, want original invalid data", data) } } func TestReconcileStateSharedRootOwnerScopeRepairsCurrentOwnerOnly(t *testing.T) { backend := fake.New() cfg := reconcileStateS3Config(t) sharedRoot := reconcileSharedRootFixture(t) writeFakeSharedRootStateForApp(t, backend, sharedRoot) if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"report.md", "report.html"}, storage.DeleteOptions{}); err != nil { t.Fatalf("delete managed outputs: %v", err) } report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{ PipelineID: "reports", DestinationID: "archive", }, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend})) if err != nil { t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err) } if !report.Changed { t.Fatal("report changed = false, want true") } repaired := readFakeSharedRootStateForApp(t, backend) if got := strings.Join(repaired.AllManagedOutputPaths(), ","); got != "report.html" { t.Fatalf("shared-root outputs = %q, want other owner output preserved", got) } } func TestReconcileStateSharedRootAllOwnersRepairsEveryOwner(t *testing.T) { backend := fake.New() cfg := reconcileStateS3Config(t) sharedRoot := reconcileSharedRootFixture(t) writeFakeSharedRootStateForApp(t, backend, sharedRoot) if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"report.md", "report.html"}, storage.DeleteOptions{}); err != nil { t.Fatalf("delete managed outputs: %v", err) } report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{ PipelineID: "reports", DestinationID: "archive", AllOwners: true, }, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend})) if err != nil { t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err) } if !report.Changed || report.CheckedCount != 2 { t.Fatalf("report changed=%t checked=%d, want all-owner repair", report.Changed, report.CheckedCount) } repaired := readFakeSharedRootStateForApp(t, backend) if got := repaired.AllManagedOutputPaths(); len(got) != 0 { t.Fatalf("shared-root outputs = %#v, want none", got) } } func reconcileStateS3Config(t *testing.T) config.Config { t.Helper() cfg := config.Config{Pipelines: []config.Pipeline{{ ID: "reports", Source: config.Backend{Backend: config.BackendLocal, Path: t.TempDir()}, Destinations: []config.Destination{{ ID: "archive", Backend: config.BackendS3, Bucket: "reports", }}, }}} config.ApplyDefaults(&cfg) return cfg } func readFakeSingleOwnerState(t *testing.T, backend *fake.Backend) state.DistributorState { t.Helper() data, err := backend.ReadFile(context.Background(), storage.StateFileName) if err != nil { t.Fatalf("read state: %v", err) } destinationState, err := state.Parse(data) if err != nil { t.Fatalf("parse state: %v", err) } return destinationState } func writeFakeSharedRootStateForApp(t *testing.T, backend *fake.Backend, sharedRoot state.SharedRootState) { t.Helper() data, err := json.MarshalIndent(sharedRoot, "", " ") if err != nil { t.Fatalf("marshal shared-root state: %v", err) } testutil.WriteFakeFile(t, backend, storage.StateFileName, string(append(data, '\n'))) for _, output := range sharedRoot.Outputs { testutil.WriteFakeFile(t, backend, output.Path, "old") } } func readFakeSharedRootStateForApp(t *testing.T, backend *fake.Backend) state.SharedRootState { t.Helper() data, err := backend.ReadFile(context.Background(), storage.StateFileName) if err != nil { t.Fatalf("read shared-root state: %v", err) } sharedRoot, err := state.ParseSharedRoot(data) if err != nil { t.Fatalf("parse shared-root state: %v", err) } return sharedRoot } func reconcileSharedRootFixture(t *testing.T) state.SharedRootState { t.Helper() source := testutil.ValidManifest(testutil.BundleOptions{}) htmlSource := source createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC) return state.SharedRootState{ SchemaVersion: state.SharedRootSchemaVersion, DistributorVersion: "test", CreatedAt: createdAt, UpdatedAt: createdAt, State: state.StatePolicy{Mode: state.StateModeSharedRoot}, Owners: []state.OwnerRecord{{ Scope: state.CurrentOwnerScope("reports", "archive"), Reconciliation: state.ReconciliationPolicy{Mode: config.ReconciliationModeReplace}, Source: state.SourceState{Manifest: source}, }, { Scope: state.CurrentOwnerScope("reports", "html"), Reconciliation: state.ReconciliationPolicy{Mode: config.ReconciliationModeMerge}, Source: state.SourceState{Manifest: htmlSource}, }}, Outputs: []state.SharedRootOutputFile{{ Path: "report.md", Kind: state.OutputKindSource, SourcePath: "report.md", SHA256: source.Files[0].SHA256, Size: source.Files[0].Size, Owner: state.CurrentOwnerScope("reports", "archive"), SourceID: source.ID, SourceDigest: source.Digest, SourceCreated: source.Created, CreatedAt: createdAt, UpdatedAt: createdAt, }, { Path: "report.html", Kind: state.OutputKindGenerated, SourcePath: "report.md", Transform: "markdown_to_html", SHA256: "sha256:" + strings.Repeat("a", 64), Size: 128, Owner: state.CurrentOwnerScope("reports", "html"), SourceID: htmlSource.ID, SourceDigest: htmlSource.Digest, SourceCreated: htmlSource.Created, CreatedAt: createdAt, UpdatedAt: createdAt, }}, } } func reportPathList(paths []ReconcileStatePath) string { values := make([]string, 0, len(paths)) for _, path := range paths { values = append(values, path.Path) } return strings.Join(values, ",") } func entryPathList(entries []ReconcileStateEntry) string { values := make([]string, 0, len(entries)) for _, entry := range entries { values = append(values, entry.Path) } return strings.Join(values, ",") }