Files
clean-architecture-frontend…/tests/unit/abortable-operation.test.ts
T
DongHyeonkaandClaude Opus 5 46e067e555 fix: put presigned transfer work inside one owned abort scope
TR-RR-05. The shared abortable-operation primitive now distinguishes VALUE,
REJECTED and TERMINAL, so a collaborator's own rejection is no longer forged
into a cancellation and race() always names the same first owner terminal()
reports. The caller signal and scheduler are captured once, so replacing a
method after construction cannot change how an in-flight operation is bounded.
A scheduler that cannot install the deadline is itself terminal: previously it
released the caller listener and left no owner, which made every later abort
invisible. Late values are compensated exactly once.

Both presigned subsystems, which each carried their own copy of these
mechanics, are now projections of that primitive — giving it real production
importers rather than a shared helper nobody used.

TR-RR-01. close() on an active download aborts the scope instead of only
dropping listeners, so a fetch or read already in flight actually stops. The
consumer's stream signal joins the operation's ownership before any I/O begins,
so an already-aborted consumer no longer causes one network request first.

TR-RR-02. The upload abort scope is created before the digest, and the digest
races the caller and the deadline like every other step. A non-settling hash can
no longer hold put() open, and the vault claim and the network call happen only
after the owner is re-checked.

TR-RR-03. A capability registration is a versioned exact union validated at
registration time: the protocol version, HTTPS only, exact own-data fields, a
2xx expected status, and no ambient credential or cookie header — a presigned
URL carries its own authorization, and a session header alongside it would send
the user's credentials to that origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:06:46 +09:00

213 lines
7.0 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
compensateLateHandle,
createAbortableOperation,
} from "../../src/adapters/platform/abortable-operation.ts";
/**
* BT-X-01 golden tests. The utility owns abort mechanics only: it must never
* import or imply a subsystem result taxonomy.
*/
describe("shared abortable operation mechanics", () => {
it("records the first terminal owner and never overwrites it", async () => {
const caller = new AbortController();
const timers: Array<() => void> = [];
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: (callback) => {
timers.push(callback);
return timers.length;
},
clearTimer: () => {},
});
expect(operation.terminal()).toBeNull();
caller.abort();
expect(operation.terminal()).toBe("CALLER_ABORT");
// A later deadline or close cannot rewrite the owner.
timers.forEach((callback) => callback());
operation.close();
expect(operation.terminal()).toBe("CALLER_ABORT");
});
it("reports a deadline owner and aborts the composed signal", async () => {
const timers: Array<() => void> = [];
const operation = createAbortableOperation({
timeoutMs: 5,
setTimer: (callback) => {
timers.push(callback);
return timers.length;
},
clearTimer: () => {},
});
timers[0]?.();
expect(operation.terminal()).toBe("DEADLINE");
expect(operation.signal.aborted).toBe(true);
});
it("race returns a terminal owner instead of a bare value", async () => {
const caller = new AbortController();
const operation = createAbortableOperation({ signal: caller.signal });
let release: ((value: string) => void) | undefined;
const pending = new Promise<string>((resolve) => {
release = resolve;
});
const raced = operation.race(pending);
caller.abort();
await expect(raced).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
// The late value is observed and discarded.
release?.("late");
await expect(operation.race(pending)).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
});
it("race returns the value when nothing terminal happened", async () => {
const operation = createAbortableOperation();
await expect(operation.race(Promise.resolve(7))).resolves.toEqual({
kind: "VALUE",
value: 7,
});
operation.close();
expect(operation.terminal()).toBe("CLOSED");
});
/**
* TR-RR-05. A collaborator's own rejection is evidence about the work.
* Reporting it as `TERMINAL/CLOSED` erased the reason the operation failed
* and made `race()` disagree with `terminal()`, which still said no owner.
*/
it("keeps a rejection distinct from a terminal owner", async () => {
const operation = createAbortableOperation();
const reason = new Error("upstream failed");
await expect(
operation.race(Promise.reject(reason)),
).resolves.toEqual({ kind: "REJECTED", reason });
expect(operation.terminal()).toBeNull();
});
it("agrees with terminal() on the first owner", async () => {
const caller = new AbortController();
const operation = createAbortableOperation({ signal: caller.signal });
const pending = operation.race(new Promise<never>(() => {}));
caller.abort();
// A later close cannot overwrite the first owner in either place.
operation.close();
await expect(pending).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
expect(operation.terminal()).toBe("CALLER_ABORT");
});
it("compensates a late value exactly once instead of admitting it", async () => {
const caller = new AbortController();
const operation = createAbortableOperation({ signal: caller.signal });
const compensated: string[] = [];
let release: ((value: string) => void) | undefined;
const pending = operation.race(
new Promise<string>((resolve) => {
release = resolve;
}),
(value) => compensated.push(value),
);
caller.abort();
expect(await pending).toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
release?.("late-value");
await Promise.resolve();
await Promise.resolve();
expect(compensated).toEqual(["late-value"]);
});
it("ignores a scheduler method replaced after construction", async () => {
const scheduler = {
setTimer: (callback: () => void, delayMs: number) =>
setTimeout(callback, delayMs),
clearTimer: (handle: unknown) => {
clearTimeout(handle as ReturnType<typeof setTimeout>);
},
};
const operation = createAbortableOperation({
timeoutMs: 5,
setTimer: scheduler.setTimer,
clearTimer: scheduler.clearTimer,
});
scheduler.setTimer = () => {
throw new TypeError("replaced after construction");
};
await expect(
operation.race(new Promise<never>(() => {})),
).resolves.toEqual({ kind: "TERMINAL", terminal: "DEADLINE" });
});
it("close is idempotent and releases listeners and timers exactly once", () => {
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
const clearTimer = vi.fn();
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: () => "handle",
clearTimer,
});
operation.close();
operation.close();
operation.close();
expect(remove).toHaveBeenCalledTimes(1);
expect(clearTimer).toHaveBeenCalledTimes(1);
});
/**
* TR-RR-05. A scheduler that cannot install the deadline leaves the operation
* unbounded. Removing the caller listener and leaving no terminal owner made
* every later abort invisible, so installation failure is itself terminal and
* closes atomically with the resources already created.
*/
it("closes atomically when the scheduler cannot install the deadline", async () => {
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: () => {
throw new TypeError("scheduler exploded");
},
clearTimer: () => {},
});
expect(operation.terminal()).toBe("CLOSED");
expect(remove).toHaveBeenCalledTimes(1);
// The operation is bounded, so a caller abort afterwards cannot be lost.
caller.abort();
await expect(
operation.race(new Promise<never>(() => {})),
).resolves.toEqual({ kind: "TERMINAL", terminal: "CLOSED" });
});
it("compensates a late native handle without changing the outcome", async () => {
const cancel = vi.fn(async () => {});
compensateLateHandle(Promise.resolve({ body: { cancel } }));
await Promise.resolve();
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
// A rejecting handle is swallowed.
compensateLateHandle(Promise.reject(new Error("late")));
await Promise.resolve();
});
});