diff --git a/internal/platform/canvas_model_integration_test.go b/internal/platform/canvas_model_integration_test.go index 8aabab5..b549603 100644 --- a/internal/platform/canvas_model_integration_test.go +++ b/internal/platform/canvas_model_integration_test.go @@ -301,4 +301,47 @@ func TestCanonicalModelMigrationAndIncrementalLifecycle(t *testing.T) { if _, exists := model.Elements[innerFrame]["data"].(map[string]any)["presentation"].(map[string]any)["unitScale"]; exists || !modelEqual(model.Elements[memberID], member) || model.Canvases[innerCanvas].Parent.CanvasID != root { t.Fatal("scaled move inverse did not restore absent unit metadata and the original child binding") } + // Several logical edits share one atomic request while retaining every deduplication identity. + batchKey, firstLogical, secondLogical := modelTestID(1016), modelTestID(22001), modelTestID(22002) + batchVersion := model.Versions[innerCanvas] + batch := modelBatch{ProtocolVersion: 2, RequestID: batchKey, Expected: map[string]int64{innerCanvas: batchVersion}, Operations: []modelOperation{ + {ID: firstLogical, Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON(memberID), After: modelJSON("intermediate")}}}, + {ID: secondLogical, Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("intermediate"), After: modelJSON("batched")}}}, + }} + batched := call("POST", base+"/model/operations", batch, batchKey, 200) + if !modelEqual(batched["operation_ids"], []string{firstLogical, secondLogical}) { + t.Fatal("batch receipt did not cover every original logical ID") + } + model = read() + if model.Versions[innerCanvas] != batchVersion+1 || modelString(model.Elements[memberID]["data"].(map[string]any)["title"]) != "batched" { + t.Fatal("batch lost ordered conditions or advanced the canvas version more than once") + } + if !modelEqual(call("GET", base+"/model/operations/"+batchKey, nil, "", 200), batched) || !modelEqual(call("POST", base+"/model/operations", batch, batchKey, 200), batched) { + t.Fatal("batch query/retry did not return its original receipt") + } + batch.RequestID = modelTestID(1017) + batch.Expected[innerCanvas] = model.Versions[innerCanvas] + batch.Operations = batch.Operations[:1] + if call("POST", base+"/model/operations", batch, batch.RequestID, 409)["error"] != "operation_already_applied" { + t.Fatal("a covered logical ID was executable under a new request key") + } + // A later failed condition cannot publish the earlier edit or consume its logical identity. + failedKey := modelTestID(1018) + batch.RequestID = failedKey + batch.Operations = []modelOperation{ + {ID: modelTestID(22003), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("batched"), After: modelJSON("must roll back")}}}, + {ID: modelTestID(22004), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("wrong before"), After: modelJSON("invalid")}}}, + } + if call("POST", base+"/model/operations", batch, failedKey, 409)["status"] != "not-applied" { + t.Fatal("failed batch did not produce a zero-write terminal receipt") + } + model = read() + if model.Versions[innerCanvas] != batchVersion+1 || modelString(model.Elements[memberID]["data"].(map[string]any)["title"]) != "batched" { + t.Fatal("a failed batch leaked its successful prefix") + } + var consumed int + if err = db.QueryRowContext(ctx, `SELECT count(*) FROM canvas_model_operations WHERE project=$1 AND operation_id=$2`, project, modelTestID(22003)).Scan(&consumed); err != nil || consumed != 0 { + t.Fatal("a rejected atomic prefix consumed a logical operation ID") + } + }