fix(dataflow): preserve edits during saves and CSV staging evidence
Module Package Release / publish-packages (push) Successful in 15s
Module Package Release / publish-packages (push) Successful in 15s
Release v0.1.25. Coordinated integrity review: GovOPlaN/govoplan-core#298.
This commit is contained in:
Executable
+200
@@ -0,0 +1,200 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||
const { transformSync } = require("esbuild");
|
||||
const page = readFileSync(new URL("../src/features/dataflow/DataflowPage.tsx", import.meta.url), "utf8");
|
||||
function loadTs(path) {
|
||||
const context = vm.createContext({ module: { exports: {} }, require: () => ({}), structuredClone });
|
||||
context.exports = context.module.exports;
|
||||
vm.runInContext(transformSync(readFileSync(new URL(path, import.meta.url), "utf8"), { loader: "ts", format: "cjs" }).code, context);
|
||||
return context.module.exports;
|
||||
}
|
||||
const { reconcilePipelineSave } = loadTs("../src/features/dataflow/saveCompletion.ts");
|
||||
const { draftFingerprint, pipelinePayload } = loadTs("../src/features/dataflow/model.ts");
|
||||
const { authAuthorityKey } = loadTs("../../../govoplan-core/webui/src/api/authAuthority.ts");
|
||||
const start = page.indexOf(" const saveDraft = useCallback(async (): Promise<boolean> => {");
|
||||
const end = page.indexOf(" }, [canEdit, draft, settings, authorityKey, authorityGeneration]);", start);
|
||||
assert.ok(start >= 0 && end > start, "exercise the actual page save closure");
|
||||
const code = transformSync(page.slice(start, end) + " }, []);\nmodule.exports = saveDraft;", { loader: "ts" }).code;
|
||||
const base = () => ({
|
||||
id: "pipeline-1", currentRevision: 1, name: "Pipeline", description: "Submitted", status: "draft",
|
||||
graph: { nodes: [{ id: "a", config: { rows: [{ id: "001", value: " text " }] } }, { id: "b" }], edges: [{ id: "a-b" }] },
|
||||
sqlText: "", editorMode: "graph", scopeType: "tenant", scopeId: "tenant-1", definitionKind: "flow",
|
||||
inheritToLowerScopes: false, allowRun: true, allowReuse: true, allowAutomation: false, governance: { actions: {} }
|
||||
});
|
||||
function harness(draft = base()) {
|
||||
const calls = [];
|
||||
const responses = [];
|
||||
const context = vm.createContext({
|
||||
module: { exports: {} }, useCallback: (value) => value, structuredClone,
|
||||
draft, canEdit: true, settings: {}, saveInFlight: { current: false },
|
||||
draftSession: { current: { generation: 1, value: draft } },
|
||||
authorityKey: "authority-A", saveContext: { current: "authority-A" }, unresolvedSaveGeneration: { current: null },
|
||||
authorityGeneration: 0, saveAuthorityEpoch: { current: { key: "authority-A", revision: 0 } },
|
||||
reconcilePipelineSave, draftFingerprint, pipelinePayload, draftFromPipeline: (value) => value,
|
||||
updateDataflowPipeline: (_settings, id, payload) => new Promise((resolve, reject) => { calls.push({ id, payload }); responses.push({ resolve, reject }); }),
|
||||
createDataflowPipeline: (_settings, payload) => new Promise((resolve, reject) => { calls.push({ payload }); responses.push({ resolve, reject }); }),
|
||||
setDraftValue: (value) => { context.draft = value; },
|
||||
setSavedDraft: (value) => { context.baseline = value; },
|
||||
setSaving: (value) => { context.saving = value; },
|
||||
setError: (value) => { context.error = value; },
|
||||
setSuccess: () => {}, setPipelines: () => {}, setSelectedNodeId: () => {}, setDiagnostics: () => {}, apiErrorMessage: String
|
||||
});
|
||||
vm.runInContext(code, context);
|
||||
return { context, calls, responses, save: context.module.exports };
|
||||
}
|
||||
function accepted(draft, revision = 2) {
|
||||
return { ...structuredClone(draft), currentRevision: revision, current_revision: revision, governance: { actions: { edit: { allowed: true } } } };
|
||||
}
|
||||
|
||||
test("edits made during an accepted save survive and navigation remains blocked until saved", async () => {
|
||||
const h = harness();
|
||||
const submitted = h.context.draft;
|
||||
const pending = h.save();
|
||||
const newerGraph = { ...submitted.graph, nodes: [...submitted.graph.nodes].reverse(), custom: { preserve: ["001", null, ""] } };
|
||||
h.context.draftSession.current.value = { ...submitted, description: "Typed during save", graph: newerGraph };
|
||||
h.responses[0].resolve(accepted(submitted));
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.context.draft.description, "Typed during save");
|
||||
assert.equal(h.context.draft.graph, newerGraph, "graph remains atomic, including order and unknown data");
|
||||
assert.equal(h.context.baseline.description, "Submitted");
|
||||
assert.equal(h.context.draft.currentRevision, 2);
|
||||
assert.notEqual(draftFingerprint(h.context.draft), draftFingerprint(h.context.baseline));
|
||||
const second = h.save();
|
||||
assert.equal(h.calls[1].payload.expected_revision, 2, "next save uses the accepted revision, not the stale submitted one");
|
||||
assert.deepEqual(h.calls[1].payload.graph, newerGraph);
|
||||
h.responses[1].resolve(accepted(h.context.draft, 3));
|
||||
assert.equal(await second, true);
|
||||
assert.equal(draftFingerprint(h.context.draft), draftFingerprint(h.context.baseline));
|
||||
});
|
||||
|
||||
test("unchanged submitted fields accept canonical response and new identities without a duplicate create", async () => {
|
||||
const h = harness({ ...base(), id: null, currentRevision: null });
|
||||
const pending = h.save();
|
||||
assert.equal(await h.save(), false);
|
||||
assert.equal(h.calls.length, 1);
|
||||
const response = { ...accepted(h.context.draft), id: "created", name: "Server canonical name" };
|
||||
h.responses[0].resolve(response);
|
||||
assert.equal(await pending, true);
|
||||
assert.equal(h.context.draft.id, "created");
|
||||
assert.equal(h.context.draft.name, "Server canonical name");
|
||||
});
|
||||
|
||||
test("replacement draft, authority change and unmount cannot receive an old save completion", async () => {
|
||||
for (const change of ["replacement", "authority", "unmount"]) {
|
||||
const h = harness();
|
||||
const pending = h.save();
|
||||
if (change === "authority") h.context.saveContext.current = "authority-B";
|
||||
else h.context.draftSession.current.generation += 1;
|
||||
h.responses[0].resolve(accepted(h.context.draft));
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.context.baseline, undefined);
|
||||
assert.equal(h.context.draft.currentRevision, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("harmless session/profile object refresh preserves an accepted new identity and revision", async () => {
|
||||
const h = harness({ ...base(), id: null, currentRevision: null });
|
||||
const settings = { apiBaseUrl: "/api", apiKey: "", accessToken: "" };
|
||||
const auth = {
|
||||
user: { id: "member", account_id: "account", email: "person@example.test", display_name: "Before" },
|
||||
tenant: { id: "tenant" }, scopes: ["dataflow:pipeline:write"], roles: [], groups: []
|
||||
};
|
||||
h.context.authorityKey = authAuthorityKey(auth, settings);
|
||||
h.context.saveContext.current = h.context.authorityKey;
|
||||
const pending = h.save();
|
||||
h.context.saveContext.current = authAuthorityKey({ ...structuredClone(auth), user: { ...auth.user, display_name: "After", preferred_language: "de" } }, { ...settings });
|
||||
h.responses[0].resolve({ ...accepted(h.context.draft), id: "accepted-created-id" });
|
||||
assert.equal(await pending, true);
|
||||
assert.equal(h.context.draft.id, "accepted-created-id");
|
||||
const next = h.save();
|
||||
assert.equal(h.calls[1].id, "accepted-created-id", "retry updates the accepted identity, never creates another pipeline");
|
||||
assert.equal(h.calls[1].payload.expected_revision, 2);
|
||||
h.responses[1].resolve(accepted(h.context.draft, 3));
|
||||
assert.equal(await next, true);
|
||||
});
|
||||
|
||||
test("an accepted save across a real authority change cannot be blindly retried as a duplicate create", async () => {
|
||||
const h = harness({ ...base(), id: null, currentRevision: null });
|
||||
const pending = h.save();
|
||||
h.context.saveContext.current = "authority-B";
|
||||
h.responses[0].resolve({ ...accepted(h.context.draft), id: "accepted-under-A" });
|
||||
assert.equal(await pending, false);
|
||||
h.context.authorityKey = "authority-B";
|
||||
assert.equal(await h.save(), false);
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.match(h.context.error, /Reload and review/);
|
||||
});
|
||||
|
||||
test("returning to authority A after B does not revive a stale A save completion", async () => {
|
||||
const h = harness();
|
||||
const pending = h.save();
|
||||
h.context.saveAuthorityEpoch.current = { key: "authority-A", revision: 2 };
|
||||
h.responses[0].resolve(accepted(h.context.draft));
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.context.baseline, undefined);
|
||||
});
|
||||
|
||||
test("conflict preserves both local draft and prior revision, allowing an explicit reviewed retry", async () => {
|
||||
const h = harness();
|
||||
const original = h.context.draft;
|
||||
const pending = h.save();
|
||||
h.responses[0].reject(new Error("revision conflict"));
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.context.draft, original);
|
||||
assert.equal(h.context.baseline, undefined);
|
||||
assert.match(h.context.error, /revision conflict/);
|
||||
assert.equal(h.context.saveInFlight.current, false);
|
||||
});
|
||||
|
||||
test("the submitted baseline is frozen even if a nested local editor mutates a shared object", async () => {
|
||||
const h = harness();
|
||||
const serverAccepted = accepted(h.context.draft);
|
||||
const pending = h.save();
|
||||
h.context.draft.graph.nodes.reverse();
|
||||
h.responses[0].resolve(serverAccepted);
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.context.draft.graph.nodes[0].id, "b");
|
||||
assert.equal(h.context.baseline.graph.nodes[0].id, "a");
|
||||
assert.equal(h.calls[0].payload.graph.nodes[0].id, "a", "submission data is not aliased to later editor mutations");
|
||||
});
|
||||
|
||||
test("CSV imports explicitly preserve text by default; JSON payload stays independent", () => {
|
||||
assert.match(page, /\[csvValueMode, setCsvValueMode\] = useState<"text" \| "legacy_typed">\("text"\)/);
|
||||
assert.match(page, /\? \{ format, rows \}\s*: \{ format, csv_text: csvText, delimiter, csv_value_mode: csvValueMode \}/);
|
||||
assert.match(page, /setCsvValueMode\("text"\)/);
|
||||
});
|
||||
|
||||
test("source dialog sends exact CSV content and selected mode without changing JSON rows", async () => {
|
||||
const dialog = page.slice(page.indexOf("function SourceSnapshotDialog("));
|
||||
const start = dialog.indexOf(" const create = async (): Promise<boolean> => {");
|
||||
const end = dialog.indexOf("\n };", start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const code = transformSync(dialog.slice(start, end) + "\n}; module.exports = create;", { loader: "ts" }).code;
|
||||
const csvText = 'code,value\r\n001," text "\r\n';
|
||||
for (const format of ["csv", "json"]) {
|
||||
for (const csvValueMode of ["text", "legacy_typed"]) {
|
||||
let payload;
|
||||
const context = vm.createContext({
|
||||
module: { exports: {} }, settings: {}, format, csvValueMode, csvText, delimiter: ",",
|
||||
name: "Fixture", sourceName: "fixture", description: "", rowsText: '[{"code":"001","value":" text "}]',
|
||||
isRecord: (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value),
|
||||
createDataflowSourceSnapshot: async (_settings, value) => { payload = value; return {}; },
|
||||
onCreated: () => {}, setBusy: () => {}, setError: () => {}, apiErrorMessage: String
|
||||
});
|
||||
vm.runInContext(code, context);
|
||||
assert.equal(await context.module.exports(), true);
|
||||
if (format === "csv") {
|
||||
assert.equal(payload.csv_value_mode, csvValueMode);
|
||||
assert.equal(payload.csv_text, csvText);
|
||||
} else {
|
||||
assert.equal("csv_value_mode" in payload, false);
|
||||
assert.equal(JSON.stringify(payload.rows), '[{"code":"001","value":" text "}]');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user