Implement catalog force replacement

This commit is contained in:
2026-06-19 16:39:01 +00:00
parent 4e673dda76
commit 9a2eaf8e5e
13 changed files with 307 additions and 35 deletions

View File

@@ -137,6 +137,7 @@ func TestBuildTransfersManagedPathOwnership(t *testing.T) {
func TestBuildRejectsUnmanagedPlannedPathCollision(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
writeCatalogState(t, destinationBackend, "", baseCatalog(req))
testutil.WriteFakeFile(t, destinationBackend, "report.md", "unmanaged")
plan, err := Build(context.Background(), req)
@@ -148,6 +149,55 @@ func TestBuildRejectsUnmanagedPlannedPathCollision(t *testing.T) {
}
}
func TestBuildForceReplacesUnmanagedPlannedPathCollision(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
existing := baseCatalog(req)
writeCatalogState(t, destinationBackend, "", existing)
testutil.WriteFakeFile(t, destinationBackend, "report.md", "unmanaged")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
}
func TestBuildRejectsNoStateNonEmptyDestinationWithoutForce(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "unmanaged")
plan, err := Build(context.Background(), req)
if err == nil {
t.Fatal("Build() error = nil, want unmanaged destination")
}
if plan.Action != ActionFailUnmanaged || !strings.Contains(err.Error(), "destination has content but no distributor state") {
t.Fatalf("plan action=%s error=%v, want unmanaged destination", plan.Action, err)
}
}
func TestBuildForceReplacesNoStateNonEmptyDestination(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "unmanaged")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
}
func TestBuildPlansSupersededLegacyAdditive(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
legacyState := testutil.DestinationState(req.SourceBundle.Manifest, testutil.DestinationStateOptions{})
@@ -204,6 +254,60 @@ func TestBuildRejectsInvalidOrFutureState(t *testing.T) {
}
}
func TestBuildForceReplacesInvalidOrFutureState(t *testing.T) {
tests := []struct {
name string
data string
}{
{name: "invalid json", data: `{"schema_version":`},
{name: "future schema", data: `{"schema_version":99}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, storage.StateFileName, tt.data)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
})
}
}
func TestBuildForceDoesNotChangeValidCatalogActions(t *testing.T) {
tests := []struct {
name string
workflow string
want Action
}{
{name: "additive", workflow: config.WorkflowAdditive, want: ActionUpsertAdditive},
{name: "replacement", workflow: config.WorkflowReplacement, want: ActionReplaceCatalog},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, tt.workflow)
req.Force = true
writeCatalogState(t, destinationBackend, "", baseCatalog(req))
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != tt.want || plan.Force || plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want %s without forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot, tt.want)
}
})
}
}
func TestValidateRequestRejectsInvalidWorkflow(t *testing.T) {
sourceBackend, destinationBackend, req := catalogPlanRequest(t, "append")
req.SourceBackend = sourceBackend