@things-factory/board-service 10.1.205 → 10.1.208

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.
@@ -11,6 +11,7 @@
11
11
  * 5) generateThumbnail 실패 시 hash unset → 다음 schedule 에서 재시도
12
12
  * 6) saveThumbnail 실패도 동일 — 재시도 가능
13
13
  * 7) timestamp 가 saveThumbnail 인자로 정확히 전달됨
14
+ * 8) 클라이언트 thumbnail — 대기 작업 버림 · 진행 중 결과는 since 로 거절 · hash 지움
14
15
  */
15
16
  Object.defineProperty(exports, "__esModule", { value: true });
16
17
  // scheduler 모듈 import 시 끌려오는 무거운 deps (entity, puppeteer, shell DB)
@@ -84,10 +85,11 @@ describe('scheduleThumbnail — debounce / dedup / hash', () => {
84
85
  throw new Error('generate failed');
85
86
  return nextGenerateResult;
86
87
  },
87
- saveThumbnail: async (boardId, thumbnail, timestamp) => {
88
+ saveThumbnail: async (boardId, thumbnail, timestamp, since) => {
88
89
  if (saveShouldFail)
89
90
  throw new Error('save failed');
90
- saveCalls.push({ boardId, thumbnail, timestamp });
91
+ saveCalls.push({ boardId, thumbnail, timestamp, since });
92
+ return true;
91
93
  }
92
94
  };
93
95
  (0, thumbnail_scheduler_1._setSchedulerDeps)(mockDeps);
@@ -171,8 +173,9 @@ describe('scheduleThumbnail — debounce / dedup / hash', () => {
171
173
  }
172
174
  return 'base64data';
173
175
  },
174
- saveThumbnail: async (boardId, thumbnail, timestamp) => {
175
- saveCalls.push({ boardId, thumbnail, timestamp });
176
+ saveThumbnail: async (boardId, thumbnail, timestamp, since) => {
177
+ saveCalls.push({ boardId, thumbnail, timestamp, since });
178
+ return true;
176
179
  }
177
180
  });
178
181
  // 5 연속 schedule (각각 다른 model)
@@ -262,8 +265,9 @@ describe('scheduleThumbnail — debounce / dedup / hash', () => {
262
265
  return firstPromise;
263
266
  return 'base64data';
264
267
  },
265
- saveThumbnail: async (boardId, thumbnail, timestamp) => {
266
- saveCalls.push({ boardId, thumbnail, timestamp });
268
+ saveThumbnail: async (boardId, thumbnail, timestamp, since) => {
269
+ saveCalls.push({ boardId, thumbnail, timestamp, since });
270
+ return true;
267
271
  }
268
272
  });
269
273
  (0, thumbnail_scheduler_1.scheduleThumbnail)('board-1', '{"v":1}', {});
@@ -290,4 +294,100 @@ describe('scheduleThumbnail — debounce / dedup / hash', () => {
290
294
  expect(after.cachedHashes).toBeGreaterThanOrEqual(1);
291
295
  });
292
296
  });
