Files
clean-architecture-frontend…/tests/unit/realtime/realtime-reconnect-policy.test.ts
T

161 lines
4.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
calculateReconnectDelay,
defineReconnectPolicy,
isReconnectAttemptResetEligible,
parseRetryAfterDelay,
REALTIME_RECONNECT_CEILINGS,
reconnectBudgetRemaining,
type ReconnectPolicy,
} from "../../../src/adapters/realtime/reconnect-policy.ts";
const policy = defineReconnectPolicy({
baseDelayMs: 1_000,
maxDelayMs: 60_000,
maxAttempts: 10,
maxElapsedMs: 300_000,
stableOpenMs: 30_000,
});
describe("realtime reconnect policy", () => {
it("uses full jitter and treats a server hint as a not-before floor", () => {
expect(
calculateReconnectDelay({
policy,
attemptIndex: 2,
remainingElapsedMs: 10_000,
random: () => 0.5,
}),
).toBe(2_000);
expect(
calculateReconnectDelay({
policy,
attemptIndex: 2,
remainingElapsedMs: 10_000,
random: () => 0.5,
serverNotBeforeMs: 3_000,
}),
).toBe(3_000);
});
it("stops instead of clamping a hint past a hard or remaining budget", () => {
expect(
calculateReconnectDelay({
policy,
attemptIndex: 0,
remainingElapsedMs: 100_000,
random: () => 0,
serverNotBeforeMs: 60_001,
}),
).toBeNull();
expect(
calculateReconnectDelay({
policy,
attemptIndex: 0,
remainingElapsedMs: 3_000,
random: () => 0,
serverNotBeforeMs: 3_000,
}),
).toBeNull();
expect(
calculateReconnectDelay({
policy,
attemptIndex: 10,
remainingElapsedMs: 100_000,
random: () => 0,
}),
).toBeNull();
});
it("parses only bounded-shape Retry-After syntax for caller validation", () => {
expect(parseRetryAfterDelay("12", 0)).toBe(12_000);
expect(
parseRetryAfterDelay(
"Thu, 01 Jan 1970 00:00:20 GMT",
5_000,
),
).toBe(15_000);
expect(parseRetryAfterDelay("1.5", 0)).toBeNull();
expect(parseRetryAfterDelay("-1", 0)).toBeNull();
});
it("resets attempts only after stable open or a valid signal", () => {
expect(
isReconnectAttemptResetEligible({
policy,
openedAtMs: 10,
nowMs: 29_000,
observedValidHeartbeatOrEvent: false,
}),
).toBe(false);
expect(
isReconnectAttemptResetEligible({
policy,
openedAtMs: 10,
nowMs: 30_010,
observedValidHeartbeatOrEvent: false,
}),
).toBe(true);
expect(
isReconnectAttemptResetEligible({
policy,
openedAtMs: 10,
nowMs: 11,
observedValidHeartbeatOrEvent: true,
}),
).toBe(true);
expect(reconnectBudgetRemaining(policy, 1_000, 2_000)).toBe(
299_000,
);
expect(reconnectBudgetRemaining(policy, 2_000, 1_000)).toBe(0);
});
it("rejects policies above the implementation ceiling", () => {
expect(() =>
defineReconnectPolicy({
...policy,
maxAttempts: 11,
}),
).toThrow(TypeError);
});
it("keeps aborted-task drain below the absolute implementation ceiling", () => {
expect(REALTIME_RECONNECT_CEILINGS.drainTimeoutMs).toBe(2_000);
expect(
REALTIME_RECONNECT_CEILINGS.drainTimeoutMs,
).toBeLessThanOrEqual(
REALTIME_RECONNECT_CEILINGS.maxDrainTimeoutMs,
);
expect(
REALTIME_RECONNECT_CEILINGS.maxDrainTimeoutMs,
).toBe(30_000);
});
it("snapshots only exact own data properties", () => {
const inherited = Object.create(policy) as ReconnectPolicy;
const accessor = Object.defineProperties(
{},
{
baseDelayMs: { enumerable: true, value: 1_000 },
maxDelayMs: {
enumerable: true,
get: () => 60_000,
},
maxAttempts: { enumerable: true, value: 10 },
maxElapsedMs: { enumerable: true, value: 300_000 },
stableOpenMs: { enumerable: true, value: 30_000 },
},
) as ReconnectPolicy;
expect(() => defineReconnectPolicy(inherited)).toThrow(TypeError);
expect(() => defineReconnectPolicy(accessor)).toThrow(TypeError);
expect(() =>
defineReconnectPolicy({
...policy,
extra: true,
} as ReconnectPolicy),
).toThrow(TypeError);
});
});