// tests/main/services/dictionary-redteam-r3-26.test.ts // 서버 길이 제한(단어 120·발음 200)을 넘는 사전 항목은 로컬 저장·동기화 push 전에 거부돼야 한다. // 예전엔 로컬에 저장한 뒤 pushOne 했고, 서버가 22023 으로 거부해 아웃박스에 영구 보류됐다. import { describe, it, expect, beforeEach, vi } from 'vitest' import { ErrorCode } from '@d3ro/core/errors' const readFileSync = vi.fn() const pushOne = vi.fn(async () => undefined) vi.mock('fs', () => ({ default: { writeFileSync: vi.fn(), readFileSync }, writeFileSync: vi.fn(), readFileSync })) vi.mock('electron', () => ({ app: { getPath: () => '/tmp/d3ro' }, dialog: { showSaveDialog: vi.fn() } })) vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null })) vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) })) vi.mock('../../../src/main/services/CloudSyncService', () => ({ getCloudSyncService: () => ({ pushOne, pushDelete: vi.fn() }) })) function chain(result: { all?: unknown[]; get?: unknown }): Record { const builder: Record = {} builder.from = vi.fn(() => builder) builder.where = vi.fn(() => builder) builder.all = vi.fn(() => result.all ?? []) builder.get = vi.fn(() => result.get) return builder } const existingRow = { id: 'row-1', word: 'AI', pronunciation: null, category: 'user', usageCount: 0, lastUsedAt: null, createdAt: 1, updatedAt: 1 } const insertValues = vi.fn((_values: Record) => ({ run: vi.fn(() => ({ changes: 1 })) })) const updateSet = vi.fn(() => ({ where: vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) })) })) const txStub = { select: vi.fn(() => chain({ get: undefined })), insert: vi.fn(() => ({ values: insertValues })) } const mockDb = { select: vi.fn(() => chain({ all: [], get: existingRow })), insert: vi.fn(() => ({ values: insertValues })), update: vi.fn(() => ({ set: updateSet })), transaction: vi.fn((fn: (tx: typeof txStub) => void) => fn(txStub)) } vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb })) type ServiceModule = typeof import('../../../src/main/services/DictionaryService') let mod: ServiceModule beforeEach(async () => { vi.clearAllMocks() vi.resetModules() mod = await import('../../../src/main/services/DictionaryService') }) interface CapturedError { name: string message: string code: unknown details: unknown } // vi.resetModules() 로 서비스가 다른 D3ROError 클래스 인스턴스를 쓰므로 instanceof 대신 모양으로 확인한다 function captureError(fn: () => unknown): CapturedError { try { fn() } catch (err) { const captured = err as CapturedError expect(captured.name).toBe('D3ROError') return captured } throw new Error('expected a D3ROError') } describe('DictionaryService.add — 서버 길이 제한', () => { it('121자 단어는 저장·push 없이 field/max 를 담아 거부한다', () => { const err = captureError(() => mod.getDictionaryService().add({ word: 'w'.repeat(121) })) expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) expect(err.details).toMatchObject({ field: 'word', max: 120 }) expect(mockDb.insert).not.toHaveBeenCalled() expect(pushOne).not.toHaveBeenCalled() }) it('201자 발음은 저장·push 없이 거부한다', () => { const err = captureError(() => mod.getDictionaryService().add({ word: 'ok', pronunciation: 'p'.repeat(201) }) ) expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 }) expect(mockDb.insert).not.toHaveBeenCalled() expect(pushOne).not.toHaveBeenCalled() }) it('제한 이내 항목은 그대로 저장하고 push 한다', () => { const entry = mod.getDictionaryService().add({ word: 'w'.repeat(120), pronunciation: ' p ' }) expect(entry.word).toBe('w'.repeat(120)) expect(entry.pronunciation).toBe('p') expect(mockDb.insert).toHaveBeenCalledTimes(1) expect(pushOne).toHaveBeenCalledWith('dictionary', entry.id) }) it('빈 단어는 기존과 같은 메시지로 거부한다', () => { const err = captureError(() => mod.getDictionaryService().add({ word: ' ' })) expect(err.message).toBe('Dictionary word is empty') }) }) describe('DictionaryService.update — 서버 길이 제한', () => { it('발음을 201자로 바꾸는 편집은 쓰기·push 없이 거부한다', () => { const err = captureError(() => mod.getDictionaryService().update({ id: 'row-1', pronunciation: 'p'.repeat(201) }) ) expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 }) expect(mockDb.update).not.toHaveBeenCalled() expect(pushOne).not.toHaveBeenCalled() }) it('단어를 121자로 바꾸는 편집을 거부한다', () => { const err = captureError(() => mod.getDictionaryService().update({ id: 'row-1', word: 'w'.repeat(121) }) ) expect(err.details).toMatchObject({ field: 'word', max: 120 }) expect(mockDb.update).not.toHaveBeenCalled() }) }) describe('DictionaryService.importDictionary — 서버 길이 제한', () => { it('너무 긴 단어·발음 행은 오류로 세고 저장·push 하지 않는다', () => { readFileSync.mockReturnValue( JSON.stringify({ entries: [ { word: 'w'.repeat(121) }, { word: 'long-pron', pronunciation: 'p'.repeat(201) }, { word: 'ok', pronunciation: '오케이' } ] }) ) const result = mod .getDictionaryService() .importDictionary({ filePath: '/in/d.json', format: 'json' }) expect(result).toEqual({ imported: 1, skipped: 0, errors: 2 }) expect(insertValues).toHaveBeenCalledTimes(1) expect(insertValues).toHaveBeenCalledWith( expect.objectContaining({ word: 'ok', pronunciation: '오케이', category: 'user' }) ) expect(pushOne).toHaveBeenCalledTimes(1) }) it('CSV 의 너무 긴 행도 거부한다', () => { readFileSync.mockReturnValue(`word,pronunciation\r\n${'x'.repeat(121)},\r\nfine,\r\n`) const result = mod .getDictionaryService() .importDictionary({ filePath: '/in/d.csv', format: 'csv' }) expect(result).toEqual({ imported: 1, skipped: 0, errors: 1 }) }) })