297
+ // ─── 클라이언트 thumbnail ──────────────────────────────────────────────────
298
+ describe('isClientThumbnail', () => {
299
+ test('png data URL 은 클라이언트 그림', () => {
300
+ expect((0, thumbnail_scheduler_1.isClientThumbnail)('data:image/png;base64,AAAA')).toBe(true);
301
+ });
302
+ test('없거나 빈 gif 는 그림이 없다는 뜻', () => {
303
+ expect((0, thumbnail_scheduler_1.isClientThumbnail)(undefined)).toBe(false);
304
+ expect((0, thumbnail_scheduler_1.isClientThumbnail)(null)).toBe(false);
305
+ expect((0, thumbnail_scheduler_1.isClientThumbnail)('')).toBe(false);
306
+ expect((0, thumbnail_scheduler_1.isClientThumbnail)('data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')).toBe(false);
307
+ });
308
+ });
309
+ describe('acceptClientThumbnail', () => {
310
+ let generateCalls;
311
+ let saveCalls;
312
+ /* DB 처럼 board 마다 thumbnailUpdatedAt 을 갖고, since 보다 늦게 저장된 것이 있으면 거절한다 */
313
+ let thumbnailUpdatedAt;
314
+ let releaseGenerate;
315
+ beforeEach(() => {
316
+ generateCalls = [];
317
+ saveCalls = [];
318
+ thumbnailUpdatedAt = new Map();
319
+ releaseGenerate = undefined;
320
+ (0, thumbnail_scheduler_1._setSchedulerDeps)({
321
+ generateThumbnail: async (model) => {
322
+ generateCalls.push(model);
323
+ if (model.includes('slow'))
324
+ return new Promise(resolve => (releaseGenerate = resolve));
325
+ return 'server';
326
+ },
327
+ saveThumbnail: async (boardId, thumbnail, timestamp, since) => {
328
+ const saved = thumbnailUpdatedAt.get(boardId);
329
+ if (saved && saved >= since)
330
+ return false;
331
+ thumbnailUpdatedAt.set(boardId, timestamp);
332
+ saveCalls.push({ boardId, thumbnail, since });
333
+ return true;
334
+ }
335
+ });
336
+ (0, thumbnail_scheduler_1._resetSchedulerState)();
337
+ });
338
+ afterEach(() => {
339
+ (0, thumbnail_scheduler_1._resetSchedulerDeps)();
340
+ (0, thumbnail_scheduler_1._resetSchedulerState)();
341
+ });
342
+ /* updateBoard 가 하는 일: acceptClientThumbnail 을 부르고 thumbnail 과 thumbnailUpdatedAt 을 저장한다 */
343
+ function saveClientThumbnail(boardId) {
344
+ (0, thumbnail_scheduler_1.acceptClientThumbnail)(boardId);
345
+ thumbnailUpdatedAt.set(boardId, new Date());
346
+ }
347
+ test('대기 중인 생성은 버린다', async () => {
348
+ (0, thumbnail_scheduler_1.scheduleThumbnail)('board-1', '{"v":"slow"}', {});
349
+ await new Promise(r => setImmediate(r));
350
+ (0, thumbnail_scheduler_1.scheduleThumbnail)('board-1', '{"v":2}', {}); // 진행 중이라 대기
351
+ saveClientThumbnail('board-1');
352
+ releaseGenerate('server-slow');
353
+ await (0, thumbnail_scheduler_1._waitForIdle)();
354
+ expect(generateCalls).toEqual(['{"v":"slow"}']);
355
+ expect((0, thumbnail_scheduler_1.getSchedulerStats)().pending).toBe(0);
356
+ });
357
+ test('진행 중인 생성은 끝나도 저장하지 않는다', async () => {
358
+ (0, thumbnail_scheduler_1.scheduleThumbnail)('board-1', '{"v":"slow"}', {});
359
+ await new Promise(r => setImmediate(r));
360
+ await new Promise(r => setTimeout(r, 2)); // 생성 시작과 클라이언트 저장이 같은 ms 가 되지 않게
361
+ saveClientThumbnail('board-1');
362
+ await new Promise(r => setTimeout(r, 2)); // 생성이 끝나는 때는 클라이언트 저장보다 늦다
363
+ releaseGenerate('server-slow');
364
+ await (0, thumbnail_scheduler_1._waitForIdle)();
365
+ expect(saveCalls).toHaveLength(0);
366
+ expect((0, thumbnail_scheduler_1.getSchedulerStats)().cachedHashes).toBe(0);
367
+ });
368
+ test('since 는 생성을 시작한 때다', async () => {
369
+ const before = new Date();
370
+ (0, thumbnail_scheduler_1.scheduleThumbnail)('board-1', '{"v":1}', {});
371
+ await (0, thumbnail_scheduler_1._waitForIdle)();
372
+ expect(saveCalls).toHaveLength(1);
373
+ expect(saveCalls[0].since.getTime()).toBeGreaterThanOrEqual(before.getTime());
374
+ });
375
+ test('hash 를 지운다 — 같은 model 을 thumbnail 없이 다시 저장하면 다시 그린다', async () => {
376
+ (0, thumbnail_scheduler_1.scheduleThumbnail)('board-1', '{"v":1}', {});
377
+ await (0, thumbnail_scheduler_1._waitForIdle)();
378
+ expect(generateCalls).toHaveLength(1);
379
+ saveClientThumbnail('board-1');
380
+ await new Promise(r => setTimeout(r, 2));
381
+ (0, thumbnail_scheduler_1.scheduleThumbnail)('board-1', '{"v":1}', {});
382
+ await (0, thumbnail_scheduler_1._waitForIdle)();
383
+ expect(generateCalls).toHaveLength(2);
384
+ expect(saveCalls).toHaveLength(2);
385
+ });
386
+ test('다른 board 의 생성은 그대로', async () => {
387
+ (0, thumbnail_scheduler_1.scheduleThumbnail)('board-2', '{"v":1}', {});
388
+ saveClientThumbnail('board-1');
389
+ await (0, thumbnail_scheduler_1._waitForIdle)();
390
+ expect(saveCalls.map(c => c.boardId)).toEqual(['board-2']);
391
+ });
392
+ });
293
393
  //# sourceMappingURL=thumbnail-scheduler.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"thumbnail-scheduler.test.js","sourceRoot":"","sources":["../../../server/service/board/thumbnail-scheduler.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Thumbnail Scheduler — debounce / dedup / hash 동작 단위 테스트.\n *\n * 실제 puppeteer / DB 의존성은 dep injection (_setSchedulerDeps) 로 mock.\n * 검증 대상은 *스케줄링 정책 그 자체*:\n * 1) fnv1a hash 결정성 / 충돌 적음\n * 2) 동일 model 반복 schedule → generateThumbnail 1 회만\n * 3) per-board debounce — 빈번 schedule 시 중간 model 은 skip, 최신만 처리\n * 4) 다른 board 는 서로 독립\n * 5) generateThumbnail 실패 시 hash unset → 다음 schedule 에서 재시도\n * 6) saveThumbnail 실패도 동일 — 재시도 가능\n * 7) timestamp 가 saveThumbnail 인자로 정확히 전달됨\n */\n\n// scheduler 모듈 import 시 끌려오는 무거운 deps (entity, puppeteer, shell DB)\n// 는 _setSchedulerDeps 로 어차피 교체하니, import 시점 부담만 줄이고자 mock.\njest.mock('../../controllers/thumbnail', () => ({\n thumbnail: jest.fn().mockResolvedValue(undefined)\n}))\njest.mock('./board', () => ({ Board: class MockBoard {} }))\njest.mock('@things-factory/shell', () => ({\n getDataSource: () => ({ transaction: async (cb: any) => cb({}) }),\n getRepository: () => ({ save: jest.fn().mockResolvedValue({}) })\n}))\n\nimport {\n fnv1a,\n scheduleThumbnail,\n _setSchedulerDeps,\n _resetSchedulerDeps,\n _resetSchedulerState,\n _waitForIdle,\n getSchedulerStats,\n type SchedulerDeps\n} from './thumbnail-scheduler'\n\n// ─── fnv1a hash ─────────────────────────────────────────────────────────────\n\ndescribe('fnv1a hash', () => {\n test('같은 입력은 항상 같은 hash', () => {\n expect(fnv1a('hello')).toBe(fnv1a('hello'))\n expect(fnv1a('{\"x\":1}')).toBe(fnv1a('{\"x\":1}'))\n expect(fnv1a('')).toBe(fnv1a(''))\n })\n\n test('다른 입력은 다른 hash (현실적인 model 변경)', () => {\n expect(fnv1a('{\"width\":100}')).not.toBe(fnv1a('{\"width\":200}'))\n expect(fnv1a('{\"a\":1}')).not.toBe(fnv1a('{\"a\":2}'))\n expect(fnv1a('A')).not.toBe(fnv1a('B'))\n })\n\n test('빈 문자열은 결정적 hash', () => {\n const h = fnv1a('')\n expect(typeof h).toBe('string')\n expect(h.length).toBeGreaterThan(0)\n })\n\n test('아주 작은 변경도 다른 hash', () => {\n expect(fnv1a('{\"x\":1.0}')).not.toBe(fnv1a('{\"x\":1.1}'))\n expect(fnv1a('abc')).not.toBe(fnv1a('abd'))\n })\n\n test('긴 입력에서도 빠르게 결정적', () => {\n const big = JSON.stringify({ components: Array(10000).fill({ type: 'rect', x: 1, y: 2 }) })\n const h1 = fnv1a(big)\n const h2 = fnv1a(big)\n expect(h1).toBe(h2)\n })\n\n test('hex 형식 string 반환', () => {\n const h = fnv1a('test')\n expect(h).toMatch(/^[0-9a-f]+$/)\n })\n\n test('100 개 다른 입력 중 hash 충돌 없음', () => {\n const hashes = new Set<string>()\n for (let i = 0; i < 100; i++) {\n hashes.add(fnv1a(`{\"id\":${i},\"data\":\"value-${i}\"}`))\n }\n expect(hashes.size).toBe(100)\n })\n})\n\n// ─── scheduleThumbnail 동작 ─────────────────────────────────────────────────\n\ndescribe('scheduleThumbnail — debounce / dedup / hash', () => {\n let generateCalls: Array<{ model: string; context: any }>\n let saveCalls: Array<{ boardId: string; thumbnail: string; timestamp: Date }>\n let nextGenerateResult: string | undefined\n let generateShouldFail: boolean\n let saveShouldFail: boolean\n\n beforeEach(() => {\n generateCalls = []\n saveCalls = []\n nextGenerateResult = 'base64data'\n generateShouldFail = false\n saveShouldFail = false\n\n const mockDeps: SchedulerDeps = {\n generateThumbnail: async (model, context) => {\n generateCalls.push({ model, context })\n if (generateShouldFail) throw new Error('generate failed')\n return nextGenerateResult\n },\n saveThumbnail: async (boardId, thumbnail, timestamp) => {\n if (saveShouldFail) throw new Error('save failed')\n saveCalls.push({ boardId, thumbnail, timestamp })\n }\n }\n\n _setSchedulerDeps(mockDeps)\n _resetSchedulerState()\n })\n\n afterEach(() => {\n _resetSchedulerDeps()\n _resetSchedulerState()\n })\n\n test('첫 schedule → generateThumbnail 1 회, saveThumbnail 1 회', async () => {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(1)\n expect(saveCalls).toHaveLength(1)\n expect(saveCalls[0].boardId).toBe('board-1')\n expect(saveCalls[0].thumbnail).toBe('data:image/png;base64,base64data')\n })\n\n test('saveThumbnail 인자에 dataURL 형식 적용', async () => {\n nextGenerateResult = 'AAAA'\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(saveCalls[0].thumbnail).toBe('data:image/png;base64,AAAA')\n })\n\n test('generateThumbnail 가 null 반환 → empty thumbnail dataURL', async () => {\n nextGenerateResult = 'null'\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(saveCalls[0].thumbnail).toMatch(/^data:image\\/gif;base64,/)\n })\n\n test('generateThumbnail 가 undefined 반환 → empty thumbnail', async () => {\n nextGenerateResult = undefined\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(saveCalls[0].thumbnail).toMatch(/^data:image\\/gif;base64,/)\n })\n\n test('saveThumbnail timestamp 가 Date 인스턴스', async () => {\n const before = new Date()\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n const after = new Date()\n\n expect(saveCalls[0].timestamp).toBeInstanceOf(Date)\n expect(saveCalls[0].timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime())\n expect(saveCalls[0].timestamp.getTime()).toBeLessThanOrEqual(after.getTime())\n })\n\n test('동일 model 두 번 schedule → 두 번째는 hash dedup 으로 skip', async () => {\n const sameModel = '{\"w\":100,\"h\":200}'\n\n scheduleThumbnail('board-1', sameModel, {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n\n scheduleThumbnail('board-1', sameModel, {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1) // hash dedup\n })\n\n test('model 변경 후 schedule → generateThumbnail 다시 호출', async () => {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n\n scheduleThumbnail('board-1', '{\"w\":200}', {}) // 다른 model\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n })\n\n test('빈 boardId 또는 빈 model 은 no-op', async () => {\n scheduleThumbnail('', '{\"w\":100}', {})\n scheduleThumbnail('board-1', '', {})\n scheduleThumbnail('', '', {})\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(0)\n expect(saveCalls).toHaveLength(0)\n })\n\n test('빈번한 schedule (5 회) → debounce 로 generateThumbnail 최대 2 회 (진행 중 1 + 최신 1)', async () => {\n // 첫 schedule 은 즉시 진행. 그 사이 나머지 4 개는 pending 큐에 누적되어\n // 최신 model 1 개만 살아남고 그것이 두 번째 호출이 됨. 총 generate = 2.\n let resolveFirst: (value: string) => void\n let firstStarted = false\n\n _setSchedulerDeps({\n generateThumbnail: async (model, context) => {\n generateCalls.push({ model, context })\n if (!firstStarted) {\n firstStarted = true\n return new Promise<string>(resolve => { resolveFirst = resolve })\n }\n return 'base64data'\n },\n saveThumbnail: async (boardId, thumbnail, timestamp) => {\n saveCalls.push({ boardId, thumbnail, timestamp })\n }\n })\n\n // 5 연속 schedule (각각 다른 model)\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await new Promise(r => setImmediate(r)) // 첫 호출 시작 보장\n scheduleThumbnail('board-1', '{\"v\":2}', {})\n scheduleThumbnail('board-1', '{\"v\":3}', {})\n scheduleThumbnail('board-1', '{\"v\":4}', {})\n scheduleThumbnail('board-1', '{\"v\":5}', {})\n\n // 첫 호출 release\n resolveFirst!('base64data')\n await _waitForIdle()\n\n // 첫 호출 + 큐의 최신 (v=5) = 2 회\n expect(generateCalls).toHaveLength(2)\n expect(generateCalls[0].model).toBe('{\"v\":1}')\n expect(generateCalls[1].model).toBe('{\"v\":5}')\n })\n\n test('다른 board 는 서로 독립 처리', async () => {\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n scheduleThumbnail('board-2', '{\"v\":1}', {})\n scheduleThumbnail('board-3', '{\"v\":1}', {})\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(3)\n expect(saveCalls.map(s => s.boardId).sort()).toEqual(['board-1', 'board-2', 'board-3'])\n })\n\n test('generateThumbnail 실패 시 hash unset → 다음 schedule 에서 재시도', async () => {\n generateShouldFail = true\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n expect(saveCalls).toHaveLength(0)\n\n // 같은 model 로 다시 — 실패했으니 hash 없어서 재시도\n generateShouldFail = false\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n expect(saveCalls).toHaveLength(1)\n })\n\n test('saveThumbnail 실패 시 hash unset → 다음 schedule 에서 재시도', async () => {\n saveShouldFail = true\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n expect(saveCalls).toHaveLength(0)\n\n saveShouldFail = false\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n expect(saveCalls).toHaveLength(1)\n })\n\n test('성공 → 같은 model 재시도 안 함 (hash set 됨)', async () => {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n for (let i = 0; i < 5; i++) {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n }\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(1) // 첫 성공 후 모두 dedup\n })\n\n test('성공 → 다른 model → 같은 첫 model 다시: 다시 호출됨 (hash 는 마지막 성공 model 만 추적)', async () => {\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n\n scheduleThumbnail('board-1', '{\"v\":2}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n\n // v=1 으로 돌아가도 마지막 hash 는 v=2 이므로 다시 호출.\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(3)\n })\n\n test('context 객체가 generateThumbnail 에 전달됨', async () => {\n const ctx = { state: { domain: { subdomain: 'test' }, user: { id: 'u1' } } }\n scheduleThumbnail('board-1', '{\"w\":100}', ctx)\n await _waitForIdle()\n\n expect(generateCalls[0].context).toBe(ctx)\n })\n\n test('진행 중에 새 schedule → 큐에 보존 → 끝나면 자동으로 처리', async () => {\n let resolveFirst: (value: string) => void\n const firstPromise = new Promise<string>(resolve => { resolveFirst = resolve })\n let callCount = 0\n\n _setSchedulerDeps({\n generateThumbnail: async (model, context) => {\n callCount++\n generateCalls.push({ model, context })\n if (callCount === 1) return firstPromise\n return 'base64data'\n },\n saveThumbnail: async (boardId, thumbnail, timestamp) => {\n saveCalls.push({ boardId, thumbnail, timestamp })\n }\n })\n\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await new Promise(r => setImmediate(r))\n expect(generateCalls).toHaveLength(1)\n\n // 진행 중에 새 schedule\n scheduleThumbnail('board-1', '{\"v\":2}', {})\n expect(generateCalls).toHaveLength(1) // 아직 1 (대기 중)\n\n // 첫 호출 release\n resolveFirst!('first')\n await _waitForIdle()\n\n // 첫 호출 끝나고 큐의 v=2 처리됨\n expect(generateCalls).toHaveLength(2)\n expect(generateCalls[1].model).toBe('{\"v\":2}')\n })\n\n test('getSchedulerStats 는 합리적 값 반환', async () => {\n const stats = getSchedulerStats()\n expect(typeof stats.pending).toBe('number')\n expect(typeof stats.inFlight).toBe('number')\n expect(typeof stats.cachedHashes).toBe('number')\n\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n const after = getSchedulerStats()\n expect(after.cachedHashes).toBeGreaterThanOrEqual(1)\n })\n})\n"]}
