@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,427 @@
1
+ // DP-253 컨텍스트 수집 검증(통합).
2
+ //
3
+ // 여기 통합 테스트는 "제보를 만들어 → 큐에 넣고 → 어댑터가 전송"까지 실제 경로를 태운
4
+ // 다음, **전송된 페이로드**(JSON 왕복본)를 본다. 내부 객체를 들여다보지 않는 이유는,
5
+ // 받는 쪽에 실제로 도착하는 값이 검증 대상이기 때문이다(직렬화에서 사라지는 필드가 있으면
6
+ // 그것도 여기서 잡힌다).
7
+ //
8
+ // 담당 TC:
9
+ // - TC1 요청이 오간 세션의 제보에는 network 가 담긴다
10
+ // - TC2 경고·에러가 난 세션의 제보에는 logs 가 담긴다
11
+ // - TC5 빈 배열을 상수로 보내지 않는다(수집 미배선 = diagnostics 자체가 null)
12
+ // - TC6 앱 제보에 기기·앱·사용자 컨텍스트가 모두 담긴다
13
+ // - TC7 게스트 사용자도 user 가 식별 가능하게 담긴다
14
+ // - TC10 매핑이 없어도 나머지 컨텍스트는 수집된다(screenPath 만 null)
15
+
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+
18
+ import { DiagnosticsCollector } from "./diagnostics.js";
19
+ import { FeedbackQueue } from "./queue.js";
20
+ import { buildReport } from "./report.js";
21
+ import type { BuildReportOpts, ReportParts } from "./report.js";
22
+ import type {
23
+ FeedbackAdapter,
24
+ FeedbackReport,
25
+ FeedbackStorage,
26
+ SubmitResult,
27
+ FeedbackUser,} from "./types.js";
28
+
29
+ // ────────────────────────────────────────────────────────────────────────────
30
+ // 테스트 도구
31
+ // ────────────────────────────────────────────────────────────────────────────
32
+
33
+ const START_TIME = 1_700_000_000_000;
34
+ let clockNow = START_TIME;
35
+ const clock = (): number => clockNow;
36
+ const iso = (n: number): string => new Date(n).toISOString();
37
+
38
+ type G = Record<string, unknown>;
39
+ const g = globalThis as unknown as G;
40
+
41
+ function memoryStorage(): FeedbackStorage {
42
+ const map = new Map<string, string>();
43
+ return {
44
+ async get(key) {
45
+ return map.get(key) ?? null;
46
+ },
47
+ async set(key, value) {
48
+ map.set(key, value);
49
+ },
50
+ async remove(key) {
51
+ map.delete(key);
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 전송된 페이로드를 그대로 잡아두는 어댑터.
58
+ * 실제 어댑터와 같이 JSON 직렬화를 거치므로, 여기 잡힌 값이 곧 서버가 받는 값이다.
59
+ */
60
+ function captureAdapter(): { sent: FeedbackReport[]; adapter: FeedbackAdapter } {
61
+ const sent: FeedbackReport[] = [];
62
+ return {
63
+ sent,
64
+ adapter: {
65
+ async submit(report): Promise<SubmitResult> {
66
+ sent.push(JSON.parse(JSON.stringify(report)) as FeedbackReport);
67
+ return { ok: true, id: "ann-1", retryable: false, retryAfterMs: null };
68
+ },
69
+ },
70
+ };
71
+ }
72
+
73
+ const PARTS: ReportParts = {
74
+ kind: "report",
75
+ comment: "여기서 버튼이 안 눌린다",
76
+ priority: "unset",
77
+ screenshot: null,
78
+ pin: null,
79
+ element: null,
80
+ };
81
+
82
+ /** 제보 하나를 만들어 큐로 전송하고, 서버가 받은 페이로드를 돌려준다. */
83
+ async function sendOnce(
84
+ opts: Omit<BuildReportOpts, "app" | "sessionId" | "storage" | "now" | "iso"> & {
85
+ storage?: FeedbackStorage;
86
+ }
87
+ ): Promise<FeedbackReport> {
88
+ const storage = opts.storage ?? memoryStorage();
89
+ const { sent, adapter } = captureAdapter();
90
+ const queue = new FeedbackQueue({ storage, adapter, now: clock, warn: () => {} });
91
+
92
+ const report = await buildReport(PARTS, {
93
+ ...opts,
94
+ app: "test-app",
95
+ sessionId: "session-1",
96
+ storage,
97
+ now: clock,
98
+ iso,
99
+ });
100
+ const outcome = await queue.submit(report);
101
+
102
+ expect(outcome.delivered).toBe(true);
103
+ expect(sent).toHaveLength(1);
104
+ return sent[0] as FeedbackReport;
105
+ }
106
+
107
+ // 진단 수집기가 글로벌을 패치하므로 매 테스트마다 원복한다.
108
+ let originals: { fetch: unknown; warn: unknown; error: unknown };
109
+ let collector: DiagnosticsCollector;
110
+
111
+ beforeEach(() => {
112
+ clockNow = START_TIME;
113
+ originals = {
114
+ fetch: g.fetch,
115
+ warn: (g.console as G).warn,
116
+ error: (g.console as G).error,
117
+ };
118
+ collector = new DiagnosticsCollector({ now: clock, iso });
119
+ });
120
+
121
+ afterEach(() => {
122
+ collector.uninstall();
123
+ g.fetch = originals.fetch;
124
+ (g.console as G).warn = originals.warn;
125
+ (g.console as G).error = originals.error;
126
+ });
127
+
128
+ // ────────────────────────────────────────────────────────────────────────────
129
+ // TC1 / TC2 — 세션 중 오간 요청과 로그가 제보에 실린다
130
+ // ────────────────────────────────────────────────────────────────────────────
131
+
132
+ describe("진단이 제보에 실린다 (TC1, TC2)", () => {
133
+ it("요청이 오간 세션의 제보에는 network 가 담긴다", async () => {
134
+ g.fetch = (): Promise<unknown> => {
135
+ clockNow += 15;
136
+ return Promise.resolve({ status: 500 });
137
+ };
138
+ collector.install();
139
+
140
+ await (g.fetch as (u: string, i?: unknown) => Promise<unknown>)(
141
+ "https://api.example/orders",
142
+ { method: "post" }
143
+ );
144
+
145
+ const received = await sendOnce({
146
+ platform: "web",
147
+ getDiagnostics: () => collector.snapshot(),
148
+ });
149
+
150
+ const network = received.context.diagnostics?.network ?? [];
151
+ expect(network.length).toBeGreaterThanOrEqual(1);
152
+ // 각 항목에 메서드·URL·상태코드·소요시간이 있다.
153
+ for (const entry of network) {
154
+ expect(typeof entry.method).toBe("string");
155
+ expect(typeof entry.url).toBe("string");
156
+ expect(typeof entry.durationMs).toBe("number");
157
+ expect(entry).toHaveProperty("status");
158
+ expect(typeof entry.at).toBe("string");
159
+ }
160
+ expect(network[0]).toMatchObject({
161
+ method: "POST",
162
+ url: "https://api.example/orders",
163
+ status: 500,
164
+ durationMs: 15,
165
+ });
166
+ });
167
+
168
+ it("경고·에러가 난 세션의 제보에는 logs 가 담긴다", async () => {
169
+ g.fetch = (): Promise<unknown> => Promise.resolve({ status: 200 });
170
+ (g.console as G).warn = (): void => {};
171
+ (g.console as G).error = (): void => {};
172
+ collector.install();
173
+
174
+ (g.console as { warn: (m: string) => void }).warn("응답이 느리다");
175
+ (g.console as { error: (m: string, e: unknown) => void }).error(
176
+ "결제 실패",
177
+ new Error("카드 거절")
178
+ );
179
+
180
+ const received = await sendOnce({
181
+ platform: "web",
182
+ getDiagnostics: () => collector.snapshot(),
183
+ });
184
+
185
+ const logs = received.context.diagnostics?.logs ?? [];
186
+ expect(logs.length).toBeGreaterThanOrEqual(2);
187
+ expect(logs.map((l) => l.level)).toEqual(["warn", "error"]);
188
+ expect(logs[0]?.message).toContain("응답이 느리다");
189
+ expect(logs[1]?.message).toContain("결제 실패");
190
+ expect(logs[1]?.message).toContain("카드 거절");
191
+ });
192
+ });
193
+
194
+ // ────────────────────────────────────────────────────────────────────────────
195
+ // TC5 — 빈 배열을 상수로 보내지 않는다
196
+ // ────────────────────────────────────────────────────────────────────────────
197
+
198
+ describe("빈 배열을 상수로 보내지 않는다 (TC5)", () => {
199
+ it("수집이 배선되지 않았으면 diagnostics 자체가 null 이다", async () => {
200
+ const received = await sendOnce({ platform: "web" });
201
+
202
+ expect(received.context.diagnostics).toBeNull();
203
+ // 하드코딩된 빈 배열이 아니라는 뜻: 배열 자체가 존재하지 않는다.
204
+ expect(JSON.stringify(received.context.diagnostics)).toBe("null");
205
+ });
206
+
207
+ it("수집이 배선됐는데 아무 일도 없었으면 빈 배열이고, 일이 있었으면 채워진다", async () => {
208
+ g.fetch = (): Promise<unknown> => {
209
+ clockNow += 2;
210
+ return Promise.resolve({ status: 200 });
211
+ };
212
+ collector.install();
213
+
214
+ const quiet = await sendOnce({
215
+ platform: "web",
216
+ getDiagnostics: () => collector.snapshot(),
217
+ });
218
+ expect(quiet.context.diagnostics).toEqual({ network: [], logs: [] });
219
+
220
+ await (g.fetch as (u: string) => Promise<unknown>)("https://api.example/ping");
221
+
222
+ const busy = await sendOnce({
223
+ platform: "web",
224
+ getDiagnostics: () => collector.snapshot(),
225
+ });
226
+ expect(busy.context.diagnostics?.network).toHaveLength(1);
227
+ });
228
+ });
229
+
230
+ // ────────────────────────────────────────────────────────────────────────────
231
+ // TC6 / TC10 — 앱 컨텍스트 전체 수집, 매핑 없어도 나머지는 온전
232
+ // ────────────────────────────────────────────────────────────────────────────
233
+
234
+ const nativeProviders = {
235
+ platform: "native" as const,
236
+ getCurrentScreen: (): string => "OrderDetail",
237
+ getNavPath: (): string[] => ["Root", "Orders", "OrderDetail"],
238
+ getRouteParams: (): unknown => ({ orderId: "A-1", onDone: (): void => {} }),
239
+ getAppInfo: () => ({
240
+ version: "1.4.2",
241
+ channel: "preview",
242
+ updateId: "upd-9",
243
+ runtimeVersion: "56.0.0",
244
+ }),
245
+ getDevice: () => ({
246
+ model: "iPhone 15",
247
+ osName: "iOS",
248
+ osVersion: "18.2",
249
+ deviceType: "phone",
250
+ }),
251
+ getDisplay: () => ({ width: 393, height: 852, pixelRatio: 3, fontScale: 1 }),
252
+ getTimezone: (): string => "Asia/Seoul",
253
+ };
254
+
255
+ describe("앱 컨텍스트 (TC6, TC10)", () => {
256
+ it("기기·앱·사용자 컨텍스트가 모두 담긴다", async () => {
257
+ const received = await sendOnce({
258
+ ...nativeProviders,
259
+ screenSourceMap: { OrderDetail: "src/screens/OrderDetail.tsx" },
260
+ getUser: () => ({ id: "u-77", email: "a@example.com", name: "정경훈" }),
261
+ });
262
+
263
+ const ctx = received.context;
264
+ expect(ctx.screen).toBe("OrderDetail");
265
+ expect(ctx.platform).toBe("native");
266
+ expect(ctx.timezone).toBe("Asia/Seoul");
267
+ expect(ctx.clientTimestamp).toBe(iso(START_TIME));
268
+ expect(ctx.user).toMatchObject({ id: "u-77", email: "a@example.com", isGuest: false });
269
+
270
+ const native = ctx.native;
271
+ expect(native).not.toBeNull();
272
+ expect(native?.navPath).toEqual(["Root", "Orders", "OrderDetail"]);
273
+ expect(native?.screenPath).toBe("src/screens/OrderDetail.tsx");
274
+ // 함수는 직렬화에서 떨어져 나가고 값만 남는다.
275
+ expect(native?.routeParams).toEqual({ orderId: "A-1" });
276
+ expect(native?.appInfo).toEqual({
277
+ version: "1.4.2",
278
+ channel: "preview",
279
+ updateId: "upd-9",
280
+ runtimeVersion: "56.0.0",
281
+ });
282
+ expect(native?.device).toEqual({
283
+ model: "iPhone 15",
284
+ osName: "iOS",
285
+ osVersion: "18.2",
286
+ deviceType: "phone",
287
+ });
288
+ expect(native?.display).toEqual({
289
+ width: 393,
290
+ height: 852,
291
+ pixelRatio: 3,
292
+ fontScale: 1,
293
+ });
294
+
295
+ // 어느 하나라도 비어 있으면 안 된다.
296
+ for (const group of [native?.appInfo, native?.device, native?.display]) {
297
+ for (const value of Object.values(group ?? {})) {
298
+ expect(value).not.toBeNull();
299
+ }
300
+ }
301
+ });
302
+
303
+ it("매핑이 없으면 screenPath 만 null 이고 나머지는 그대로 수집된다", async () => {
304
+ const received = await sendOnce({
305
+ ...nativeProviders,
306
+ // screenSourceMap 없음(빌드 플러그인 미사용)
307
+ getUser: () => ({ id: "u-77" }),
308
+ });
309
+
310
+ const native = received.context.native;
311
+ expect(native?.screenPath).toBeNull();
312
+ expect(received.context.source).toEqual({ screenId: null, sourceFile: null });
313
+
314
+ // 나머지는 TC6 과 동일하게 살아 있다.
315
+ expect(received.context.screen).toBe("OrderDetail");
316
+ expect(native?.navPath).toEqual(["Root", "Orders", "OrderDetail"]);
317
+ expect(native?.appInfo.version).toBe("1.4.2");
318
+ expect(native?.device.model).toBe("iPhone 15");
319
+ expect(native?.display.width).toBe(393);
320
+ expect(received.context.timezone).toBe("Asia/Seoul");
321
+ expect(received.context.user?.id).toBe("u-77");
322
+ });
323
+
324
+ it("매핑에 현재 화면이 없어도(누락) 다른 화면 매핑 때문에 틀린 경로를 넣지 않는다", async () => {
325
+ const received = await sendOnce({
326
+ ...nativeProviders,
327
+ screenSourceMap: { Home: "src/screens/Home.tsx" },
328
+ getUser: () => ({ id: "u-77" }),
329
+ });
330
+
331
+ expect(received.context.native?.screenPath).toBeNull();
332
+ expect(received.context.screen).toBe("OrderDetail");
333
+ });
334
+ });
335
+
336
+ // ────────────────────────────────────────────────────────────────────────────
337
+ // TC7 — 게스트도 식별 가능하게
338
+ // ────────────────────────────────────────────────────────────────────────────
339
+
340
+ describe("게스트 신원 (TC7)", () => {
341
+ it("getUser 가 없으면 게스트 식별자를 채우고 isGuest 를 true 로 보낸다", async () => {
342
+ const received = await sendOnce({ platform: "web" });
343
+
344
+ const user = received.context.user;
345
+ expect(user).not.toBeNull();
346
+ expect(user?.isGuest).toBe(true);
347
+ expect(typeof user?.id).toBe("string");
348
+ expect((user?.id ?? "").length).toBeGreaterThan(0);
349
+ expect(user?.id).toMatch(/^guest_/);
350
+ });
351
+
352
+ it("같은 저장소를 쓰면 다시 제보해도 같은 게스트 식별자다", async () => {
353
+ const storage = memoryStorage();
354
+ const first = await sendOnce({ platform: "web", storage });
355
+ const second = await sendOnce({ platform: "web", storage });
356
+
357
+ expect(second.context.user?.id).toBe(first.context.user?.id);
358
+ });
359
+
360
+ // getUser 를 "값"이 아니라 "함수"로 받는 이유가 여기 있다. 위젯을 마운트한 뒤
361
+ // 사용자가 로그인하거나 로그아웃하므로, 마운트 시점 값을 캐싱하면 제보에 옛 신원이
362
+ // 실린다(로그아웃한 사람 이름으로 제보가 올라가는 쪽이 더 나쁘다).
363
+ it("getUser 는 마운트가 아니라 제출 시점에 불린다(그 사이 로그인이 반영된다)", async () => {
364
+ const storage = memoryStorage();
365
+ // 세션 복구 전에는 호스트가 빈 id 를 준다(기존 TC 와 같은 관례). 복구되면 실제 id.
366
+ let session: FeedbackUser = { id: "" };
367
+ const getUser = vi.fn((): FeedbackUser => session);
368
+
369
+ // 1) 아직 로그인 전 — 게스트로 나가야 한다.
370
+ const before = await sendOnce({ platform: "web", storage, getUser });
371
+ expect(before.context.user?.isGuest).toBe(true);
372
+
373
+ // 2) 그 사이 로그인.
374
+ session = { id: "u-77" };
375
+
376
+ // 3) 같은 getUser 함수인데 이번엔 로그인 신원이 실려야 한다.
377
+ const after = await sendOnce({ platform: "web", storage, getUser });
378
+ expect(after.context.user?.id).toBe("u-77");
379
+ expect(after.context.user?.isGuest).toBe(false);
380
+
381
+ // 제보 1건당 정확히 1회. 캐싱도 아니고 중복 호출도 아니다.
382
+ expect(getUser).toHaveBeenCalledTimes(2);
383
+ });
384
+
385
+ it("getUser 가 던지거나 빈 id 를 주면 게스트로 떨어진다(제보를 버리지 않는다)", async () => {
386
+ const thrown = await sendOnce({
387
+ platform: "web",
388
+ getUser: () => {
389
+ throw new Error("아직 세션 복구 전");
390
+ },
391
+ });
392
+ expect(thrown.context.user?.isGuest).toBe(true);
393
+ expect(thrown.context.user?.id).toMatch(/^guest_/);
394
+
395
+ const empty = await sendOnce({
396
+ platform: "web",
397
+ getUser: () => ({ id: "" }),
398
+ });
399
+ expect(empty.context.user?.isGuest).toBe(true);
400
+ expect(empty.context.user?.id).toMatch(/^guest_/);
401
+ });
402
+
403
+ it("저장소가 아예 못 쓰는 상황에서만 user 가 null 이 된다", async () => {
404
+ const broken: FeedbackStorage = {
405
+ async get() {
406
+ throw new Error("storage unavailable");
407
+ },
408
+ async set() {
409
+ throw new Error("storage unavailable");
410
+ },
411
+ async remove() {
412
+ /* 큐 쪽은 조용히 넘어가야 한다 */
413
+ },
414
+ };
415
+
416
+ const report = await buildReport(PARTS, {
417
+ app: "test-app",
418
+ sessionId: "session-1",
419
+ storage: broken,
420
+ platform: "web",
421
+ now: clock,
422
+ iso,
423
+ });
424
+
425
+ expect(report.context.user).toBeNull();
426
+ });
427
+ });