package publish import ( "context" "encoding/json" "strings" "testing" "gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage/fake" "gitea.maximumdirect.net/eric/distributor/internal/testutil" ) func TestBuildPlansForcedReplacementOnlyWhenExplicit(t *testing.T) { tests := []struct { name string prepare func(t *testing.T, backend *fake.Backend, source bundle.Manifest) transfer config.TransferPolicy wantReason string forceAction bool }{ { name: "unmanaged content", prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) { t.Helper() writeFakeFile(t, backend, "bundle/old.txt", "old") }, transfer: defaultTransfer(), wantReason: "fail_unmanaged", forceAction: true, }, { name: "different source id", prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) { t.Helper() conflict := source conflict.ID = "other.source" writeFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{}) }, transfer: conflictReplaceTransfer(), wantReason: "requires --force", forceAction: true, }, { name: "same created digest conflict", prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) { t.Helper() conflict := testutil.ValidManifest(testutil.BundleOptions{Files: []testutil.SourceFile{{Path: "report.md", Data: "# Different\n"}}}) writeFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{}) }, transfer: conflictReplaceTransfer(), wantReason: "requires --force", forceAction: true, }, { name: "pipeline mismatch", prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) { t.Helper() writeFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{PipelineID: "other-pipeline"}) }, transfer: conflictReplaceTransfer(), wantReason: "requires --force", forceAction: true, }, { name: "destination mismatch", prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) { t.Helper() writeFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{DestinationID: "other-destination"}) }, transfer: conflictReplaceTransfer(), wantReason: "requires --force", forceAction: true, }, { name: "newer destination", prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) { t.Helper() newer := source newer.Created = newer.Created.AddDate(0, 0, 1) writeFakeDestinationState(t, backend, "bundle", newer, testutil.DestinationStateOptions{}) }, transfer: newerReplaceTransfer(), wantReason: "requires --force", forceAction: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sourceBackend := fake.New() sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{}) destinationBackend := fake.New() tt.prepare(t, destinationBackend, sourceBundle.Manifest) req := forceRequest(sourceBackend, destinationBackend, sourceBundle, tt.transfer) _, err := Build(context.Background(), req) if err == nil || !strings.Contains(err.Error(), tt.wantReason) { t.Fatalf("Build() error = %v, want %q", err, tt.wantReason) } req.Force = true plan, err := Build(context.Background(), req) if tt.forceAction { if err != nil { t.Fatalf("Build() with force error = %v", err) } if plan.Action != ActionForceReplace || !plan.Force { t.Fatalf("forced plan action = %s force=%t", plan.Action, plan.Force) } } }) } } func TestBuildRequiresConflictPolicyForStateConflicts(t *testing.T) { sourceBackend := fake.New() sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{}) destinationBackend := fake.New() conflict := sourceBundle.Manifest conflict.ID = "other.source" writeFakeDestinationState(t, destinationBackend, "bundle", conflict, testutil.DestinationStateOptions{}) req := forceRequest(sourceBackend, destinationBackend, sourceBundle, defaultTransfer()) req.Force = true _, err := Build(context.Background(), req) if err == nil || !strings.Contains(err.Error(), "destination source id differs") { t.Fatalf("Build() error = %v, want conservative conflict", err) } } func TestExecuteForcedReplacementDeletesOnlyBundlePath(t *testing.T) { sourceBackend := fake.New() sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{}) destinationBackend := fake.New() writeFakeFile(t, destinationBackend, "bundle/old.txt", "old") writeFakeFile(t, destinationBackend, "bundle/nested/old.txt", "old") writeFakeFile(t, destinationBackend, "bundle-sibling/keep.txt", "keep") writeFakeFile(t, destinationBackend, "outside.txt", "outside") req := forceRequest(sourceBackend, destinationBackend, sourceBundle, defaultTransfer()) req.Force = true plan, err := Build(context.Background(), req) if err != nil { t.Fatalf("Build() error = %v", err) } if plan.Action != ActionForceReplace { t.Fatalf("plan action = %s, want force_replace", plan.Action) } if err := Execute(context.Background(), req, plan); err != nil { t.Fatalf("Execute() error = %v", err) } assertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nSunny.\n") assertFakeMissing(t, destinationBackend, "bundle/old.txt") assertFakeMissing(t, destinationBackend, "bundle/nested/old.txt") assertFakeFile(t, destinationBackend, "bundle-sibling/keep.txt", "keep") assertFakeFile(t, destinationBackend, "outside.txt", "outside") } func forceRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle bundle.Bundle, transfer config.TransferPolicy) Request { return Request{ PipelineID: "reports", DestinationID: "archive", SourceBundle: sourceBundle, SourceBackend: sourceBackend, DestinationBackend: destinationBackend, DestinationBundlePath: sourceBundle.RootRelativePath, Publish: config.PublishPolicy{Source: true}, Transfer: transfer, DistributorVersion: "test", } } func defaultTransfer() config.TransferPolicy { return config.TransferPolicy{ OnDestinationSame: config.TransferActionSkip, OnDestinationOlder: config.TransferActionReplace, OnDestinationNewer: config.TransferActionSkip, OnConflict: config.TransferActionFail, } } func conflictReplaceTransfer() config.TransferPolicy { transfer := defaultTransfer() transfer.OnConflict = config.TransferActionReplace return transfer } func newerReplaceTransfer() config.TransferPolicy { transfer := defaultTransfer() transfer.OnDestinationNewer = config.TransferActionReplace return transfer } func writeFakeDestinationState(t *testing.T, backend *fake.Backend, relative string, manifest bundle.Manifest, opts testutil.DestinationStateOptions) { t.Helper() destinationState := testutil.DestinationState(manifest, opts) data, err := json.MarshalIndent(destinationState, "", " ") if err != nil { t.Fatalf("marshal destination state: %v", err) } statePath, err := storage.StatePath(relative) if err != nil { t.Fatalf("state path: %v", err) } writeFakeFile(t, backend, statePath, string(append(data, '\n'))) for _, output := range destinationState.Outputs { path, err := storage.Join(relative, output.Path) if err != nil { t.Fatalf("join output path: %v", err) } writeFakeFile(t, backend, path, "old") } } func writeFakeFile(t *testing.T, backend *fake.Backend, path, data string) { t.Helper() if _, err := backend.WriteFile(context.Background(), path, []byte(data), storage.WriteOptions{}); err != nil { t.Fatalf("write fake file %s: %v", path, err) } } func assertFakeFile(t *testing.T, backend *fake.Backend, path, want string) { t.Helper() data, err := backend.ReadFile(context.Background(), path) if err != nil { t.Fatalf("read fake file %s: %v", path, err) } if got := string(data); got != want { t.Fatalf("fake file %s = %q, want %q", path, got, want) } } func assertFakeMissing(t *testing.T, backend *fake.Backend, path string) { t.Helper() if _, err := backend.Stat(context.Background(), path); !storage.IsNotFound(err) { t.Fatalf("fake file %s stat error = %v, want not found", path, err) } }