1
+ {"version":3,"file":"thumbnail-scheduler.test.js","sourceRoot":"","sources":["../../../server/service/board/thumbnail-scheduler.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Thumbnail Scheduler — debounce / dedup / hash 동작 단위 테스트.\n *\n * 실제 puppeteer / DB 의존성은 dep injection (_setSchedulerDeps) 로 mock.\n * 검증 대상은 *스케줄링 정책 그 자체*:\n * 1) fnv1a hash 결정성 / 충돌 적음\n * 2) 동일 model 반복 schedule → generateThumbnail 1 회만\n * 3) per-board debounce — 빈번 schedule 시 중간 model 은 skip, 최신만 처리\n * 4) 다른 board 는 서로 독립\n * 5) generateThumbnail 실패 시 hash unset → 다음 schedule 에서 재시도\n * 6) saveThumbnail 실패도 동일 — 재시도 가능\n * 7) timestamp 가 saveThumbnail 인자로 정확히 전달됨\n * 8) 클라이언트 thumbnail — 대기 작업 버림 · 진행 중 결과는 since 로 거절 · hash 지움\n */\n\n// scheduler 모듈 import 시 끌려오는 무거운 deps (entity, puppeteer, shell DB)\n// 는 _setSchedulerDeps 로 어차피 교체하니, import 시점 부담만 줄이고자 mock.\njest.mock('../../controllers/thumbnail', () => ({\n thumbnail: jest.fn().mockResolvedValue(undefined)\n}))\njest.mock('./board', () => ({ Board: class MockBoard {} }))\njest.mock('@things-factory/shell', () => ({\n getDataSource: () => ({ transaction: async (cb: any) => cb({}) }),\n getRepository: () => ({ save: jest.fn().mockResolvedValue({}) })\n}))\n\nimport {\n acceptClientThumbnail,\n fnv1a,\n isClientThumbnail,\n scheduleThumbnail,\n _setSchedulerDeps,\n _resetSchedulerDeps,\n _resetSchedulerState,\n _waitForIdle,\n getSchedulerStats,\n type SchedulerDeps\n} from './thumbnail-scheduler'\n\n// ─── fnv1a hash ─────────────────────────────────────────────────────────────\n\ndescribe('fnv1a hash', () => {\n test('같은 입력은 항상 같은 hash', () => {\n expect(fnv1a('hello')).toBe(fnv1a('hello'))\n expect(fnv1a('{\"x\":1}')).toBe(fnv1a('{\"x\":1}'))\n expect(fnv1a('')).toBe(fnv1a(''))\n })\n\n test('다른 입력은 다른 hash (현실적인 model 변경)', () => {\n expect(fnv1a('{\"width\":100}')).not.toBe(fnv1a('{\"width\":200}'))\n expect(fnv1a('{\"a\":1}')).not.toBe(fnv1a('{\"a\":2}'))\n expect(fnv1a('A')).not.toBe(fnv1a('B'))\n })\n\n test('빈 문자열은 결정적 hash', () => {\n const h = fnv1a('')\n expect(typeof h).toBe('string')\n expect(h.length).toBeGreaterThan(0)\n })\n\n test('아주 작은 변경도 다른 hash', () => {\n expect(fnv1a('{\"x\":1.0}')).not.toBe(fnv1a('{\"x\":1.1}'))\n expect(fnv1a('abc')).not.toBe(fnv1a('abd'))\n })\n\n test('긴 입력에서도 빠르게 결정적', () => {\n const big = JSON.stringify({ components: Array(10000).fill({ type: 'rect', x: 1, y: 2 }) })\n const h1 = fnv1a(big)\n const h2 = fnv1a(big)\n expect(h1).toBe(h2)\n })\n\n test('hex 형식 string 반환', () => {\n const h = fnv1a('test')\n expect(h).toMatch(/^[0-9a-f]+$/)\n })\n\n test('100 개 다른 입력 중 hash 충돌 없음', () => {\n const hashes = new Set<string>()\n for (let i = 0; i < 100; i++) {\n hashes.add(fnv1a(`{\"id\":${i},\"data\":\"value-${i}\"}`))\n }\n expect(hashes.size).toBe(100)\n })\n})\n\n// ─── scheduleThumbnail 동작 ─────────────────────────────────────────────────\n\ndescribe('scheduleThumbnail — debounce / dedup / hash', () => {\n let generateCalls: Array<{ model: string; context: any }>\n let saveCalls: Array<{ boardId: string; thumbnail: string; timestamp: Date; since: Date }>\n let nextGenerateResult: string | undefined\n let generateShouldFail: boolean\n let saveShouldFail: boolean\n\n beforeEach(() => {\n generateCalls = []\n saveCalls = []\n nextGenerateResult = 'base64data'\n generateShouldFail = false\n saveShouldFail = false\n\n const mockDeps: SchedulerDeps = {\n generateThumbnail: async (model, context) => {\n generateCalls.push({ model, context })\n if (generateShouldFail) throw new Error('generate failed')\n return nextGenerateResult\n },\n saveThumbnail: async (boardId, thumbnail, timestamp, since) => {\n if (saveShouldFail) throw new Error('save failed')\n saveCalls.push({ boardId, thumbnail, timestamp, since })\n return true\n }\n }\n\n _setSchedulerDeps(mockDeps)\n _resetSchedulerState()\n })\n\n afterEach(() => {\n _resetSchedulerDeps()\n _resetSchedulerState()\n })\n\n test('첫 schedule → generateThumbnail 1 회, saveThumbnail 1 회', async () => {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(1)\n expect(saveCalls).toHaveLength(1)\n expect(saveCalls[0].boardId).toBe('board-1')\n expect(saveCalls[0].thumbnail).toBe('data:image/png;base64,base64data')\n })\n\n test('saveThumbnail 인자에 dataURL 형식 적용', async () => {\n nextGenerateResult = 'AAAA'\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(saveCalls[0].thumbnail).toBe('data:image/png;base64,AAAA')\n })\n\n test('generateThumbnail 가 null 반환 → empty thumbnail dataURL', async () => {\n nextGenerateResult = 'null'\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(saveCalls[0].thumbnail).toMatch(/^data:image\\/gif;base64,/)\n })\n\n test('generateThumbnail 가 undefined 반환 → empty thumbnail', async () => {\n nextGenerateResult = undefined\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n expect(saveCalls[0].thumbnail).toMatch(/^data:image\\/gif;base64,/)\n })\n\n test('saveThumbnail timestamp 가 Date 인스턴스', async () => {\n const before = new Date()\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n const after = new Date()\n\n expect(saveCalls[0].timestamp).toBeInstanceOf(Date)\n expect(saveCalls[0].timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime())\n expect(saveCalls[0].timestamp.getTime()).toBeLessThanOrEqual(after.getTime())\n })\n\n test('동일 model 두 번 schedule → 두 번째는 hash dedup 으로 skip', async () => {\n const sameModel = '{\"w\":100,\"h\":200}'\n\n scheduleThumbnail('board-1', sameModel, {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n\n scheduleThumbnail('board-1', sameModel, {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1) // hash dedup\n })\n\n test('model 변경 후 schedule → generateThumbnail 다시 호출', async () => {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n\n scheduleThumbnail('board-1', '{\"w\":200}', {}) // 다른 model\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n })\n\n test('빈 boardId 또는 빈 model 은 no-op', async () => {\n scheduleThumbnail('', '{\"w\":100}', {})\n scheduleThumbnail('board-1', '', {})\n scheduleThumbnail('', '', {})\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(0)\n expect(saveCalls).toHaveLength(0)\n })\n\n test('빈번한 schedule (5 회) → debounce 로 generateThumbnail 최대 2 회 (진행 중 1 + 최신 1)', async () => {\n // 첫 schedule 은 즉시 진행. 그 사이 나머지 4 개는 pending 큐에 누적되어\n // 최신 model 1 개만 살아남고 그것이 두 번째 호출이 됨. 총 generate = 2.\n let resolveFirst: (value: string) => void\n let firstStarted = false\n\n _setSchedulerDeps({\n generateThumbnail: async (model, context) => {\n generateCalls.push({ model, context })\n if (!firstStarted) {\n firstStarted = true\n return new Promise<string>(resolve => { resolveFirst = resolve })\n }\n return 'base64data'\n },\n saveThumbnail: async (boardId, thumbnail, timestamp, since) => {\n saveCalls.push({ boardId, thumbnail, timestamp, since })\n return true\n }\n })\n\n // 5 연속 schedule (각각 다른 model)\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await new Promise(r => setImmediate(r)) // 첫 호출 시작 보장\n scheduleThumbnail('board-1', '{\"v\":2}', {})\n scheduleThumbnail('board-1', '{\"v\":3}', {})\n scheduleThumbnail('board-1', '{\"v\":4}', {})\n scheduleThumbnail('board-1', '{\"v\":5}', {})\n\n // 첫 호출 release\n resolveFirst!('base64data')\n await _waitForIdle()\n\n // 첫 호출 + 큐의 최신 (v=5) = 2 회\n expect(generateCalls).toHaveLength(2)\n expect(generateCalls[0].model).toBe('{\"v\":1}')\n expect(generateCalls[1].model).toBe('{\"v\":5}')\n })\n\n test('다른 board 는 서로 독립 처리', async () => {\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n scheduleThumbnail('board-2', '{\"v\":1}', {})\n scheduleThumbnail('board-3', '{\"v\":1}', {})\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(3)\n expect(saveCalls.map(s => s.boardId).sort()).toEqual(['board-1', 'board-2', 'board-3'])\n })\n\n test('generateThumbnail 실패 시 hash unset → 다음 schedule 에서 재시도', async () => {\n generateShouldFail = true\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n expect(saveCalls).toHaveLength(0)\n\n // 같은 model 로 다시 — 실패했으니 hash 없어서 재시도\n generateShouldFail = false\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n expect(saveCalls).toHaveLength(1)\n })\n\n test('saveThumbnail 실패 시 hash unset → 다음 schedule 에서 재시도', async () => {\n saveShouldFail = true\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n expect(saveCalls).toHaveLength(0)\n\n saveShouldFail = false\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n expect(saveCalls).toHaveLength(1)\n })\n\n test('성공 → 같은 model 재시도 안 함 (hash set 됨)', async () => {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n await _waitForIdle()\n\n for (let i = 0; i < 5; i++) {\n scheduleThumbnail('board-1', '{\"w\":100}', {})\n }\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(1) // 첫 성공 후 모두 dedup\n })\n\n test('성공 → 다른 model → 같은 첫 model 다시: 다시 호출됨 (hash 는 마지막 성공 model 만 추적)', async () => {\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n\n scheduleThumbnail('board-1', '{\"v\":2}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(2)\n\n // v=1 으로 돌아가도 마지막 hash 는 v=2 이므로 다시 호출.\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(3)\n })\n\n test('context 객체가 generateThumbnail 에 전달됨', async () => {\n const ctx = { state: { domain: { subdomain: 'test' }, user: { id: 'u1' } } }\n scheduleThumbnail('board-1', '{\"w\":100}', ctx)\n await _waitForIdle()\n\n expect(generateCalls[0].context).toBe(ctx)\n })\n\n test('진행 중에 새 schedule → 큐에 보존 → 끝나면 자동으로 처리', async () => {\n let resolveFirst: (value: string) => void\n const firstPromise = new Promise<string>(resolve => { resolveFirst = resolve })\n let callCount = 0\n\n _setSchedulerDeps({\n generateThumbnail: async (model, context) => {\n callCount++\n generateCalls.push({ model, context })\n if (callCount === 1) return firstPromise\n return 'base64data'\n },\n saveThumbnail: async (boardId, thumbnail, timestamp, since) => {\n saveCalls.push({ boardId, thumbnail, timestamp, since })\n return true\n }\n })\n\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await new Promise(r => setImmediate(r))\n expect(generateCalls).toHaveLength(1)\n\n // 진행 중에 새 schedule\n scheduleThumbnail('board-1', '{\"v\":2}', {})\n expect(generateCalls).toHaveLength(1) // 아직 1 (대기 중)\n\n // 첫 호출 release\n resolveFirst!('first')\n await _waitForIdle()\n\n // 첫 호출 끝나고 큐의 v=2 처리됨\n expect(generateCalls).toHaveLength(2)\n expect(generateCalls[1].model).toBe('{\"v\":2}')\n })\n\n test('getSchedulerStats 는 합리적 값 반환', async () => {\n const stats = getSchedulerStats()\n expect(typeof stats.pending).toBe('number')\n expect(typeof stats.inFlight).toBe('number')\n expect(typeof stats.cachedHashes).toBe('number')\n\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n const after = getSchedulerStats()\n expect(after.cachedHashes).toBeGreaterThanOrEqual(1)\n })\n})\n\n// ─── 클라이언트 thumbnail ──────────────────────────────────────────────────\n\ndescribe('isClientThumbnail', () => {\n test('png data URL 은 클라이언트 그림', () => {\n expect(isClientThumbnail('data:image/png;base64,AAAA')).toBe(true)\n })\n\n test('없거나 빈 gif 는 그림이 없다는 뜻', () => {\n expect(isClientThumbnail(undefined)).toBe(false)\n expect(isClientThumbnail(null)).toBe(false)\n expect(isClientThumbnail('')).toBe(false)\n expect(isClientThumbnail('data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==')).toBe(false)\n })\n})\n\ndescribe('acceptClientThumbnail', () => {\n let generateCalls: string[]\n let saveCalls: Array<{ boardId: string; thumbnail: string; since: Date }>\n\n /* DB 처럼 board 마다 thumbnailUpdatedAt 을 갖고, since 보다 늦게 저장된 것이 있으면 거절한다 */\n let thumbnailUpdatedAt: Map<string, Date>\n let releaseGenerate: ((value: string) => void) | undefined\n\n beforeEach(() => {\n generateCalls = []\n saveCalls = []\n thumbnailUpdatedAt = new Map()\n releaseGenerate = undefined\n\n _setSchedulerDeps({\n generateThumbnail: async model => {\n generateCalls.push(model)\n if (model.includes('slow')) return new Promise<string>(resolve => (releaseGenerate = resolve))\n return 'server'\n },\n saveThumbnail: async (boardId, thumbnail, timestamp, since) => {\n const saved = thumbnailUpdatedAt.get(boardId)\n if (saved && saved >= since) return false\n thumbnailUpdatedAt.set(boardId, timestamp)\n saveCalls.push({ boardId, thumbnail, since })\n return true\n }\n })\n _resetSchedulerState()\n })\n\n afterEach(() => {\n _resetSchedulerDeps()\n _resetSchedulerState()\n })\n\n /* updateBoard 가 하는 일: acceptClientThumbnail 을 부르고 thumbnail 과 thumbnailUpdatedAt 을 저장한다 */\n function saveClientThumbnail(boardId: string) {\n acceptClientThumbnail(boardId)\n thumbnailUpdatedAt.set(boardId, new Date())\n }\n\n test('대기 중인 생성은 버린다', async () => {\n scheduleThumbnail('board-1', '{\"v\":\"slow\"}', {})\n await new Promise(r => setImmediate(r))\n scheduleThumbnail('board-1', '{\"v\":2}', {}) // 진행 중이라 대기\n\n saveClientThumbnail('board-1')\n releaseGenerate!('server-slow')\n await _waitForIdle()\n\n expect(generateCalls).toEqual(['{\"v\":\"slow\"}'])\n expect(getSchedulerStats().pending).toBe(0)\n })\n\n test('진행 중인 생성은 끝나도 저장하지 않는다', async () => {\n scheduleThumbnail('board-1', '{\"v\":\"slow\"}', {})\n await new Promise(r => setImmediate(r))\n await new Promise(r => setTimeout(r, 2)) // 생성 시작과 클라이언트 저장이 같은 ms 가 되지 않게\n\n saveClientThumbnail('board-1')\n await new Promise(r => setTimeout(r, 2)) // 생성이 끝나는 때는 클라이언트 저장보다 늦다\n releaseGenerate!('server-slow')\n await _waitForIdle()\n\n expect(saveCalls).toHaveLength(0)\n expect(getSchedulerStats().cachedHashes).toBe(0)\n })\n\n test('since 는 생성을 시작한 때다', async () => {\n const before = new Date()\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n\n expect(saveCalls).toHaveLength(1)\n expect(saveCalls[0].since.getTime()).toBeGreaterThanOrEqual(before.getTime())\n })\n\n test('hash 를 지운다 — 같은 model 을 thumbnail 없이 다시 저장하면 다시 그린다', async () => {\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n expect(generateCalls).toHaveLength(1)\n\n saveClientThumbnail('board-1')\n await new Promise(r => setTimeout(r, 2))\n\n scheduleThumbnail('board-1', '{\"v\":1}', {})\n await _waitForIdle()\n\n expect(generateCalls).toHaveLength(2)\n expect(saveCalls).toHaveLength(2)\n })\n\n test('다른 board 의 생성은 그대로', async () => {\n scheduleThumbnail('board-2', '{\"v\":1}', {})\n saveClientThumbnail('board-1')\n await _waitForIdle()\n\n expect(saveCalls.map(c => c.boardId)).toEqual(['board-2'])\n })\n})\n"]}