// packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts // 사전 쓰기 정책이 서버 트리거(normalize_dictionary_word, 마이그레이션 20260821000005)의 // 길이 제한(단어 120·발음 200 코드 포인트)을 로컬에서 먼저 거부하는지 검증한다. import { describe, expect, it } from 'vitest' import { D3ROError, ErrorCode } from '../src/errors' import { DICTIONARY_LIMITS, buildDictionaryUpdatePatch, dictionaryWriteProblemError, validateDictionaryDraft } from '../src/dictionary-policy' describe('DICTIONARY_LIMITS', () => { it('서버 트리거와 같은 값이다', () => { expect(DICTIONARY_LIMITS).toEqual({ word: 120, pronunciation: 200 }) }) }) describe('validateDictionaryDraft', () => { it('정상 입력을 정규화한다', () => { expect(validateDictionaryDraft({ word: ' GPT ', pronunciation: ' 지피티 ' })).toEqual({ ok: true, draft: { word: 'GPT', pronunciation: '지피티' } }) expect(validateDictionaryDraft({ word: 'GPT', pronunciation: ' ' })).toEqual({ ok: true, draft: { word: 'GPT', pronunciation: null } }) }) it('빈 단어를 거부한다', () => { expect(validateDictionaryDraft({ word: ' ' })).toEqual({ ok: false, reason: 'empty-word' }) }) it('정확히 120자 단어·200자 발음은 받아들인다 (앞뒤 공백 제외)', () => { const result = validateDictionaryDraft({ word: ` ${'가'.repeat(120)} `, pronunciation: ` ${'a'.repeat(200)} ` }) expect(result.ok).toBe(true) }) it('121자 단어를 too-long 으로 거부한다', () => { expect(validateDictionaryDraft({ word: 'a'.repeat(121) })).toEqual({ ok: false, reason: 'too-long', field: 'word', max: 120 }) }) it('201자 발음을 too-long 으로 거부한다', () => { expect(validateDictionaryDraft({ word: 'ok', pronunciation: 'b'.repeat(201) })).toEqual({ ok: false, reason: 'too-long', field: 'pronunciation', max: 200 }) }) it('코드 포인트로 센다 — 서로게이트 쌍 이모지 120개는 허용', () => { const emoji = '\u{1F600}'.repeat(120) // UTF-16 길이 240 expect(validateDictionaryDraft({ word: emoji }).ok).toBe(true) expect(validateDictionaryDraft({ word: `${emoji}\u{1F600}` }).ok).toBe(false) }) }) describe('buildDictionaryUpdatePatch 길이 제한', () => { it('121자 단어로 바꾸는 편집을 거부한다', () => { expect(buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(121) })).toEqual({ ok: false, reason: 'too-long', field: 'word', max: 120 }) }) it('201자 발음으로 바꾸는 편집을 거부한다', () => { expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: 'p'.repeat(201) })).toEqual({ ok: false, reason: 'too-long', field: 'pronunciation', max: 200 }) }) it('제한 이내 편집과 발음 지우기는 그대로 통과한다', () => { expect( buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) }) ).toEqual({ ok: true, patch: { word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) } }) expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: null })).toEqual({ ok: true, patch: { pronunciation: null } }) }) }) describe('dictionaryWriteProblemError', () => { it('too-long 은 field·max 를 details 로 담은 DictionaryImportInvalidFormat 이다', () => { const err = dictionaryWriteProblemError({ reason: 'too-long', field: 'pronunciation', max: 200 }) expect(err).toBeInstanceOf(D3ROError) expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) expect(err.details).toEqual({ reason: 'too-long', field: 'pronunciation', max: 200 }) }) it('empty-word 는 기존 메시지를 유지한다', () => { const err = dictionaryWriteProblemError({ reason: 'empty-word' }) expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat) expect(err.message).toBe('Dictionary word is empty') expect(err.details).toEqual({ reason: 'empty-word', field: 'word' }) }) })