172 lines
9.2 KiB
TypeScript
172 lines
9.2 KiB
TypeScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import test from "node:test";
|
||
|
|
import type { TransferTask } from "../../../domain/models";
|
||
|
|
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||
|
|
|
||
|
|
function child(): TransferTask {
|
||
|
|
return {
|
||
|
|
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||
|
|
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
|
||
|
|
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
|
||
|
|
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const status of ["completed", "failed", "cancelled"] as const) {
|
||
|
|
test(`settlement observation retains exact ${status} before history pruning`, () => {
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = child();
|
||
|
|
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
|
||
|
|
const observation = store.observeTaskSettlement(task);
|
||
|
|
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
|
||
|
|
assert.equal(observation.read()?.status, status);
|
||
|
|
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
|
||
|
|
observation.dispose();
|
||
|
|
assert.equal(observation.read(), undefined);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
test("another file reusing an id and index is not completion evidence", () => {
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = child();
|
||
|
|
store.upsertTasks([task]);
|
||
|
|
const observation = store.observeTaskSettlement(task);
|
||
|
|
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
|
||
|
|
assert.equal(observation.read(), undefined);
|
||
|
|
observation.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a disposed observer receives no later completion", () => {
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = child();
|
||
|
|
store.upsertTasks([task]);
|
||
|
|
const observation = store.observeTaskSettlement(task);
|
||
|
|
observation.dispose();
|
||
|
|
store.patchTask(task.id, { status: "completed" });
|
||
|
|
assert.equal(observation.read(), undefined);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
|
||
|
|
store.upsertTasks([task]);
|
||
|
|
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
|
||
|
|
assert.equal(store.admitTaskRun(next), "ready");
|
||
|
|
assert.equal(store.getTask(task.id)?.status, "transferring");
|
||
|
|
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
|
||
|
|
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
|
||
|
|
test(`dispatch cannot revive a retained ${status} row`, () => {
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = child();
|
||
|
|
store.upsertTasks([
|
||
|
|
{ ...task, id: "root", parentTaskId: undefined },
|
||
|
|
{ ...task, status },
|
||
|
|
]);
|
||
|
|
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
|
||
|
|
assert.equal(store.getTask(task.id)?.status, status);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = { ...child(), lifecycleEpoch: 9 };
|
||
|
|
store.upsertTasks([task]);
|
||
|
|
const before = store.getTask(task.id);
|
||
|
|
assert.equal(store.admitTaskRun(task), "ready");
|
||
|
|
assert.equal(store.getTask(task.id), before);
|
||
|
|
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
|
||
|
|
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
|
||
|
|
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||
|
|
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = { ...child(), status: "failed" as const };
|
||
|
|
store.upsertTasks([task]);
|
||
|
|
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
|
||
|
|
latchTransferPause("root");
|
||
|
|
assert.equal(store.admitTaskRun(task), "paused");
|
||
|
|
resetTransferPauseLatchesForTests();
|
||
|
|
markTransferCancelledTree("root", [task.id]);
|
||
|
|
assert.equal(store.admitTaskRun(task), "cancelled");
|
||
|
|
assert.equal(store.getTask(task.id)?.status, "failed");
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
|
||
|
|
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
|
||
|
|
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||
|
|
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||
|
|
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
|
||
|
|
store.upsertTasks([{ ...task, status: "paused" }]);
|
||
|
|
let starts = 0;
|
||
|
|
let abort = false;
|
||
|
|
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
|
||
|
|
// Attach rejection handling before the cancellation event is delivered.
|
||
|
|
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||
|
|
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||
|
|
assert.equal(starts, 0);
|
||
|
|
assert.equal(store.getTask(task.id)?.status, "paused");
|
||
|
|
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
|
||
|
|
if (nextStatus === "cancelled") {
|
||
|
|
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||
|
|
markTransferCancelledTree(task.id, []);
|
||
|
|
t.after(() => settleTransferCancelTree(task.id, []));
|
||
|
|
} else store.patchTask(task.id, { status: nextStatus });
|
||
|
|
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
|
||
|
|
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
|
||
|
|
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
|
||
|
|
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||
|
|
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||
|
|
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
|
||
|
|
store.upsertTasks([{ ...task, status: "completed" }]);
|
||
|
|
t.after(() => store.dismiss(task.id));
|
||
|
|
const start = async () => { assert.fail("completed transfer must not restart"); };
|
||
|
|
await runTransferAndWaitForOwner(task, start, () => false);
|
||
|
|
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
|
||
|
|
assert.equal(store.getTask(task.id)?.status, "completed");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
|
||
|
|
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||
|
|
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||
|
|
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||
|
|
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
|
||
|
|
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
|
||
|
|
latchTransferPause(task.id);
|
||
|
|
let abort = false;
|
||
|
|
const running = runTransferAndWaitForOwner(task, async () => {
|
||
|
|
store.patchTask(task.id, { status: "completed" });
|
||
|
|
return { superseded: true };
|
||
|
|
}, () => abort);
|
||
|
|
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||
|
|
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
|
||
|
|
// An unrelated lifecycle publication captures the previous failed row while waiting.
|
||
|
|
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
|
||
|
|
t.after(() => store.dismiss("unrelated-admission"));
|
||
|
|
resetTransferPauseLatchesForTests();
|
||
|
|
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
|
||
|
|
const store = createSftpTransferCenterStore();
|
||
|
|
const task = child();
|
||
|
|
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
|
||
|
|
const paused = store.getTask(task.id)!;
|
||
|
|
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
|
||
|
|
assert.equal(store.admitTaskRun(task, paused), "ready");
|
||
|
|
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
|
||
|
|
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
|
||
|
|
const currentPause = store.getTask(task.id)!;
|
||
|
|
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
|
||
|
|
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
|
||
|
|
});
|