@gencow/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * packages/core/src/__tests__/network-sim.test.ts
3
+ *
4
+ * 네트워크 I/O 특성을 시뮬레이션한 부하 테스트.
5
+ *
6
+ * mock `send()`에 실제 네트워크 조건을 모델링:
7
+ * - LAN : P50 = 0.5ms, P99 = 2ms, loss = 0%
8
+ * - WAN : P50 = 30ms, P99 = 120ms, loss = 0.1%
9
+ * - Mobile: P50 = 80ms, P99 = 400ms, loss = 2%
10
+ * - 혼잡 : P50 = 200ms, P99 = 2000ms, loss = 5%
11
+ *
12
+ * 측정 지표:
13
+ * - 전체 delivery 시간 (P50 / P95 / P99)
14
+ * - 패킷 손실 후 실제 수신률
15
+ * - slow client(back-pressure)가 전체에 미치는 영향
16
+ *
17
+ * Run: bun test packages/core/src/__tests__/network-sim.test.ts
18
+ */
19
+
20
+ import { describe, it, expect } from "bun:test";
21
+ import { buildRealtimeCtx, subscribe, deregisterClient } from "../reactive";
22
+
23
+ // ─── 네트워크 조건 정의 ──────────────────────────────────────────────────────
24
+
25
+ interface NetworkProfile {
26
+ name: string;
27
+ p50LatencyMs: number; // 중간값 지연
28
+ p99LatencyMs: number; // 99번째 백분위 지연
29
+ lossRate: number; // 패킷 손실률 0~1
30
+ }
31
+
32
+ const PROFILES: Record<string, NetworkProfile> = {
33
+ LAN: { name: "LAN", p50LatencyMs: 0.5, p99LatencyMs: 2, lossRate: 0.000 },
34
+ WAN: { name: "WAN", p50LatencyMs: 30, p99LatencyMs: 120, lossRate: 0.001 },
35
+ Mobile: { name: "Mobile 4G", p50LatencyMs: 80, p99LatencyMs: 400, lossRate: 0.020 },
36
+ Congested: { name: "혼잡", p50LatencyMs: 200, p99LatencyMs: 2000, lossRate: 0.050 },
37
+ };
38
+
39
+ // ─── 네트워크 지연 샘플러 (log-normal 분포 근사) ────────────────────────────
40
+ // log-normal은 실제 네트워크 지연 분포와 유사함
41
+
42
+ function sampleLatency(p50: number, p99: number): number {
43
+ // log-normal: μ = ln(p50), σ 역산
44
+ const mu = Math.log(p50);
45
+ const sigma = (Math.log(p99) - mu) / 2.326; // z=2.326 = 99th percentile
46
+ // Box-Muller transform
47
+ const u1 = Math.random();
48
+ const u2 = Math.random();
49
+ const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
50
+ return Math.max(0, Math.exp(mu + sigma * z));
51
+ }
52
+
53
+ // ─── 네트워크 시뮬레이션 WS mock ─────────────────────────────────────────────
54
+
55
+ function makeNetworkWs(profile: NetworkProfile) {
56
+ const deliveryTimes: number[] = [];
57
+ let dropped = 0;
58
+ let sendCallCount = 0;
59
+ const sendStart = performance.now();
60
+
61
+ return {
62
+ send: (msg: string) => {
63
+ sendCallCount++;
64
+ // 패킷 손실 시뮬레이션
65
+ if (Math.random() < profile.lossRate) {
66
+ dropped++;
67
+ return; // 손실 — 전달 안 됨
68
+ }
69
+ // 지연 시뮬레이션 (비동기 — 실제 I/O 기다림)
70
+ const latency = sampleLatency(profile.p50LatencyMs, profile.p99LatencyMs);
71
+ setTimeout(() => {
72
+ deliveryTimes.push(performance.now() - sendStart);
73
+ }, latency);
74
+ },
75
+ readyState: 1,
76
+ get deliveryTimes() { return deliveryTimes; },
77
+ get dropped() { return dropped; },
78
+ get sendCallCount() { return sendCallCount; },
79
+ } as any;
80
+ }
81
+
82
+ // ─── 퍼센타일 계산 ───────────────────────────────────────────────────────────
83
+
84
+ function percentile(sorted: number[], p: number): number {
85
+ if (sorted.length === 0) return 0;
86
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
87
+ return sorted[Math.max(0, idx)];
88
+ }
89
+
90
+ function stats(times: number[]) {
91
+ const sorted = [...times].sort((a, b) => a - b);
92
+ return {
93
+ count: sorted.length,
94
+ p50: percentile(sorted, 50),
95
+ p95: percentile(sorted, 95),
96
+ p99: percentile(sorted, 99),
97
+ max: sorted[sorted.length - 1] ?? 0,
98
+ };
99
+ }
100
+
101
+ const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
102
+ function key() { return `net.${Math.random().toString(36).slice(2)}`; }
103
+
104
+ // ─── 1. LAN 환경 ─────────────────────────────────────────────────────────────
105
+
106
+ describe("[NetSim] LAN 환경 (P50=0.5ms, 손실 0%)", () => {
107
+ it("1,000 구독자 전체 delivery P99 < 10ms", async () => {
108
+ const N = 1_000;
109
+ const k = key();
110
+ const profile = PROFILES.LAN;
111
+ const clients = Array.from({ length: N }, () => makeNetworkWs(profile));
112
+ clients.forEach(ws => subscribe(k, ws));
113
+
114
+ buildRealtimeCtx().emit(k, [{ id: 1 }]);
115
+ await wait(60 + 20); // debounce + max LAN latency
116
+
117
+ const allTimes = clients.flatMap(ws => ws.deliveryTimes);
118
+ const s = stats(allTimes);
119
+ const successRate = (s.count / N) * 100;
120
+
121
+ console.log(`[LAN] delivered=${s.count}/${N} (${successRate.toFixed(1)}%), P50=${s.p50.toFixed(1)}ms P95=${s.p95.toFixed(1)}ms P99=${s.p99.toFixed(1)}ms`);
122
+
123
+ expect(s.count).toBe(N); // 손실 없음
124
+ expect(s.p99).toBeLessThan(200); // debounce(50) + LAN P99(2) + margin
125
+ clients.forEach(ws => deregisterClient(ws));
126
+ });
127
+ });
128
+
129
+ // ─── 2. WAN 환경 ─────────────────────────────────────────────────────────────
130
+
131
+ describe("[NetSim] WAN 환경 (P50=30ms, 손실 0.1%)", () => {
132
+ it("1,000 구독자 전체 delivery P95 < 300ms, 수신률 99% 이상", async () => {
133
+ const N = 1_000;
134
+ const k = key();
135
+ const profile = PROFILES.WAN;
136
+ const clients = Array.from({ length: N }, () => makeNetworkWs(profile));
137
+ clients.forEach(ws => subscribe(k, ws));
138
+
139
+ buildRealtimeCtx().emit(k, [{ id: 1 }]);
140
+ await wait(60 + 300); // debounce + WAN P99
141
+
142
+ const allTimes = clients.flatMap(ws => ws.deliveryTimes);
143
+ const s = stats(allTimes);
144
+ const successRate = (s.count / N) * 100;
145
+ const dropped = clients.reduce((a, ws) => a + ws.dropped, 0);
146
+
147
+ console.log(`[WAN] delivered=${s.count}/${N} (${successRate.toFixed(2)}%), dropped=${dropped}, P50=${s.p50.toFixed(1)}ms P95=${s.p95.toFixed(1)}ms P99=${s.p99.toFixed(1)}ms`);
148
+
149
+ expect(successRate).toBeGreaterThan(99); // 0.1% 손실 → 99% 이상 수신
150
+ expect(s.p95).toBeLessThan(300);
151
+ clients.forEach(ws => deregisterClient(ws));
152
+ });
153
+ });
154
+
155
+ // ─── 3. 모바일 환경 ──────────────────────────────────────────────────────────
156
+
157
+ describe("[NetSim] Mobile 4G 환경 (P50=80ms, 손실 2%)", () => {
158
+ it("500 구독자 전체 delivery P95 < 700ms, 수신률 95% 이상", async () => {
159
+ const N = 500;
160
+ const k = key();
161
+ const profile = PROFILES.Mobile;
162
+ const clients = Array.from({ length: N }, () => makeNetworkWs(profile));
163
+ clients.forEach(ws => subscribe(k, ws));
164
+
165
+ buildRealtimeCtx().emit(k, [{ id: 1 }]);
166
+ await wait(60 + 600); // debounce + Mobile P99
167
+
168
+ const allTimes = clients.flatMap(ws => ws.deliveryTimes);
169
+ const s = stats(allTimes);
170
+ const successRate = (s.count / N) * 100;
171
+ const dropped = clients.reduce((a, ws) => a + ws.dropped, 0);
172
+
173
+ console.log(`[Mobile] delivered=${s.count}/${N} (${successRate.toFixed(2)}%), dropped=${dropped}, P50=${s.p50.toFixed(1)}ms P95=${s.p95.toFixed(1)}ms P99=${s.p99.toFixed(1)}ms`);
174
+
175
+ expect(successRate).toBeGreaterThan(95); // 2% 손실 → 95%+ 수신
176
+ expect(s.p95).toBeLessThan(700);
177
+ clients.forEach(ws => deregisterClient(ws));
178
+ });
179
+ });
180
+
181
+ // ─── 4. Slow Client (back-pressure) 격리 ─────────────────────────────────────
182
+ //
183
+ // 일부 클라이언트가 매우 느릴 때 다른 클라이언트에 영향을 주지 않아야 함.
184
+ // (현재 구현: for loop의 send()가 동기적으로 setTimeout만 등록하므로 격리됨)
185
+
186
+ describe("[NetSim] Slow client back-pressure 격리", () => {
187
+ it("일부 slow client가 있어도 fast client 전달 시간은 영향 없다", async () => {
188
+ const N_FAST = 900;
189
+ const N_SLOW = 100;
190
+ const k = key();
191
+
192
+ const fastClients = Array.from({ length: N_FAST }, () => makeNetworkWs(PROFILES.LAN));
193
+ const slowClients = Array.from({ length: N_SLOW }, () => makeNetworkWs(PROFILES.Congested));
194
+
195
+ fastClients.forEach(ws => subscribe(k, ws));
196
+ slowClients.forEach(ws => subscribe(k, ws));
197
+
198
+ const emitStart = performance.now();
199
+ buildRealtimeCtx().emit(k, [{ id: 1 }]);
200
+ await wait(60 + 30); // debounce + LAN P99만 기다림 (slow client는 아직 미도달)
201
+ const elapsed = performance.now() - emitStart;
202
+
203
+ const fastDelivered = fastClients.flatMap(ws => ws.deliveryTimes).length;
204
+ const slowDelivered = slowClients.flatMap(ws => ws.deliveryTimes).length;
205
+
206
+ const fastRate = (fastDelivered / N_FAST) * 100;
207
+ const slowRate = (slowDelivered / N_SLOW) * 100;
208
+
209
+ console.log(`[back-pressure] fast: ${fastDelivered}/${N_FAST} (${fastRate.toFixed(1)}%) in ${elapsed.toFixed(1)}ms`);
210
+ console.log(`[back-pressure] slow: ${slowDelivered}/${N_SLOW} (${slowRate.toFixed(1)}%) — still in flight`);
211
+
212
+ // fast client는 LAN latency 이내에 거의 모두 도달
213
+ expect(fastRate).toBeGreaterThan(95);
214
+ // slow client는 혼잡 환경이라 아직 미도달 (격리 확인)
215
+ expect(slowRate).toBeLessThan(50); // 혼잡 환경 P50=200ms, 아직 30ms만 기다렸으므로
216
+
217
+ fastClients.forEach(ws => deregisterClient(ws));
218
+ slowClients.forEach(ws => deregisterClient(ws));
219
+ });
220
+ });
221
+
222
+ // ─── 5. 혼합 환경: LAN + WAN + Mobile 혼재 ──────────────────────────────────
223
+
224
+ describe("[NetSim] 혼합 환경 (LAN + WAN + Mobile)", () => {
225
+ it("500명 혼합 구독자 전체 delivery → 그룹별 지연 분포 확인", async () => {
226
+ const k = key();
227
+ const groups = {
228
+ lan: { n: 200, profile: PROFILES.LAN, clients: [] as any[] },
229
+ wan: { n: 200, profile: PROFILES.WAN, clients: [] as any[] },
230
+ mobile: { n: 100, profile: PROFILES.Mobile, clients: [] as any[] },
231
+ };
232
+
233
+ for (const g of Object.values(groups)) {
234
+ g.clients = Array.from({ length: g.n }, () => makeNetworkWs(g.profile));
235
+ g.clients.forEach((ws: any) => subscribe(k, ws));
236
+ }
237
+
238
+ buildRealtimeCtx().emit(k, [{ id: 1 }]);
239
+ await wait(60 + 600); // 가장 느린 Mobile P99까지 대기
240
+
241
+ for (const [name, g] of Object.entries(groups)) {
242
+ const allTimes = g.clients.flatMap((ws: any) => ws.deliveryTimes);
243
+ const s = stats(allTimes);
244
+ const dropped = g.clients.reduce((a: number, ws: any) => a + ws.dropped, 0);
245
+ const successRate = (s.count / g.n) * 100;
246
+ console.log(`[mixed/${name}] ${s.count}/${g.n} (${successRate.toFixed(1)}%), dropped=${dropped}, P50=${s.p50.toFixed(1)}ms P95=${s.p95.toFixed(1)}ms`);
247
+ }
248
+
249
+ // 전체 수신률
250
+ const totalDelivered = Object.values(groups).reduce(
251
+ (a, g) => a + g.clients.flatMap((ws: any) => ws.deliveryTimes).length, 0
252
+ );
253
+ const totalN = Object.values(groups).reduce((a, g) => a + g.n, 0);
254
+ const overallRate = (totalDelivered / totalN) * 100;
255
+
256
+ console.log(`[mixed/total] ${totalDelivered}/${totalN} (${overallRate.toFixed(1)}%)`);
257
+ expect(overallRate).toBeGreaterThan(97); // Mobile 2% 손실 반영
258
+
259
+ for (const g of Object.values(groups)) {
260
+ g.clients.forEach((ws: any) => deregisterClient(ws));
261
+ }
262
+ });
263
+ });
264
+
265
+ // ─── 6. debounce + 네트워크 지연 조합 효과 ──────────────────────────────────
266
+
267
+ describe("[NetSim] debounce + 네트워크 지연 조합", () => {
268
+ it("100번 rapid emit 후 단 1회 WAN 전송으로 전체 delivery 완료된다", async () => {
269
+ const N = 200;
270
+ const k = key();
271
+ const clients = Array.from({ length: N }, () => makeNetworkWs(PROFILES.WAN));
272
+ clients.forEach(ws => subscribe(k, ws));
273
+
274
+ const rt = buildRealtimeCtx();
275
+ // 30ms 간격으로 10번 emit (각 emit 사이에 짧은 간격, 50ms debounce 이내)
276
+ for (let i = 0; i < 10; i++) {
277
+ rt.emit(k, [{ id: i, title: `Update ${i}` }]);
278
+ await wait(4); // 4ms 간격 → 총 40ms < 50ms debounce window
279
+ }
280
+
281
+ // 마지막 emit 후 debounce(50ms) + WAN P99(120ms) 대기
282
+ await wait(50 + 200);
283
+
284
+ const allSendCounts = clients.map(ws => ws.sendCallCount);
285
+ const allDelivered = clients.flatMap(ws => ws.deliveryTimes);
286
+
287
+ // send()는 debounce로 인해 1번만 호출됨
288
+ expect(allSendCounts.every(c => c === 1)).toBe(true);
289
+
290
+ const successRate = (allDelivered.length / N) * 100;
291
+ console.log(`[debounce+net] sends: 1 per client, WAN delivered: ${allDelivered.length}/${N} (${successRate.toFixed(1)}%)`);
292
+
293
+ expect(successRate).toBeGreaterThan(99); // WAN 0.1% 손실
294
+ clients.forEach(ws => deregisterClient(ws));
295
+ });
296
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * packages/core/src/__tests__/reactive.test.ts
3
+ *
4
+ * Tests for ctx.realtime.emit() push model and invalidateQueries() simplification.
5
+ *
6
+ * Run: bun test packages/core/src/__tests__/reactive.test.ts
7
+ */
8
+
9
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
10
+ import { buildRealtimeCtx, invalidateQueries, subscribe, deregisterClient, registerClient } from "../reactive";
11
+ import type { GencowCtx } from "../reactive";
12
+
13
+ // ─── Mock WebSocket (Bun-style WSContext) ────────────────────────────────────
14
+
15
+ function makeMockWs() {
16
+ const sent: string[] = [];
17
+ return {
18
+ send: (msg: string) => sent.push(msg),
19
+ readyState: 1,
20
+ _sent: sent,
21
+ } as any;
22
+ }
23
+
24
+ // ─── buildRealtimeCtx ────────────────────────────────────────────────────────
25
+
26
+ describe("buildRealtimeCtx()", () => {
27
+ it("각 mutation 호출마다 독립적인 인스턴스를 반환한다", () => {
28
+ const a = buildRealtimeCtx();
29
+ const b = buildRealtimeCtx();
30
+ expect(a).not.toBe(b);
31
+ });
32
+
33
+ it("emit() 호출 시 해당 queryKey 구독자에게 query:updated 메시지를 push한다", async () => {
34
+ const ws = makeMockWs();
35
+ subscribe("test.list", ws);
36
+
37
+ const rt = buildRealtimeCtx();
38
+ rt.emit("test.list", [{ id: 1, title: "Task A" }]);
39
+
40
+ // 50ms debounce 대기
41
+ await new Promise(r => setTimeout(r, 60));
42
+
43
+ expect(ws._sent).toHaveLength(1);
44
+ const msg = JSON.parse(ws._sent[0]);
45
+ expect(msg.type).toBe("query:updated");
46
+ expect(msg.query).toBe("test.list");
47
+ expect(msg.data).toEqual([{ id: 1, title: "Task A" }]);
48
+
49
+ deregisterClient(ws);
50
+ });
51
+
52
+ it("50ms 내 동일 queryKey에 대한 연속 emit은 마지막 데이터만 push한다 (debounce)", async () => {
53
+ const ws = makeMockWs();
54
+ subscribe("test.debounce", ws);
55
+
56
+ const rt = buildRealtimeCtx();
57
+ rt.emit("test.debounce", [{ id: 1 }]); // 무시됨
58
+ rt.emit("test.debounce", [{ id: 2 }]); // 무시됨
59
+ rt.emit("test.debounce", [{ id: 3 }]); // 최종 emit
60
+
61
+ // 50ms debounce 대기
62
+ await new Promise(r => setTimeout(r, 80));
63
+
64
+ expect(ws._sent).toHaveLength(1);
65
+ const msg = JSON.parse(ws._sent[0]);
66
+ expect(msg.data).toEqual([{ id: 3 }]); // 마지막 값만 전달
67
+
68
+ deregisterClient(ws);
69
+ });
70
+
71
+ it("서로 다른 queryKey emit은 각각 독립적으로 처리된다", async () => {
72
+ const ws = makeMockWs();
73
+ subscribe("alpha.list", ws);
74
+ subscribe("beta.list", ws);
75
+
76
+ const rt = buildRealtimeCtx();
77
+ rt.emit("alpha.list", [{ id: 10 }]);
78
+ rt.emit("beta.list", [{ id: 20 }]);
79
+
80
+ await new Promise(r => setTimeout(r, 80));
81
+
82
+ expect(ws._sent).toHaveLength(2);
83
+ const queries = ws._sent.map((s: string) => JSON.parse(s).query);
84
+ expect(queries).toContain("alpha.list");
85
+ expect(queries).toContain("beta.list");
86
+
87
+ deregisterClient(ws);
88
+ });
89
+
90
+ it("구독자가 없으면 아무것도 전송하지 않는다", async () => {
91
+ const rt = buildRealtimeCtx();
92
+ rt.emit("no.subscribers", [{ id: 99 }]);
93
+
94
+ await new Promise(r => setTimeout(r, 80));
95
+ // no assertion needed — just must not throw
96
+ });
97
+ });
98
+
99
+ // ─── invalidateQueries (simplified) ─────────────────────────────────────────
100
+
101
+ describe("invalidateQueries() — simplified broadcast-only", () => {
102
+ it("빈 배열이면 아무것도 전송하지 않는다 (emit() 방식 no-op)", async () => {
103
+ const ws = makeMockWs();
104
+ registerClient(ws);
105
+
106
+ const mockCtx = {} as GencowCtx;
107
+ await invalidateQueries([], mockCtx);
108
+
109
+ expect(ws._sent).toHaveLength(0);
110
+ deregisterClient(ws);
111
+ });
112
+
113
+ it("queryKeys가 있으면 connectedClients 전체에 invalidate broadcast를 보낸다", async () => {
114
+ const ws = makeMockWs();
115
+ registerClient(ws);
116
+
117
+ const mockCtx = {} as GencowCtx;
118
+ await invalidateQueries(["tasks.list"], mockCtx);
119
+
120
+ expect(ws._sent).toHaveLength(1);
121
+ const msg = JSON.parse(ws._sent[0]);
122
+ expect(msg.type).toBe("invalidate");
123
+ expect(msg.queries).toEqual(["tasks.list"]);
124
+
125
+ deregisterClient(ws);
126
+ });
127
+
128
+ it("서버에서 쿼리를 재실행하지 않는다 (query:updated 미전송)", async () => {
129
+ const ws = makeMockWs();
130
+ registerClient(ws);
131
+
132
+ const mockCtx = {} as GencowCtx;
133
+ await invalidateQueries(["tasks.list", "tasks.get"], mockCtx);
134
+
135
+ // query:updated 메시지가 없어야 함
136
+ const types = ws._sent.map((s: string) => JSON.parse(s).type);
137
+ expect(types.every((t: string) => t === "invalidate")).toBe(true);
138
+
139
+ deregisterClient(ws);
140
+ });
141
+ });
142
+
143
+ // ─── buildRealtimeCtx + invalidateQueries 공존 시나리오 ─────────────────────
144
+
145
+ describe("emit() 방식과 legacy invalidateQueries() 혼용", () => {
146
+ it("emit()은 query:updated, invalidateQueries()는 invalidate를 각각 전송한다", async () => {
147
+ const wsSubscribed = makeMockWs(); // query 구독 클라이언트
148
+ const wsConnected = makeMockWs(); // 연결만 된 클라이언트 (대시보드)
149
+
150
+ subscribe("items.list", wsSubscribed);
151
+ registerClient(wsConnected);
152
+
153
+ // 1. emit() — 구독자에게만 query:updated
154
+ const rt = buildRealtimeCtx();
155
+ rt.emit("items.list", [{ id: 1 }]);
156
+ await new Promise(r => setTimeout(r, 80));
157
+
158
+ expect(wsSubscribed._sent.some((s: string) => JSON.parse(s).type === "query:updated")).toBe(true);
159
+ // connectedClients에만 등록된 ws는 query:updated 미수신
160
+ expect(wsConnected._sent).toHaveLength(0);
161
+
162
+ // 2. invalidateQueries() — ALL connectedClients에 invalidate broadcast
163
+ const mockCtx = {} as GencowCtx;
164
+ await invalidateQueries(["items.list"], mockCtx);
165
+
166
+ expect(wsConnected._sent).toHaveLength(1);
167
+ expect(JSON.parse(wsConnected._sent[0]).type).toBe("invalidate");
168
+
169
+ deregisterClient(wsSubscribed);
170
+ deregisterClient(wsConnected);
171
+ });
172
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { withRetry } from "../retry";
3
+
4
+ describe("withRetry", () => {
5
+ test("성공 시 즉시 반환", async () => {
6
+ const result = await withRetry(async () => "success");
7
+ expect(result).toBe("success");
8
+ });
9
+
10
+ test("첫 시도 실패 후 재시도 성공", async () => {
11
+ let attempt = 0;
12
+ const result = await withRetry(async () => {
13
+ attempt++;
14
+ if (attempt < 2) throw new Error("fail");
15
+ return "recovered";
16
+ }, { initialBackoffMs: 10 });
17
+
18
+ expect(result).toBe("recovered");
19
+ expect(attempt).toBe(2);
20
+ });
21
+
22
+ test("maxAttempts 초과 시 마지막 에러 throw", async () => {
23
+ let attempt = 0;
24
+ try {
25
+ await withRetry(async () => {
26
+ attempt++;
27
+ throw new Error(`fail-${attempt}`);
28
+ }, { maxAttempts: 3, initialBackoffMs: 10 });
29
+ expect(true).toBe(false); // 여기에 도달하면 안됨
30
+ } catch (err: unknown) {
31
+ expect((err as Error).message).toBe("fail-3");
32
+ expect(attempt).toBe(3);
33
+ }
34
+ });
35
+
36
+ test("shouldRetry가 false 반환 시 즉시 throw", async () => {
37
+ let attempt = 0;
38
+ try {
39
+ await withRetry(async () => {
40
+ attempt++;
41
+ throw new Error("non-retryable");
42
+ }, {
43
+ maxAttempts: 5,
44
+ initialBackoffMs: 10,
45
+ shouldRetry: (err) => (err as Error).message !== "non-retryable",
46
+ });
47
+ } catch (err: unknown) {
48
+ expect((err as Error).message).toBe("non-retryable");
49
+ expect(attempt).toBe(1); // 재시도 없이 즉시 실패
50
+ }
51
+ });
52
+
53
+ test("onRetry 콜백 호출", async () => {
54
+ let attempt = 0;
55
+ const retries: number[] = [];
56
+
57
+ await withRetry(async () => {
58
+ attempt++;
59
+ if (attempt < 3) throw new Error("fail");
60
+ return "ok";
61
+ }, {
62
+ initialBackoffMs: 10,
63
+ onRetry: (_err, attemptNum, _delay) => {
64
+ retries.push(attemptNum);
65
+ },
66
+ });
67
+
68
+ expect(retries).toEqual([1, 2]);
69
+ });
70
+
71
+ test("maxAttempts < 1이면 에러", async () => {
72
+ try {
73
+ await withRetry(async () => "ok", { maxAttempts: 0 });
74
+ expect(true).toBe(false);
75
+ } catch (err: unknown) {
76
+ expect((err as Error).message).toContain("maxAttempts must be >= 1");
77
+ }
78
+ });
79
+
80
+ test("maxAttempts = 1이면 재시도 없이 즉시 throw", async () => {
81
+ let attempt = 0;
82
+ try {
83
+ await withRetry(async () => {
84
+ attempt++;
85
+ throw new Error("once");
86
+ }, { maxAttempts: 1 });
87
+ } catch (err: unknown) {
88
+ expect((err as Error).message).toBe("once");
89
+ expect(attempt).toBe(1);
90
+ }
91
+ });
92
+
93
+ test("비동기 함수 결과 타입 보존", async () => {
94
+ const result = await withRetry(async () => ({ id: 1, name: "test" }));
95
+ expect(result.id).toBe(1);
96
+ expect(result.name).toBe("test");
97
+ });
98
+ });