feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SSE_CONTINUE,
|
||||
createFetchSseConnection,
|
||||
} from "../../../src/adapters/realtime/sse/fetch-sse-connection.ts";
|
||||
import {
|
||||
realtimeTransportRecoveryCommitted,
|
||||
type RealtimeRecoveryCheckpoint,
|
||||
} from "../../../src/application/ports/realtime/event-authority.ts";
|
||||
import type { RealtimeResult } from "../../../src/application/ports/realtime/shared.ts";
|
||||
import type {
|
||||
StreamRegistrationId,
|
||||
} from "../../../src/contracts/realtime-streams.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const continueEvent = () => realtimeSuccess(SSE_CONTINUE);
|
||||
const RECOVERY_CHECKPOINT = Object.freeze({
|
||||
recoveryMode: "CURSOR" as const,
|
||||
streamEpoch: "stream-epoch.0001",
|
||||
lastAppliedSequence: "1",
|
||||
resumeCursor: "cursor-1",
|
||||
}) as RealtimeRecoveryCheckpoint;
|
||||
const RECOVERY_OUTCOME = realtimeTransportRecoveryCommitted(
|
||||
"REFERENCE_STREAM" as StreamRegistrationId,
|
||||
RECOVERY_CHECKPOINT,
|
||||
);
|
||||
|
||||
function eventStream(
|
||||
chunks: readonly string[],
|
||||
options: Readonly<{
|
||||
status?: number;
|
||||
contentType?: string;
|
||||
}> = {},
|
||||
): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: options.status ?? 200,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
options.contentType ?? "text/event-stream; charset=utf-8",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function connection(
|
||||
fetcher: typeof fetch,
|
||||
recoveryMode: "CURSOR" | "SESSION_REBUILD" | "SNAPSHOT_ONLY" =
|
||||
"CURSOR",
|
||||
) {
|
||||
return createFetchSseConnection({
|
||||
endpoint: "https://app.example.test/events",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
describe("fetch SSE connection", () => {
|
||||
it("uses the fixed request profile and streams events sequentially", async () => {
|
||||
const response = eventStream([
|
||||
": heartbeat\r\n",
|
||||
"retry: 2500\n",
|
||||
"id: cursor-1\ndata: first\n",
|
||||
"data: second\n\n",
|
||||
]);
|
||||
const fetcher = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
response,
|
||||
);
|
||||
const received: string[] = [];
|
||||
const comments = vi.fn();
|
||||
const hints = vi.fn();
|
||||
const runtime = connection(fetcher as typeof fetch);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: "cursor-0",
|
||||
onEvent: async (event) => {
|
||||
received.push(event.data);
|
||||
return realtimeSuccess(SSE_CONTINUE);
|
||||
},
|
||||
onComment: comments,
|
||||
onRetryHint: hints,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: false,
|
||||
retryHintMs: 2_500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(received).toEqual(["first\nsecond"]);
|
||||
expect(comments).toHaveBeenCalledOnce();
|
||||
expect(hints).toHaveBeenCalledWith(2_500);
|
||||
const [target, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(target).toBe("https://app.example.test/events");
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
});
|
||||
expect(new Headers(init?.headers)).toEqual(
|
||||
new Headers({
|
||||
Accept: "text/event-stream",
|
||||
"Last-Event-ID": "cursor-0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds event consumption behind the validated open barrier gate", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: after-barrier\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
let release:
|
||||
| ((result: RealtimeResult<void>) => void)
|
||||
| undefined;
|
||||
const gate = new Promise<RealtimeResult<void>>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const onOpen = vi.fn(() => gate);
|
||||
const onEvent = vi.fn(continueEvent);
|
||||
|
||||
const reading = runtime.read({
|
||||
resumeCursor: null,
|
||||
onOpen,
|
||||
onEvent,
|
||||
});
|
||||
await vi.waitFor(() => expect(onOpen).toHaveBeenCalledOnce());
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
|
||||
release?.(realtimeSuccess(undefined));
|
||||
await expect(reading).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "EOF" },
|
||||
});
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates an exact open-barrier failure before reading events", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: unreachable\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const barrierFailure = realtimeFailure(
|
||||
"SCOPE_FENCED",
|
||||
"RECOVER",
|
||||
false,
|
||||
);
|
||||
const onEvent = vi.fn(continueEvent);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onOpen: () => barrierFailure,
|
||||
onEvent,
|
||||
}),
|
||||
).resolves.toBe(barrierFailure);
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats 204 as terminal and rejects incorrect media types", async () => {
|
||||
const terminal = connection(
|
||||
(async () => new Response(null, { status: 204 })) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
terminal.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { kind: "NO_RECONNECT" },
|
||||
});
|
||||
|
||||
const wrongType = connection(
|
||||
(async () =>
|
||||
eventStream(["data: value\n\n"], {
|
||||
contentType: "application/json",
|
||||
})) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
wrongType.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, "AUTH_REQUIRED", false],
|
||||
[403, "FORBIDDEN", false],
|
||||
[409, "CURSOR_EXPIRED", false],
|
||||
[410, "CURSOR_EXPIRED", false],
|
||||
[429, "RATE_LIMITED", true],
|
||||
[502, "PROVIDER_UNAVAILABLE", true],
|
||||
[503, "PROVIDER_UNAVAILABLE", true],
|
||||
[504, "PROVIDER_UNAVAILABLE", true],
|
||||
[500, "PROTOCOL_MISMATCH", false],
|
||||
] as const)(
|
||||
"maps HTTP %i to %s without exposing a response body",
|
||||
async (status, kind, retryable) => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response("private backend text", {
|
||||
status,
|
||||
headers:
|
||||
status === 429 || status === 503
|
||||
? { "Retry-After": "10" }
|
||||
: undefined,
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const result = await runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "CONNECT",
|
||||
retryable,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("private backend");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[429, "RATE_LIMITED"],
|
||||
[503, "PROVIDER_UNAVAILABLE"],
|
||||
] as const)(
|
||||
"does not retry HTTP %i without a bounded server hint",
|
||||
async (status, kind) => {
|
||||
const runtime = connection(
|
||||
(async () => new Response(null, { status })) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not clamp an excessive Retry-After into an early retry", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "120" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RATE_LIMITED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces direct event IDs only for CURSOR recovery", async () => {
|
||||
const missingCursorId = connection(
|
||||
(async () =>
|
||||
eventStream(["data: value\n\n"])) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
missingCursorId.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const unexpectedCursorId = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: value\n\n",
|
||||
])) as typeof fetch,
|
||||
"SNAPSHOT_ONLY",
|
||||
);
|
||||
await expect(
|
||||
unexpectedCursorId.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stops the old stream after authority recovery commits", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: first\n\n",
|
||||
"id: cursor-2\ndata: stale-generation\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const onEvent = vi.fn(() =>
|
||||
realtimeSuccess(RECOVERY_OUTCOME),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: RECOVERY_OUTCOME,
|
||||
});
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates a canonical consumer failure and aborts its event generation", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: forbidden\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
let handlerSignal: AbortSignal | undefined;
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: (_event, signal) => {
|
||||
handlerSignal = signal;
|
||||
return realtimeFailure("FORBIDDEN", "RECEIVE");
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("FORBIDDEN", "RECEIVE"),
|
||||
);
|
||||
expect(handlerSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("fences recovery immediately and waits for bounded reader cancellation", async () => {
|
||||
let releaseCancellation: (() => void) | undefined;
|
||||
let cancellationStarted = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode("id: cursor-1\ndata: first\n\n"),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancellationStarted = true;
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseCancellation = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const pending = runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: () =>
|
||||
realtimeSuccess(RECOVERY_OUTCOME),
|
||||
});
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(cancellationStarted).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
releaseCancellation?.();
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: true,
|
||||
value: RECOVERY_OUTCOME,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards incomplete EOF and closes an active reader idempotently", async () => {
|
||||
const incomplete = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: incomplete\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
incomplete.read({
|
||||
resumeCursor: null,
|
||||
onEvent() {
|
||||
throw new Error("must not run");
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: true,
|
||||
retryHintMs: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pendingStream = new ReadableStream<Uint8Array>({
|
||||
pull: () => new Promise<void>(() => {}),
|
||||
});
|
||||
const active = connection(
|
||||
(async () =>
|
||||
new Response(pendingStream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const pending = active.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
active.close();
|
||||
active.close();
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
active.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CLOSED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches a CR-terminated blank block completed at EOF", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\rdata: final\r\r",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const received = vi.fn(() =>
|
||||
realtimeSuccess(SSE_CONTINUE),
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: received,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: false,
|
||||
},
|
||||
});
|
||||
expect(received).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: "final" }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects arbitrary endpoints and mode-incoherent cursors before fetch", async () => {
|
||||
expect(() =>
|
||||
createFetchSseConnection({
|
||||
endpoint: "https://other.example.test/events",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode: "CURSOR",
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
createFetchSseConnection({
|
||||
endpoint:
|
||||
"https://app.example.test/events?token=not-allowed",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode: "CURSOR",
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
|
||||
const fetcher = vi.fn(
|
||||
async () => eventStream(["data: unreachable\n\n"]),
|
||||
);
|
||||
const snapshotOnly = connection(
|
||||
fetcher as unknown as typeof fetch,
|
||||
"SNAPSHOT_ONLY",
|
||||
);
|
||||
await expect(
|
||||
snapshotOnly.read({
|
||||
resumeCursor: "cursor-not-allowed",
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user