@solhun/feedback-kit-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,848 @@
1
+ // DP-252 전송 보장 검증.
2
+ //
3
+ // 이 파일의 통합 테스트는 "가짜 수집 서버 + 문서화된 HTTP 계약을 그대로 구현한 테스트용
4
+ // 어댑터"를 상대로 돈다. 실 엔드포인트가 아직 배포돼 있지 않아서다(실 왕복 TC와 성능
5
+ // TC는 `lasso` 어댑터가 생기는 DP-255 에서 시도한다 — 워크패드에 명시).
6
+ // 여기서 검증하는 것은 코어가 지켜야 할 것들이다:
7
+ // 큐 적재 시점 / 제거 조건(2xx **및** ok=true) / 멱등 키 재사용 / 보관 한도 / 백오프.
8
+
9
+ import { beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import {
12
+ BACKOFF_MAX_MS,
13
+ defaultBackoff,
14
+ FeedbackQueue,
15
+ MAX_QUEUE_ITEMS,
16
+ RATE_LIMIT_MIN_DELAY_MS,
17
+ } from "./queue.js";
18
+ import { byteLengthOf, canUseKeepalive } from "./keepalive.js";
19
+ import type {
20
+ FeedbackAdapter,
21
+ FeedbackReport,
22
+ FeedbackStorage,
23
+ SubmitResult,
24
+ } from "./types.js";
25
+
26
+ // ────────────────────────────────────────────────────────────────────────────
27
+ // 테스트 도구
28
+ // ────────────────────────────────────────────────────────────────────────────
29
+
30
+ const QUEUE_KEY = "feedback-kit:queue";
31
+ const START_TIME = 1_700_000_000_000;
32
+ const DAY_MS = 24 * 60 * 60 * 1000;
33
+
34
+ let clockNow = START_TIME;
35
+ const clock = (): number => clockNow;
36
+ const advance = (ms: number): void => {
37
+ clockNow += ms;
38
+ };
39
+
40
+ let seq = 0;
41
+ function makeReport(over: Partial<FeedbackReport> = {}): FeedbackReport {
42
+ seq += 1;
43
+ return {
44
+ clientSubmissionId: `sub-${seq}`,
45
+ kind: "report",
46
+ comment: `테스트 제보 ${seq}`,
47
+ priority: "unset",
48
+ screenshot: null,
49
+ pin: null,
50
+ element: null,
51
+ context: {
52
+ app: "test-app",
53
+ screen: "/home",
54
+ url: null,
55
+ sessionId: "session-1",
56
+ user: { id: "guest_1", email: null, isGuest: true },
57
+ source: { screenId: null, sourceFile: null },
58
+ platform: "unknown",
59
+ timezone: "Asia/Seoul",
60
+ clientTimestamp: new Date(clockNow).toISOString(),
61
+ native: null,
62
+ web: null,
63
+ diagnostics: null,
64
+ extra: {},
65
+ },
66
+ createdAt: new Date(clockNow).toISOString(),
67
+ ...over,
68
+ };
69
+ }
70
+
71
+ /** 메모리 저장소. `limitBytes` 를 주면 그 크기를 넘는 쓰기를 쿼터 초과로 거절한다. */
72
+ function memoryStorage(limitBytes = Number.POSITIVE_INFINITY): FeedbackStorage & {
73
+ dump(): unknown;
74
+ } {
75
+ const map = new Map<string, string>();
76
+ return {
77
+ async get(key) {
78
+ return map.get(key) ?? null;
79
+ },
80
+ async set(key, value) {
81
+ if (value.length > limitBytes) {
82
+ throw new Error("QuotaExceededError");
83
+ }
84
+ map.set(key, value);
85
+ },
86
+ async remove(key) {
87
+ map.delete(key);
88
+ },
89
+ dump() {
90
+ const raw = map.get(QUEUE_KEY);
91
+ return raw === undefined ? null : JSON.parse(raw);
92
+ },
93
+ };
94
+ }
95
+
96
+ interface StubStep {
97
+ status: number;
98
+ body?: unknown;
99
+ headers?: Record<string, string>;
100
+ /** 네트워크 자체가 끊긴 상황. */
101
+ networkError?: boolean;
102
+ }
103
+
104
+ interface CapturedRequest {
105
+ url: string;
106
+ method: string;
107
+ headers: Record<string, string>;
108
+ body: unknown;
109
+ keepalive: boolean;
110
+ }
111
+
112
+ /**
113
+ * 가짜 수집 엔드포인트.
114
+ * 문서화된 계약만 흉내낸다: `Idempotency-Key` 로 중복 제출을 합치고,
115
+ * 성공 응답은 `{ ok: true, annotationId, sourceId, projectId }`.
116
+ */
117
+ function createFakeIngest(steps: StubStep[] = []) {
118
+ const requests: CapturedRequest[] = [];
119
+ const stored = new Map<string, { unverified: boolean }>();
120
+ const plan = [...steps];
121
+
122
+ const fetchImpl = async (
123
+ url: string,
124
+ init: {
125
+ method?: string;
126
+ headers?: Record<string, string>;
127
+ body?: string;
128
+ keepalive?: boolean;
129
+ }
130
+ ): Promise<{ status: number; headers: Map<string, string>; json(): Promise<unknown> }> => {
131
+ const headers = init.headers ?? {};
132
+ const key = headers["Idempotency-Key"] ?? "";
133
+ requests.push({
134
+ url,
135
+ method: init.method ?? "GET",
136
+ headers,
137
+ body: init.body === undefined ? null : JSON.parse(init.body),
138
+ keepalive: init.keepalive === true,
139
+ });
140
+
141
+ const step = plan.shift();
142
+ if (step?.networkError) throw new TypeError("Failed to fetch");
143
+
144
+ const status = step?.status ?? 200;
145
+ if (status >= 200 && status < 300) {
146
+ // 멱등: 같은 키는 한 번만 적재한다.
147
+ if (!stored.has(key)) {
148
+ stored.set(key, { unverified: headers["Authorization"] === undefined });
149
+ }
150
+ }
151
+ const body =
152
+ step?.body ??
153
+ (status >= 200 && status < 300
154
+ ? { ok: true, annotationId: `ann-${key}`, sourceId: "src-1", projectId: "proj-1" }
155
+ : { ok: false });
156
+
157
+ return {
158
+ status,
159
+ headers: new Map(Object.entries(step?.headers ?? {})),
160
+ async json() {
161
+ return body;
162
+ },
163
+ };
164
+ };
165
+
166
+ return { requests, stored, fetchImpl };
167
+ }
168
+
169
+ /**
170
+ * 문서화된 HTTP 계약을 그대로 구현한 테스트용 어댑터.
171
+ * 실패를 예외가 아니라 결과값으로 돌려주는 규약(DP-255)을 여기서도 지킨다.
172
+ * 프로덕션 `lasso` 어댑터는 DP-255 에서 만든다 — 이건 코어 검증용 최소 구현이다.
173
+ */
174
+ function createTestAdapter(opts: {
175
+ fetchImpl: ReturnType<typeof createFakeIngest>["fetchImpl"];
176
+ token?: string | null;
177
+ warn?: (...a: unknown[]) => void;
178
+ }): FeedbackAdapter {
179
+ const warn = opts.warn ?? (() => {});
180
+ return {
181
+ async submit(report): Promise<SubmitResult> {
182
+ const body = JSON.stringify(report);
183
+ const headers: Record<string, string> = {
184
+ "Content-Type": "application/json",
185
+ "Idempotency-Key": report.clientSubmissionId,
186
+ };
187
+ if (opts.token) {
188
+ headers["Authorization"] = `Bearer ${opts.token}`;
189
+ } else {
190
+ // 토큰이 없어도 제보를 버리지 않는다. 사용자에게는 알리지 않고 개발자 콘솔에만.
191
+ warn("feedback-kit: 수집 토큰이 없다 — 서버가 미검증(unverified) 제보로 적재한다");
192
+ }
193
+
194
+ try {
195
+ const res = await opts.fetchImpl("https://ingest.example/api/feedback", {
196
+ method: "POST",
197
+ headers,
198
+ body,
199
+ keepalive: canUseKeepalive(byteLengthOf(body)),
200
+ });
201
+
202
+ const retryAfter = res.headers.get("Retry-After");
203
+ const retryAfterMs = retryAfter ? Number(retryAfter) * 1000 : null;
204
+
205
+ if (res.status === 429) {
206
+ return { ok: false, id: null, retryable: true, retryAfterMs, rateLimited: true };
207
+ }
208
+ if (res.status >= 200 && res.status < 300) {
209
+ const json = (await res.json()) as { ok?: boolean; annotationId?: string };
210
+ // 2xx 만으로는 성공이 아니다. 본문의 ok 까지 봐야 한다.
211
+ if (json.ok === true) {
212
+ return { ok: true, id: json.annotationId ?? null, retryable: false, retryAfterMs: null };
213
+ }
214
+ return { ok: false, id: null, retryable: true, retryAfterMs };
215
+ }
216
+ if (res.status === 401 || res.status === 403) {
217
+ // 토큰 문제로 제보를 버리지 않는다. 큐에 남겨 둔다.
218
+ warn("feedback-kit: 수집 토큰이 거절됐다(제보는 큐에 보관)");
219
+ return { ok: false, id: null, retryable: true, retryAfterMs };
220
+ }
221
+ if (res.status >= 400 && res.status < 500) {
222
+ return { ok: false, id: null, retryable: false, retryAfterMs: null };
223
+ }
224
+ return { ok: false, id: null, retryable: true, retryAfterMs };
225
+ } catch {
226
+ // 네트워크 실패 = 일시적. 다음 트리거에서 다시 보낸다.
227
+ return { ok: false, id: null, retryable: true, retryAfterMs: null };
228
+ }
229
+ },
230
+ };
231
+ }
232
+
233
+ /** 호출 횟수만 세는 단순 어댑터. */
234
+ function stubAdapter(result: SubmitResult | (() => SubmitResult)) {
235
+ const calls: FeedbackReport[] = [];
236
+ const adapter: FeedbackAdapter = {
237
+ async submit(report) {
238
+ calls.push(report);
239
+ return typeof result === "function" ? result() : result;
240
+ },
241
+ };
242
+ return { adapter, calls };
243
+ }
244
+
245
+ const RETRYABLE_FAIL: SubmitResult = {
246
+ ok: false,
247
+ id: null,
248
+ retryable: true,
249
+ retryAfterMs: null,
250
+ };
251
+
252
+ beforeEach(() => {
253
+ clockNow = START_TIME;
254
+ seq = 0;
255
+ });
256
+
257
+ // ────────────────────────────────────────────────────────────────────────────
258
+ // TC1 / TC14 — 큐 적재와 제거 조건
259
+ // ────────────────────────────────────────────────────────────────────────────
260
+
261
+ describe("큐 적재와 제거 조건", () => {
262
+ it("TC1: 2xx 를 받기 전에는 제보가 큐에 남아 있다", async () => {
263
+ const storage = memoryStorage();
264
+ const { adapter, calls } = stubAdapter(RETRYABLE_FAIL);
265
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
266
+
267
+ const outcome = await queue.submit(makeReport());
268
+
269
+ expect(calls).toHaveLength(1); // 전송을 시도는 했다
270
+ expect(outcome.delivered).toBe(false);
271
+ expect(outcome.queued).toBe(true);
272
+ expect(await queue.size()).toBe(1); // 그래도 큐에 남아 있다
273
+ });
274
+
275
+ it("TC14: 정상 전송되면 큐가 비고 서버에는 제보가 1건만 남는다", async () => {
276
+ const storage = memoryStorage();
277
+ const ingest = createFakeIngest();
278
+ const queue = new FeedbackQueue({
279
+ storage,
280
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "tok" }),
281
+ now: clock,
282
+ flushIntervalMs: 0,
283
+ });
284
+
285
+ const outcome = await queue.submit(makeReport());
286
+
287
+ expect(outcome.delivered).toBe(true);
288
+ expect(outcome.id).toBe("ann-sub-1");
289
+ expect(await queue.size()).toBe(0);
290
+ expect(ingest.stored.size).toBe(1);
291
+ });
292
+
293
+ it("TC2: 재시도는 같은 clientSubmissionId 로 나가고 서버 제보는 1건만 생긴다", async () => {
294
+ const storage = memoryStorage();
295
+ const ingest = createFakeIngest([
296
+ { status: 500 }, // 첫 시도 실패
297
+ { status: 200 }, // 재시도 성공
298
+ ]);
299
+ const queue = new FeedbackQueue({
300
+ storage,
301
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "tok" }),
302
+ now: clock,
303
+ flushIntervalMs: 0,
304
+ });
305
+
306
+ const report = makeReport();
307
+ const first = await queue.submit(report);
308
+ expect(first.delivered).toBe(false);
309
+ expect(await queue.size()).toBe(1);
310
+
311
+ advance(10 * 1000); // 백오프(5초) 경과
312
+ await queue.flush();
313
+
314
+ expect(await queue.size()).toBe(0);
315
+ expect(ingest.requests).toHaveLength(2);
316
+ expect(ingest.requests[0]?.headers["Idempotency-Key"]).toBe(report.clientSubmissionId);
317
+ expect(ingest.requests[1]?.headers["Idempotency-Key"]).toBe(report.clientSubmissionId);
318
+ expect(ingest.stored.size).toBe(1); // 두 번 보냈지만 서버에는 1건
319
+ });
320
+
321
+ it("TC10: 2xx 라도 본문이 ok=false 면 실패로 보고 큐에 남긴다", async () => {
322
+ const storage = memoryStorage();
323
+ const ingest = createFakeIngest([{ status: 200, body: { ok: false, error: "invalid" } }]);
324
+ const queue = new FeedbackQueue({
325
+ storage,
326
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "tok" }),
327
+ now: clock,
328
+ flushIntervalMs: 0,
329
+ });
330
+
331
+ const outcome = await queue.submit(makeReport());
332
+
333
+ expect(outcome.delivered).toBe(false);
334
+ expect(await queue.size()).toBe(1);
335
+ });
336
+
337
+ it("어댑터가 예외를 던져도 제보를 잃지 않는다", async () => {
338
+ const storage = memoryStorage();
339
+ const warn = vi.fn();
340
+ const adapter: FeedbackAdapter = {
341
+ async submit() {
342
+ throw new Error("boom");
343
+ },
344
+ };
345
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0, warn });
346
+
347
+ await queue.submit(makeReport());
348
+
349
+ expect(await queue.size()).toBe(1);
350
+ expect(warn).toHaveBeenCalled();
351
+ });
352
+
353
+ it("여러 건이 밀려 있으면 한 건씩 순서대로 보낸다", async () => {
354
+ const storage = memoryStorage();
355
+ let inFlight = 0;
356
+ let maxInFlight = 0;
357
+ const order: string[] = [];
358
+ const adapter: FeedbackAdapter = {
359
+ async submit(report) {
360
+ inFlight += 1;
361
+ maxInFlight = Math.max(maxInFlight, inFlight);
362
+ await Promise.resolve();
363
+ order.push(report.clientSubmissionId);
364
+ inFlight -= 1;
365
+ return { ok: true, id: report.clientSubmissionId, retryable: false, retryAfterMs: null };
366
+ },
367
+ };
368
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
369
+
370
+ await queue.enqueue(makeReport());
371
+ await queue.enqueue(makeReport());
372
+ await queue.enqueue(makeReport());
373
+ await queue.flush();
374
+
375
+ expect(maxInFlight).toBe(1);
376
+ expect(order).toEqual(["sub-1", "sub-2", "sub-3"]);
377
+ expect(await queue.size()).toBe(0);
378
+ });
379
+ });
380
+
381
+ // ────────────────────────────────────────────────────────────────────────────
382
+ // TC3 / TC4 / TC5 — 보관 한도
383
+ // ────────────────────────────────────────────────────────────────────────────
384
+
385
+ describe("보관 한도", () => {
386
+ it(`TC3: ${MAX_QUEUE_ITEMS}건을 넘기면 오래된 것부터 버리고 콘솔에 경고한다`, async () => {
387
+ const storage = memoryStorage();
388
+ const warn = vi.fn();
389
+ const { adapter } = stubAdapter(RETRYABLE_FAIL);
390
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0, warn });
391
+
392
+ for (let i = 0; i < MAX_QUEUE_ITEMS + 3; i += 1) {
393
+ await queue.enqueue(makeReport());
394
+ }
395
+
396
+ expect(await queue.size()).toBe(MAX_QUEUE_ITEMS);
397
+
398
+ const persisted = storage.dump() as Array<{ report: { clientSubmissionId: string } }>;
399
+ // 가장 오래된 3건(sub-1~3)이 밀려나고 sub-4 가 맨 앞이다.
400
+ expect(persisted[0]?.report.clientSubmissionId).toBe("sub-4");
401
+ expect(
402
+ warn.mock.calls.filter((c) => String(c[0]).includes("가장 오래된 제보를 버렸다"))
403
+ ).toHaveLength(3);
404
+ });
405
+
406
+ it("TC4: 7일이 지난 제보는 마운트 시 정리되고 전송을 시도하지 않는다", async () => {
407
+ const storage = memoryStorage();
408
+ const warn = vi.fn();
409
+ const { adapter, calls } = stubAdapter(RETRYABLE_FAIL);
410
+
411
+ // 8일 전에 들어온 제보를 저장소에 직접 심어 둔다.
412
+ await storage.set(
413
+ QUEUE_KEY,
414
+ JSON.stringify([
415
+ {
416
+ report: makeReport(),
417
+ attempts: 0,
418
+ nextAttemptAt: null,
419
+ enqueuedAt: clockNow - 8 * DAY_MS,
420
+ },
421
+ ])
422
+ );
423
+
424
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0, warn });
425
+ queue.start(); // 마운트
426
+ await queue.flush();
427
+ queue.stop();
428
+
429
+ expect(calls).toHaveLength(0); // 전송을 시도조차 하지 않는다
430
+ expect(await queue.size()).toBe(0);
431
+ expect(warn.mock.calls.some((c) => String(c[0]).includes("보관 기간(7일)"))).toBe(true);
432
+ });
433
+
434
+ it("7일이 안 지난 제보는 마운트 시 그대로 전송된다", async () => {
435
+ const storage = memoryStorage();
436
+ const { adapter, calls } = stubAdapter(RETRYABLE_FAIL);
437
+
438
+ await storage.set(
439
+ QUEUE_KEY,
440
+ JSON.stringify([
441
+ {
442
+ report: makeReport(),
443
+ attempts: 0,
444
+ nextAttemptAt: null,
445
+ enqueuedAt: clockNow - 6 * DAY_MS,
446
+ },
447
+ ])
448
+ );
449
+
450
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
451
+ queue.start();
452
+ await queue.flush();
453
+ queue.stop();
454
+
455
+ expect(calls).toHaveLength(1);
456
+ expect(await queue.size()).toBe(1);
457
+ });
458
+
459
+ it("TC5: 저장소 한도를 넘으면 스크린샷만 떼어내고 제보 본문은 보관한다", async () => {
460
+ const bigScreenshot = "A".repeat(4000);
461
+ const withShot = makeReport({
462
+ comment: "스크린샷이 붙은 제보",
463
+ screenshot: { base64: bigScreenshot, contentType: "image/png" },
464
+ });
465
+
466
+ // 스크린샷을 포함하면 넘치고, 떼어내면 들어가는 한도.
467
+ const limit = 2000;
468
+ const storage = memoryStorage(limit);
469
+ const warn = vi.fn();
470
+ const { adapter } = stubAdapter(RETRYABLE_FAIL);
471
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0, warn });
472
+
473
+ await queue.enqueue(withShot);
474
+
475
+ const persisted = storage.dump() as Array<{
476
+ report: { comment: string; screenshot: unknown; clientSubmissionId: string };
477
+ }>;
478
+ expect(persisted).toHaveLength(1); // 제보 자체는 살아 있다
479
+ expect(persisted[0]?.report.comment).toBe("스크린샷이 붙은 제보"); // 사용자가 쓴 글은 보존
480
+ expect(persisted[0]?.report.screenshot).toBeNull(); // 스크린샷만 떨어져 나갔다
481
+ expect(warn.mock.calls.some((c) => String(c[0]).includes("스크린샷을 떼어내고"))).toBe(true);
482
+
483
+ // 호출자가 넘긴 원본 객체는 건드리지 않는다.
484
+ expect(withShot.screenshot).not.toBeNull();
485
+ });
486
+ });
487
+
488
+ // ────────────────────────────────────────────────────────────────────────────
489
+ // TC8 / TC9 — 백오프와 429
490
+ // ────────────────────────────────────────────────────────────────────────────
491
+
492
+ describe("재시도 간격", () => {
493
+ it("TC9: 연속 실패해도 재시도 간격이 5분을 넘지 않는다", () => {
494
+ for (let attempts = 1; attempts <= 10; attempts += 1) {
495
+ expect(defaultBackoff(attempts, null)).toBeLessThanOrEqual(BACKOFF_MAX_MS);
496
+ }
497
+ expect(defaultBackoff(1, null)).toBe(5 * 1000); // 5초에서 시작
498
+ expect(defaultBackoff(2, null)).toBe(10 * 1000); // 2배씩
499
+ expect(defaultBackoff(10, null)).toBe(BACKOFF_MAX_MS); // 상한에 붙는다
500
+ });
501
+
502
+ it("TC9(통합): 10회 연속 실패하는 동안 매 간격이 5분 이하다", async () => {
503
+ const storage = memoryStorage();
504
+ const { adapter, calls } = stubAdapter(RETRYABLE_FAIL);
505
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
506
+
507
+ await queue.enqueue(makeReport());
508
+ await queue.flush(); // 최초 전송(실패) — 여기서 첫 백오프가 잡힌다
509
+
510
+ for (let i = 0; i < 10; i += 1) {
511
+ const persisted = storage.dump() as Array<{ nextAttemptAt: number | null }>;
512
+ const nextAt = persisted[0]?.nextAttemptAt ?? clockNow;
513
+ const gap = nextAt - clockNow;
514
+ expect(gap).toBeLessThanOrEqual(BACKOFF_MAX_MS);
515
+ advance(Math.max(gap, 0));
516
+ await queue.flush();
517
+ }
518
+
519
+ expect(calls.length).toBe(11); // 최초 1회 + 재시도 10회
520
+ expect(await queue.size()).toBe(1); // 그래도 제보는 살아 있다
521
+ });
522
+
523
+ it("서버가 준 Retry-After 는 존중하되 5분으로 자른다", () => {
524
+ expect(defaultBackoff(1, 90 * 1000)).toBe(90 * 1000);
525
+ expect(defaultBackoff(1, 60 * 60 * 1000)).toBe(BACKOFF_MAX_MS);
526
+ });
527
+
528
+ it("TC8: 429 면 큐에 남기고 최소 10분 뒤에 다시 시도한다", async () => {
529
+ const storage = memoryStorage();
530
+ const ingest = createFakeIngest([
531
+ { status: 429, headers: { "Retry-After": "60" } },
532
+ { status: 200 },
533
+ ]);
534
+ const queue = new FeedbackQueue({
535
+ storage,
536
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "tok" }),
537
+ now: clock,
538
+ flushIntervalMs: 0,
539
+ });
540
+
541
+ await queue.submit(makeReport());
542
+ expect(await queue.size()).toBe(1);
543
+
544
+ const afterFail = storage.dump() as Array<{ nextAttemptAt: number; rateLimited: boolean }>;
545
+ expect(afterFail[0]?.rateLimited).toBe(true);
546
+ // 서버가 60초를 권했어도 429 는 최소 10분이다.
547
+ expect((afterFail[0]?.nextAttemptAt ?? 0) - clockNow).toBe(RATE_LIMIT_MIN_DELAY_MS);
548
+
549
+ // 5분 뒤 수동 [다시 보내기] — 429 대기는 앞당기지 않는다.
550
+ advance(5 * 60 * 1000);
551
+ await queue.retryNow();
552
+ expect(ingest.requests).toHaveLength(1);
553
+ expect(await queue.size()).toBe(1);
554
+
555
+ // 10분이 지나면 다시 보낸다.
556
+ advance(6 * 60 * 1000);
557
+ await queue.flush();
558
+ expect(ingest.requests).toHaveLength(2);
559
+ expect(await queue.size()).toBe(0);
560
+ });
561
+
562
+ it("일반 실패는 수동 [다시 보내기] 로 즉시 재시도된다", async () => {
563
+ const storage = memoryStorage();
564
+ const { adapter, calls } = stubAdapter(RETRYABLE_FAIL);
565
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
566
+
567
+ await queue.submit(makeReport());
568
+ expect(calls).toHaveLength(1);
569
+
570
+ await queue.retryNow(); // 백오프를 기다리지 않는다
571
+ expect(calls).toHaveLength(2);
572
+ });
573
+ });
574
+
575
+ // ────────────────────────────────────────────────────────────────────────────
576
+ // TC7 — 토큰 없음/무효
577
+ // ────────────────────────────────────────────────────────────────────────────
578
+
579
+ describe("토큰 없음/무효", () => {
580
+ it("TC7: 토큰이 없어도 전송하고, 사용자에게는 실패를 알리지 않으며 콘솔 경고만 남는다", async () => {
581
+ const storage = memoryStorage();
582
+ const ingest = createFakeIngest();
583
+ const warn = vi.fn();
584
+ const queue = new FeedbackQueue({
585
+ storage,
586
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: null, warn }),
587
+ now: clock,
588
+ flushIntervalMs: 0,
589
+ warn,
590
+ });
591
+
592
+ const outcome = await queue.submit(makeReport());
593
+
594
+ expect(ingest.requests[0]?.headers["Authorization"]).toBeUndefined();
595
+ expect(ingest.stored.size).toBe(1);
596
+ expect([...ingest.stored.values()][0]?.unverified).toBe(true); // 서버가 미검증으로 적재
597
+ expect(outcome.delivered).toBe(true); // 사용자에게는 성공으로 보인다
598
+ expect(warn.mock.calls.some((c) => String(c[0]).includes("수집 토큰이 없다"))).toBe(true);
599
+ expect(queue.getStatus().lastError).toBeNull();
600
+ });
601
+
602
+ it("토큰이 거절돼도(401) 제보를 버리지 않는다", async () => {
603
+ const storage = memoryStorage();
604
+ const ingest = createFakeIngest([{ status: 401 }]);
605
+ const warn = vi.fn();
606
+ const queue = new FeedbackQueue({
607
+ storage,
608
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "bad", warn }),
609
+ now: clock,
610
+ flushIntervalMs: 0,
611
+ });
612
+
613
+ await queue.submit(makeReport());
614
+
615
+ expect(await queue.size()).toBe(1);
616
+ expect(warn).toHaveBeenCalled();
617
+ });
618
+ });
619
+
620
+ // ────────────────────────────────────────────────────────────────────────────
621
+ // 이탈 대비(keepalive) — TC6 의 클라이언트 측 근거
622
+ // ────────────────────────────────────────────────────────────────────────────
623
+
624
+ describe("이탈 대비", () => {
625
+ it("작은 제보는 keepalive 로 나간다", async () => {
626
+ const storage = memoryStorage();
627
+ const ingest = createFakeIngest();
628
+ const queue = new FeedbackQueue({
629
+ storage,
630
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "tok" }),
631
+ now: clock,
632
+ flushIntervalMs: 0,
633
+ });
634
+
635
+ await queue.submit(makeReport());
636
+
637
+ expect(ingest.requests[0]?.keepalive).toBe(true);
638
+ });
639
+
640
+ it("64 KiB 를 넘는 제보(스크린샷 동반)는 keepalive 없이 보내고 큐에 남겨 재전송한다", async () => {
641
+ const storage = memoryStorage();
642
+ const ingest = createFakeIngest([{ networkError: true, status: 0 }, { status: 200 }]);
643
+ const queue = new FeedbackQueue({
644
+ storage,
645
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "tok" }),
646
+ now: clock,
647
+ flushIntervalMs: 0,
648
+ });
649
+
650
+ const big = makeReport({
651
+ screenshot: { base64: "B".repeat(100 * 1024), contentType: "image/png" },
652
+ });
653
+ await queue.submit(big); // 이탈로 끊긴 상황을 네트워크 오류로 흉내낸다
654
+
655
+ expect(ingest.requests[0]?.keepalive).toBe(false); // 한도를 넘으므로 켜지 않는다
656
+ expect(await queue.size()).toBe(1); // 유실이 아니라 지연
657
+
658
+ advance(10 * 1000);
659
+ await queue.flush(); // 다음 마운트/복귀에서 자동 재전송
660
+ expect(await queue.size()).toBe(0);
661
+ expect(ingest.stored.size).toBe(1);
662
+ });
663
+ });
664
+
665
+ // ────────────────────────────────────────────────────────────────────────────
666
+ // 상태 표시
667
+ // ────────────────────────────────────────────────────────────────────────────
668
+
669
+ describe("전송 상태", () => {
670
+ it("전송 중 / 대기 / 없음 상태를 구독으로 알린다", async () => {
671
+ const storage = memoryStorage();
672
+ const { adapter } = stubAdapter(RETRYABLE_FAIL);
673
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
674
+
675
+ const seen: string[] = [];
676
+ const unsubscribe = queue.subscribe((s) => seen.push(s.state));
677
+
678
+ await queue.submit(makeReport());
679
+
680
+ expect(seen[0]).toBe("idle"); // 구독 즉시 현재 상태 1회
681
+ expect(seen).toContain("sending");
682
+ expect(queue.getStatus()).toEqual({ state: "pending", pending: 1, lastError: "retryable" });
683
+
684
+ unsubscribe();
685
+ const before = seen.length;
686
+ await queue.flush();
687
+ expect(seen.length).toBe(before); // 해지 뒤에는 통지가 오지 않는다
688
+ });
689
+
690
+ it("성공하면 대기 건수가 0 이 되고 마지막 오류가 지워진다", async () => {
691
+ const storage = memoryStorage();
692
+ let fail = true;
693
+ const adapter: FeedbackAdapter = {
694
+ async submit(report) {
695
+ if (fail) {
696
+ fail = false;
697
+ return RETRYABLE_FAIL;
698
+ }
699
+ return { ok: true, id: report.clientSubmissionId, retryable: false, retryAfterMs: null };
700
+ },
701
+ };
702
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
703
+
704
+ await queue.submit(makeReport());
705
+ expect(queue.getStatus().lastError).toBe("retryable");
706
+
707
+ await queue.retryNow();
708
+ expect(queue.getStatus()).toEqual({ state: "idle", pending: 0, lastError: null });
709
+ });
710
+ });
711
+
712
+ // ────────────────────────────────────────────────────────────────────────────
713
+ // 저장소 견고성
714
+ // ────────────────────────────────────────────────────────────────────────────
715
+
716
+ describe("저장소 견고성", () => {
717
+ it("큐가 손상돼 있으면 초기화하고 새 제보를 계속 받는다", async () => {
718
+ const storage = memoryStorage();
719
+ await storage.set(QUEUE_KEY, "{이건 JSON 이 아니다");
720
+ const warn = vi.fn();
721
+ const { adapter } = stubAdapter(RETRYABLE_FAIL);
722
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0, warn });
723
+
724
+ await queue.enqueue(makeReport());
725
+
726
+ expect(await queue.size()).toBe(1);
727
+ expect(warn.mock.calls.some((c) => String(c[0]).includes("손상"))).toBe(true);
728
+ });
729
+
730
+ it("enqueuedAt 이 없던 옛 항목도 즉시 버리지 않는다", async () => {
731
+ const storage = memoryStorage();
732
+ const { adapter, calls } = stubAdapter(RETRYABLE_FAIL);
733
+ await storage.set(
734
+ QUEUE_KEY,
735
+ JSON.stringify([{ report: makeReport(), attempts: 0, nextAttemptAt: null }])
736
+ );
737
+
738
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
739
+ await queue.flush();
740
+
741
+ expect(calls).toHaveLength(1);
742
+ expect(await queue.size()).toBe(1);
743
+ });
744
+
745
+ it("동시에 들어온 제보가 서로를 덮어쓰지 않는다", async () => {
746
+ const storage = memoryStorage();
747
+ const { adapter } = stubAdapter(RETRYABLE_FAIL);
748
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, flushIntervalMs: 0 });
749
+
750
+ await Promise.all([
751
+ queue.enqueue(makeReport()),
752
+ queue.enqueue(makeReport()),
753
+ queue.enqueue(makeReport()),
754
+ ]);
755
+
756
+ expect(await queue.size()).toBe(3);
757
+ });
758
+ });
759
+
760
+ // ────────────────────────────────────────────────────────────────────────────
761
+ // 저장소 교체 — w1 "저장소를 교체해도 큐 동작이 같다"
762
+ // ────────────────────────────────────────────────────────────────────────────
763
+
764
+ /**
765
+ * AsyncStorage 처럼 **비동기이고 내부 구조가 다른** 저장소.
766
+ * memoryStorage 가 Map 에 즉시 쓰는 것과 달리, 여기서는 키/값 쌍 배열에 담고
767
+ * 매 연산이 한 틱 뒤에 끝난다. 큐가 저장소 구현을 알고 있으면 여기서 갈라진다.
768
+ */
769
+ function deferredPairStorage(): FeedbackStorage {
770
+ let pairs: Array<[string, string]> = [];
771
+ const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
772
+ return {
773
+ async get(key) {
774
+ await tick();
775
+ return pairs.find(([k]) => k === key)?.[1] ?? null;
776
+ },
777
+ async set(key, value) {
778
+ await tick();
779
+ pairs = pairs.filter(([k]) => k !== key);
780
+ pairs.push([key, value]);
781
+ },
782
+ async remove(key) {
783
+ await tick();
784
+ pairs = pairs.filter(([k]) => k !== key);
785
+ },
786
+ };
787
+ }
788
+
789
+ describe("저장소 교체", () => {
790
+ // 같은 시나리오(실패 → 큐 잔류 → 성공 → 큐 비움)를 두 구현에 그대로 돌리고
791
+ // 관측값을 비교한다. 기대값을 손으로 적지 않고 **두 실행의 결과를 서로 대조**하는
792
+ // 이유는, 한쪽만 보면 "둘 다 같이 틀린" 경우를 못 잡기 때문이다.
793
+ async function runScenario(storage: FeedbackStorage) {
794
+ const ingest = createFakeIngest([
795
+ { status: 503 }, // 1회차: 일시 실패 → 큐에 남는다
796
+ { status: 200 }, // 2회차: 성공 → 큐에서 빠진다
797
+ ]);
798
+ const queue = new FeedbackQueue({
799
+ storage,
800
+ adapter: createTestAdapter({ fetchImpl: ingest.fetchImpl, token: "tok" }),
801
+ now: clock,
802
+ flushIntervalMs: 0,
803
+ warn: () => {},
804
+ });
805
+
806
+ const report = makeReport();
807
+ const first = await queue.submit(report);
808
+ const sizeAfterFail = await queue.size();
809
+
810
+ advance(BACKOFF_MAX_MS);
811
+ await queue.retryNow();
812
+ const sizeAfterSuccess = await queue.size();
813
+
814
+ return {
815
+ firstDelivered: first.delivered,
816
+ firstQueued: first.queued,
817
+ sizeAfterFail,
818
+ sizeAfterSuccess,
819
+ storedCount: ingest.stored.size,
820
+ // 키의 **값**은 실행마다 달라지므로(제보마다 새로 발급) 값이 아니라 불변식을 본다:
821
+ // "몇 번을 재시도해도 하나의 키로만 나갔다".
822
+ requestCount: ingest.requests.length,
823
+ distinctSubmissionIds: new Set(
824
+ ingest.requests.map((r) => (r.body as { clientSubmissionId?: string })?.clientSubmissionId)
825
+ ).size,
826
+ };
827
+ }
828
+
829
+ it("Map 기반이든 비동기 배열 기반이든 큐 동작이 같다", async () => {
830
+ const viaMemory = await runScenario(memoryStorage());
831
+ const viaDeferred = await runScenario(deferredPairStorage());
832
+
833
+ expect(viaDeferred).toEqual(viaMemory);
834
+ });
835
+
836
+ it("교체한 저장소에서도 실패는 큐에 남기고 성공하면 비운다", async () => {
837
+ const result = await runScenario(deferredPairStorage());
838
+
839
+ expect(result.firstDelivered).toBe(false);
840
+ expect(result.firstQueued).toBe(true);
841
+ expect(result.sizeAfterFail).toBe(1);
842
+ expect(result.sizeAfterSuccess).toBe(0);
843
+ expect(result.storedCount).toBe(1);
844
+ // 두 번 나갔지만 멱등 키는 하나다.
845
+ expect(result.requestCount).toBe(2);
846
+ expect(result.distinctSubmissionIds).toBe(1);
847
+ });
848
+ });