diff --git a/backend/Dockerfile b/backend/Dockerfile index 4e18384..c3ee50f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -43,6 +43,8 @@ COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist +# 업데이트 내역 글(글 파일 하나가 글 하나 — 계약 7절) — 서버가 기동 · 요청 때 /app/release-notes 를 읽기만 한다(modules/release-notes — 쓰지 않는다) +COPY --from=builder /app/release-notes ./release-notes # 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다. # 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다. diff --git a/backend/release-notes/2026-11-18.md b/backend/release-notes/2026-11-18.md new file mode 100644 index 0000000..02e088e --- /dev/null +++ b/backend/release-notes/2026-11-18.md @@ -0,0 +1,26 @@ +# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다 + + +## 휴대폰 앱 +- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다. +- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. + 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다. + +## 웹메일과 휴대폰 앱 +- 메일 검색이 빨라졌습니다. 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다. +- 찾은 말에는 노란 표시가 붙습니다. +- 메일을 읽다가 바로 다른 메일함으로 옮길 수 있습니다. [이동] + +## 웹메일 +- 메일을 열면 같은 대화의 메일을 그 아래에서 함께 볼 수 있습니다. 목록 위 [대화로 묶기]를 켜면 목록도 대화별로 묶입니다. +- 목록의 메일을 끌어다 왼쪽 메일함에 놓아도 옮겨집니다. +- 메일함을 다른 메일함 아래로 옮기거나, 하위 메일함까지 한꺼번에 지울 수 있습니다. +- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 함께 정리할 수 있습니다. +- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. [지금 보내기] + +## 웹메일 · 주소록과 설정 +- 다른 메일이나 휴대폰에서 내보낸 연락처 파일(vCard, CSV, 엑셀)을 한 번에 가져올 수 있습니다. [주소록] → [가져오기] +- 내 주소록을 파일로 내보낼 수 있습니다. [주소록] → [내보내기] +- 서명을 10개까지 만들고, 메일을 쓰다가 [서명]으로 바꿀 수 있습니다. +- 부재중 자동 응답을 글자 모양을 꾸며 쓸 수 있고, 회사 밖에서 온 메일에는 다르게 답하거나 답하지 않을 수 있습니다. +- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다. [설정] → [2단계 인증] → [휴대폰 바꾸기] diff --git a/backend/release-notes/README.md b/backend/release-notes/README.md new file mode 100644 index 0000000..5e8f224 --- /dev/null +++ b/backend/release-notes/README.md @@ -0,0 +1,41 @@ +# 업데이트 내역 글 + +직원이 웹메일과 휴대폰 앱의 설정에서 읽는 「업데이트 내역」의 글입니다. 글 하나가 파일 하나이고, 웹과 앱이 같은 글을 보여 줍니다. 올리는 화면은 없습니다. + +## 글을 더하는 법 + +1. 이 폴더에 파일을 하나 더합니다. 파일 이름은 글의 날짜입니다(`2026-11-18.md`). 같은 날 두 번째 글은 `2026-11-18-2.md`로 씁니다. +2. 커밋하고 배포합니다(백엔드를 다시 빌드). 웹과 앱에 함께 나옵니다. Gitea 웹 화면에서 파일을 더하거나 고쳐도 됩니다. + +고치거나 지울 때도 그 파일만 다룹니다. 목록에는 최근 글이 위에 나옵니다(날짜 차례, 같은 날은 뒤 번호가 위). + +## 파일 안 + +``` +# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다 + + +## 휴대폰 앱 +- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다. +- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. + 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다. +``` + +| 줄 | 뜻 | +| --- | --- | +| `# 제목` | 파일의 첫 줄이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다(100자까지). | +| `## 머리` | 묶음 머리입니다. 휴대폰 앱, 메일 검색처럼 어디 이야기인지 씁니다(40자까지). | +| `- 한 줄` | 앞에 점이 붙은 한 줄입니다(500자까지). 쓴 글이 그대로 나옵니다. | +| (빈칸 두 개) 글 | 앞 줄에 띄어쓰기 하나로 이어 붙습니다. 긴 줄을 파일에서 나눠 쓸 때 씁니다. | +| `` | 메모입니다. 화면에 나오지 않습니다. 줄 앞에서 시작합니다(앞에 빈칸은 있어도 되지만, 글 뒤에 붙여 쓰지는 않습니다). 여러 줄로 써도 됩니다. | +| `- `(머리 없이) | 머리 없는 묶음입니다. 고친 것 한두 줄처럼 짧은 글에 씁니다. 제목 바로 아래에 씁니다. | + +## 지킬 것 + +- 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다. 있는 날짜여야 합니다(`2026-02-30.md`는 안 됩니다). +- 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다. +- 글 하나에 줄(`- `)이 하나는 있어야 하고, 묶음 머리 아래에도 줄이 하나는 있어야 합니다. +- 굵게, 링크, 그림, 표, `###`, 하위 목록은 쓸 수 없습니다. 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 `[설정] → [휴대폰 앱]`처럼 씁니다. +- 이 폴더에는 이 안내(`README.md`)와 글 파일만 둡니다. 파일은 UTF-8로 저장합니다. +- 형식이 틀린 글은 시험(CI)이 막습니다. 미리 보려면 backend 폴더에서 `npm run test -- release-notes`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다. +- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2e8f798..62bd3e6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { FileStorageModule } from '@/modules/file-storage/file-storage.module'; import { HealthModule } from '@/modules/health/health.module'; import { MailModule } from '@/modules/mail/mail.module'; import { PushModule } from '@/modules/push/push.module'; +import { ReleaseNotesModule } from '@/modules/release-notes/release-notes.module'; import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; @@ -89,6 +90,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 AppConfigModule, // 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다 AppReleasesModule, + // 업데이트 내역 — 목록 · 글 (로그인 필요). 글은 저장소의 backend/release-notes 폴더(글 파일 하나가 글 하나), 올리는 API 는 없다 + ReleaseNotesModule, UploadModule, UserModule, ], diff --git a/backend/src/modules/release-notes/dto/release-note.dto.ts b/backend/src/modules/release-notes/dto/release-note.dto.ts new file mode 100644 index 0000000..c807072 --- /dev/null +++ b/backend/src/modules/release-notes/dto/release-note.dto.ts @@ -0,0 +1,97 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + RELEASE_NOTE_HEADING_MAX_LENGTH, + RELEASE_NOTE_ITEM_MAX_LENGTH, + RELEASE_NOTE_TITLE_MAX_LENGTH, +} from '../release-notes.constants'; +import type { + ReleaseNoteDetail, + ReleaseNoteSection, + ReleaseNoteSummary, +} from '../release-notes.types'; + +/** 목록의 한 줄 · 글 화면의 다른 글 줄 (계약 7절 `ReleaseNoteSummary`) */ +export class ReleaseNoteSummaryDto implements ReleaseNoteSummary { + @ApiProperty({ + description: + '글 id — 글 파일 이름에서 .md 를 뗀 것(`YYYY-MM-DD`, 같은 날 두 번째 글부터 `YYYY-MM-DD-2` …). 글 주소에 쓴다', + example: '2026-11-18', + }) + id!: string; + + @ApiProperty({ + description: + '글의 날짜 — 달력 날짜 글(`YYYY-MM-DD`, 파일 이름에서). 시각이 아니다 — 화면은 시간대를 바꾸지 않고 그대로 쓴다(Date 로 읽지 않는다)', + example: '2026-11-18', + }) + date!: string; + + @ApiProperty({ + description: `제목 — 글 파일의 첫 줄(# 제목), ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지`, + example: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', + }) + title!: string; +} + +/** GET /api/release-notes 의 응답 */ +export class ReleaseNotePageDto { + @ApiProperty({ + type: [ReleaseNoteSummaryDto], + description: + '최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`)', + }) + items!: ReleaseNoteSummaryDto[]; + + @ApiProperty({ type: PageInfoDto, description: 'total 은 글 수' }) + pageInfo!: PageInfoDto; +} + +/** 묶음 (계약 7절 `ReleaseNoteSection`) */ +export class ReleaseNoteSectionDto implements ReleaseNoteSection { + @ApiProperty({ + description: `묶음 머리(## 머리, ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지) — 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)`, + type: String, + nullable: true, + example: '휴대폰 앱', + }) + heading!: string | null; + + @ApiProperty({ + description: + `묶음의 줄(- 한 줄, ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지 — 하나 이상). 서버가 다듬은 글 그대로(앞뒤 빈칸 지움 · 안의 빈칸 여럿은 하나로 · 이어 쓴 줄은 띄어쓰기 하나로 · NFC). ` + + '마크다운 · HTML 로 해석하지 않고 글로만 그린다', + type: [String], + example: [ + '안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.', + ], + }) + items!: string[]; +} + +/** GET /api/release-notes/{id} 의 응답 (계약 7절 `ReleaseNote`) */ +export class ReleaseNoteDto + extends ReleaseNoteSummaryDto + implements ReleaseNoteDetail +{ + @ApiProperty({ + type: [ReleaseNoteSectionDto], + description: '묶음 — 파일에 쓴 차례, 하나 이상', + }) + sections!: ReleaseNoteSectionDto[]; + + @ApiProperty({ + description: '이전 글 — 바로 더 옛 글(목록에서 이 글 아래). 없으면 null', + type: ReleaseNoteSummaryDto, + nullable: true, + }) + previous!: ReleaseNoteSummaryDto | null; + + @ApiProperty({ + description: '다음 글 — 바로 더 새 글(목록에서 이 글 위). 없으면 null', + type: ReleaseNoteSummaryDto, + nullable: true, + }) + next!: ReleaseNoteSummaryDto | null; +} diff --git a/backend/src/modules/release-notes/release-note-format.spec.ts b/backend/src/modules/release-notes/release-note-format.spec.ts new file mode 100644 index 0000000..ee91feb --- /dev/null +++ b/backend/src/modules/release-notes/release-note-format.spec.ts @@ -0,0 +1,538 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + classifyReleaseNoteFile, + compareReleaseNotes, + describeReleaseNoteProblem, + isCalendarDate, + isReleaseNoteId, + parseReleaseNoteText, + type ReleaseNoteProblem, +} from './release-note-format'; +import { + RELEASE_NOTE_HEADING_MAX_LENGTH, + RELEASE_NOTE_ITEM_MAX_LENGTH, + RELEASE_NOTE_PROBLEMS_MAX, + RELEASE_NOTE_TITLE_MAX_LENGTH, +} from './release-notes.constants'; + +/** 줄들을 이어 글 파일의 글로 */ +const lines = (...rows: string[]) => rows.join('\n'); + +/** 맞는 글의 결과 — 틀리면 문제를 보이며 실패한다 */ +function parsed(text: string) { + const result = parseReleaseNoteText(text); + if (!result.ok) { + throw new Error( + `틀린 글로 읽었다: ${result.problems.map(describeReleaseNoteProblem).join(', ')}`, + ); + } + return result; +} + +/** 틀린 글의 문제 — 맞는 글로 읽으면 실패한다 */ +function problemsOf(text: string): readonly ReleaseNoteProblem[] { + const result = parseReleaseNoteText(text); + if (result.ok) throw new Error('맞는 글로 읽었다'); + return result.problems; +} + +const LINE_SEPARATOR = String.fromCharCode(0x2028); +const BYTE_ORDER_MARK = String.fromCharCode(0xfeff); +const BELL = String.fromCharCode(0x07); +/** 이모지 하나 — UTF-16 으로 두 칸 */ +const EMOJI = String.fromCodePoint(0x1f600); + +describe('parseReleaseNoteText — 맞는 글', () => { + it('보드 C 의 글 — 제목 · 메모 · 머리 · 줄, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙는다', () => { + const result = parsed( + lines( + '# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', + '', + '', + '## 휴대폰 앱', + '- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.', + '- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.', + ' 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.', + '', + '## 메일 검색', + '- 찾은 말에는 노란 표시가 붙습니다.', + '', + ), + ); + + expect(result).toEqual({ + ok: true, + title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', + sections: [ + { + heading: '휴대폰 앱', + items: [ + '안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.', + '앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.', + ], + }, + { heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] }, + ], + }); + }); + + it('머리 없이 바로 쓴 줄은 머리 없는 묶음(heading null) — 뒤에 머리가 오면 새 묶음', () => { + expect( + parsed( + lines( + '# 왼쪽 메뉴를 정리했습니다', + '- 아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.', + '## 주소록', + '- 한 줄', + ), + ).sections, + ).toEqual([ + { + heading: null, + items: ['아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.'], + }, + { heading: '주소록', items: ['한 줄'] }, + ]); + }); + + it('앞뒤 빈칸은 지우고 안의 빈칸 · 탭 여럿은 하나로 — 탭으로 시작한 줄도 이어 쓰기, 굵게 · 링크 꼴은 글 그대로', () => { + const result = parsed( + lines( + '# 제목 ', + '##\t머리\t ', + '- 한 줄\t\t끝 ', + '\t탭으로 이어 씀', + ' 빈칸 여럿으로 이어 씀', + '- **굵게** [링크](https://example.com) `코드`', + ), + ); + + expect(result.title).toBe('제목'); + expect(result.sections).toEqual([ + { + heading: '머리', + items: [ + '한 줄 끝 탭으로 이어 씀 빈칸 여럿으로 이어 씀', + '**굵게** [링크](https://example.com) `코드`', + ], + }, + ]); + }); + + it('빈 줄 · 메모(여러 줄 · 제목 앞 · 줄 사이)는 화면에 없다 — 빈 줄 · 메모를 지나도 이어 쓰기는 앞 - 줄에 붙는다', () => { + const result = parsed( + lines( + '', + '', + '# 제목', + ' ', + '- 첫 줄', + '', + '', + ' 이어 쓴 글', + '', + '', + '- 둘째 줄', + ), + ); + + expect(result).toEqual({ + ok: true, + title: '제목', + sections: [{ heading: null, items: ['첫 줄 이어 쓴 글', '둘째 줄'] }], + }); + }); + + it('CRLF 줄 끝 · 앞의 BOM 은 지운다', () => { + const result = parsed( + `${BYTE_ORDER_MARK}# 제목\r\n## 머리\r\n- 한 줄\r\n 이어 씀\r\n`, + ); + + expect(result.title).toBe('제목'); + expect(result.sections).toEqual([ + { heading: '머리', items: ['한 줄 이어 씀'] }, + ]); + }); + + it('NFC 로 모은다 — 풀어 쓴 한글(NFD)도 같은 글 · 같은 길이', () => { + const decomposed = '업데이트 내역'.normalize('NFD'); + expect(decomposed.length).toBeGreaterThan('업데이트 내역'.length); + + const result = parsed(lines(`# ${decomposed}`, `- ${decomposed}`)); + + expect(result.title).toBe('업데이트 내역'); + expect(result.sections[0].items).toEqual(['업데이트 내역']); + }); + + it('길이 한도 — 제목 100 · 머리 40 · 한 줄 500 까지는 맞다(UTF-16 — 이모지는 두 칸)', () => { + const title = '가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 2) + EMOJI; + const heading = EMOJI + '나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH - 2); + const item = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH); + + const result = parsed(lines(`# ${title}`, `## ${heading}`, `- ${item}`)); + + expect(result.title).toHaveLength(RELEASE_NOTE_TITLE_MAX_LENGTH); + expect(result.sections[0].heading).toHaveLength( + RELEASE_NOTE_HEADING_MAX_LENGTH, + ); + expect(result.sections[0].items[0]).toHaveLength( + RELEASE_NOTE_ITEM_MAX_LENGTH, + ); + }); + + it('- 만 쓴 줄도 이어 쓰기가 글을 채우면 맞다', () => { + expect(parsed(lines('# 제목', '-', ' 이어 쓴 글')).sections).toEqual([ + { heading: null, items: ['이어 쓴 글'] }, + ]); + }); +}); + +describe('parseReleaseNoteText — 틀린 글 (줄 번호 · 까닭)', () => { + it('첫 글 줄이 # 제목이 아니면 그 줄에 title-missing — 나머지도 읽어 함께 알린다', () => { + expect(problemsOf(lines('', '## 머리', '- 한 줄', '맨 글'))).toEqual([ + { line: 2, reason: 'title-missing' }, + { line: 4, reason: 'unknown-line' }, + ]); + }); + + it('빈 파일 · 메모뿐인 파일은 title-missing · no-items (파일 전체 — 줄 번호 없음)', () => { + for (const text of ['', '\n\n', lines('')]) { + expect(problemsOf(text)).toEqual([ + { line: null, reason: 'title-missing' }, + { line: null, reason: 'no-items' }, + ]); + } + }); + + it('# 제목을 또 쓰면 title-again, 비면 title-empty, 길면 title-too-long', () => { + expect(problemsOf(lines('# 제목', '- 한 줄', '# 또 제목'))).toEqual([ + { line: 3, reason: 'title-again' }, + ]); + expect(problemsOf(lines('# ', '- 한 줄'))).toEqual([ + { line: 1, reason: 'title-empty' }, + ]); + expect( + problemsOf( + lines( + `# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 1)}${EMOJI}`, + '- 줄', + ), + ), + ).toEqual([{ line: 1, reason: 'title-too-long' }]); + }); + + it('머리가 비면 heading-empty, 길면 heading-too-long, 아래에 줄이 없으면 heading-without-items', () => { + expect( + problemsOf( + lines( + '# 제목', + '##', + '- 줄', + `## ${'나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH + 1)}`, + '- 줄', + '## 줄 없는 머리', + '', + '## 마지막 머리', + '- 줄', + ), + ), + ).toEqual([ + { line: 2, reason: 'heading-empty' }, + { line: 4, reason: 'heading-too-long' }, + { line: 6, reason: 'heading-without-items' }, + ]); + }); + + it('줄이 비면 item-empty, 이어 붙인 뒤 500자를 넘으면 그 줄의 시작에 item-too-long', () => { + const half = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH / 2); + expect( + problemsOf( + lines('# 제목', '- ', '- 맞는 줄', `- ${half}`, ` ${half}`), + ), + ).toEqual([ + { line: 2, reason: 'item-empty' }, + // 둘을 띄어쓰기 하나로 이으면 501자 + { line: 4, reason: 'item-too-long' }, + ]); + }); + + it('글에 줄이 하나도 없으면 no-items', () => { + expect(problemsOf(lines('# 제목', ''))).toEqual([ + { line: null, reason: 'no-items' }, + ]); + }); + + it.each([ + ['맨 글', '그냥 쓴 글'], + ['###', '### 작은 머리'], + ['별표 목록', '* 별표 줄'], + ['더하기 목록', '+ 더하기 줄'], + ['번호 목록', '1. 번호 줄'], + ['표', '| 칸 | 칸 |'], + ['인용', '> 인용'], + ['가로줄', '---'], + ['한 칸 들여 쓴 줄', ' - 한 칸 들여 쓴 줄'], + ['띄어쓰기 없는 머리', '##머리'], + ['띄어쓰기 없는 줄', '-줄'], + ])('쓸 수 없는 줄(%s)은 unknown-line', (_name, row) => { + expect(problemsOf(lines('# 제목', '- 한 줄', row))).toEqual([ + { line: 3, reason: 'unknown-line' }, + ]); + }); + + it('띄어쓰기 없는 제목(#제목)은 제목이 아니다 — 첫 글 줄이면 title-missing · unknown-line', () => { + expect(problemsOf(lines('#제목', '- 한 줄'))).toEqual([ + { line: 1, reason: 'title-missing' }, + { line: 1, reason: 'unknown-line' }, + ]); + }); + + it('이어 쓰기 — 앞에 - 줄이 없으면(제목 · 머리 바로 아래) continuation-without-item, 들여 쓴 - 는 nested-list', () => { + expect( + problemsOf( + lines( + '# 제목', + ' 제목에 이어 씀', + '## 머리', + ' 머리에 이어 씀', + '- 한 줄', + ' - 하위 목록', + ), + ), + ).toEqual([ + { line: 2, reason: 'continuation-without-item' }, + { line: 4, reason: 'continuation-without-item' }, + { line: 6, reason: 'nested-list' }, + ]); + }); + + it('메모 — 닫지 않으면 시작 줄에 memo-unclosed, 닫은 뒤 같은 줄에 글이 있으면 memo-trailing, 글 안의 ', + '- 한 줄 ', + ' 뒤에 글', + ' 닫은 뒤 글', + '- 둘째 줄', + ''], items, [ + `- ${'가 \t'.repeat(300_000)}`, + ]) + .join('\n'); + + const cpuMs = startCpuTimer(); + const result = parseReleaseNoteText(text); + + // 지금 Linux 컨테이너에서 수백 ms — 줄마다 앞의 글을 다시 훑는(제곱) 길로 돌아가면 이 한도를 크게 넘는다 + expect(cpuMs()).toBeLessThan(5_000); + expect(result.ok).toBe(false); + }); +}); diff --git a/backend/src/modules/release-notes/release-note-format.ts b/backend/src/modules/release-notes/release-note-format.ts new file mode 100644 index 0000000..0f8adf9 --- /dev/null +++ b/backend/src/modules/release-notes/release-note-format.ts @@ -0,0 +1,448 @@ +import { + RELEASE_NOTE_FILE_EXTENSION, + RELEASE_NOTE_FILE_MAX_BYTES, + RELEASE_NOTE_FIRST_SEQUENCE, + RELEASE_NOTE_HEADING_MAX_LENGTH, + RELEASE_NOTE_ID_MAX_LENGTH, + RELEASE_NOTE_ID_PATTERN, + RELEASE_NOTE_ITEM_MAX_LENGTH, + RELEASE_NOTE_PROBLEMS_MAX, + RELEASE_NOTE_TITLE_MAX_LENGTH, + RELEASE_NOTES_README, +} from './release-notes.constants'; +import type { ReleaseNoteSection } from './release-notes.types'; + +/** + * 업데이트 내역 글 파일 해석 (순수 함수 — 계약 7절 "글 파일"). + * + * 파일 이름 `YYYY-MM-DD.md`(같은 날 둘째부터 `YYYY-MM-DD-2.md` …)가 글 id · 날짜이고, 파일 안은 마크다운의 아주 작은 일부(글만)다: + * 첫 글 줄 `# 제목`(꼭 — 한 번만), `## 머리`(묶음 시작), `- 한 줄`(그 묶음의 줄 — 머리 없이 쓰면 머리 없는 묶음), + * 빈칸 두 개 이상(또는 탭)으로 시작한 줄은 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙인다. 빈 줄 · ``(여러 줄도)는 어디든 — + * 메모는 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다). + * 그 밖의 줄(맨 글 · `###` · 표 · 한 칸 들여 쓴 줄 등)은 틀림. 굵게 · 링크 꼴은 해석하지 않는다(글 그대로). + * + * 틀리면 줄 번호를 단 문제 목록을 돌려준다 — 시험(CI)은 실패하고 서버는 그 글만 건너뛴다. 글 내용은 문제에 싣지 않는다(로그에 남는다). + * 입력 길이에 비례한다 — 줄을 한 번씩만 보고, 정규식은 앞부분만 보거나 한 번 훑는다 + */ + +/** 문제의 까닭 → 쓰는 사람에게 보일 말 (로그 · 시험 출력 — 글 내용은 넣지 않는다) */ +export const RELEASE_NOTE_PROBLEM_TEXTS = { + 'file-name': + '글 파일 이름이 아닙니다 — YYYY-MM-DD.md 로, 같은 날 두 번째 글부터 YYYY-MM-DD-2.md 로 씁니다', + 'not-a-file': '보통 파일이 아닙니다(폴더 · 링크)', + date: '없는 날짜입니다', + 'too-large': `파일이 너무 큽니다(${RELEASE_NOTE_FILE_MAX_BYTES / 1024}KB까지)`, + unreadable: '파일을 읽지 못했습니다', + encoding: 'UTF-8 글이 아닙니다', + 'title-missing': '첫 줄은 # 제목이어야 합니다', + 'title-again': '# 제목은 첫 줄에 한 번만 씁니다', + 'title-empty': '제목이 비었습니다', + 'title-too-long': `제목은 ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지입니다`, + 'heading-empty': '묶음 머리가 비었습니다', + 'heading-too-long': `묶음 머리는 ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지입니다`, + 'heading-without-items': '묶음 머리 아래에 줄(- )이 없습니다', + 'item-empty': '줄이 비었습니다', + 'item-too-long': `한 줄은 ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지입니다(이어 붙인 뒤)`, + 'continuation-without-item': '이어 붙일 줄(- )이 앞에 없습니다', + 'nested-list': '하위 목록은 쓸 수 없습니다', + 'unknown-line': + '쓸 수 없는 줄입니다 — # 제목, ## 머리, - 한 줄, 빈칸 두 개로 시작한 이어 쓰기, 만 씁니다', + 'memo-unclosed': '메모() 않았습니다', + 'memo-trailing': '메모를 닫은(-->) 뒤 같은 줄에 글이 있습니다', + 'memo-inside-text': + '글 안에 메모('; +/** `` · `` 도 닫힌 메모다(HTML 과 같다) — 닫는 표시를 여는 표시의 셋째 글자부터 찾는다 */ +const MEMO_END_SEARCH_OFFSET = 2; + +/** 보이지 않는 글자 — C0(탭 빼고) · DEL · C1(아래 줄 · 문단 구분 문자와 함께). 화면에 글로 나가지 않게 틀린 글로 본다 */ +const CONTROL_CHARACTER = + // eslint-disable-next-line no-control-regex + /[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/; +/** + * 줄 · 문단 구분 문자(U+2028 · U+2029) — 보이지 않는 글자와 같이 본다. + * 글자 코드로 만든다 — 그 글자가 정규식 리터럴에 그대로 들어가면 줄 끝이라 문법 오류다(이스케이프가 풀린 채 저장되면) + */ +const LINE_SEPARATORS = [0x2028, 0x2029].map((code) => + String.fromCharCode(code), +); + +/** 보이지 않는 글자가 있는가 — 한 번씩 훑는다 */ +function hasInvisibleCharacter(text: string): boolean { + return ( + CONTROL_CHARACTER.test(text) || + LINE_SEPARATORS.some((separator) => text.includes(separator)) + ); +} +/** 안의 빈칸 · 탭 여럿 — 하나로 (웹 · 앱이 같은 글을 그리게) */ +const INNER_BLANKS = /[ \t]+/g; +/** 글 앞의 BOM(U+FEFF) — 지운다 (UTF-8 풀기가 이미 지우지만 해석만 따로 부를 때도) */ +const BYTE_ORDER_MARK = String.fromCharCode(0xfeff); +const CARRIAGE_RETURN = '\r'; + +type LineKind = 'title' | 'heading' | 'item' | 'continuation' | 'other'; + +function lineKindOf(line: string): LineKind { + if (CONTINUATION_LINE.test(line)) return 'continuation'; + if (DEEPER_HEADING_LINE.test(line)) return 'other'; + if (HEADING_LINE.test(line)) return 'heading'; + if (TITLE_LINE.test(line)) return 'title'; + if (ITEM_LINE.test(line)) return 'item'; + return 'other'; +} + +/** 화면에 나갈 글로 다듬는다 — NFC · 안의 빈칸 · 탭 여럿은 하나로 · 앞뒤 빈칸 지움 */ +function tidy(text: string): string { + return text.normalize('NFC').replace(INNER_BLANKS, ' ').trim(); +} + +interface DraftItem { + readonly line: number; + readonly parts: string[]; +} + +interface DraftSection { + /** `## 머리` 의 줄 — 머리 없는 묶음은 null */ + readonly line: number | null; + readonly heading: string | null; + readonly items: DraftItem[]; +} + +/** 해석 결과 — 맞으면 제목 · 묶음(다듬은 글), 틀리면 줄 차례의 문제(앞의 RELEASE_NOTE_PROBLEMS_MAX 개) */ +export type ReleaseNoteTextParse = + | { + readonly ok: true; + readonly title: string; + readonly sections: readonly ReleaseNoteSection[]; + } + | { readonly ok: false; readonly problems: readonly ReleaseNoteProblem[] }; + +/** 모아 둔 문제 하나와 차례 키(줄 번호 — 파일 전체의 문제는 0) */ +interface KeptProblem { + readonly problem: ReleaseNoteProblem; + readonly order: number; +} + +/** + * 문제 모음 — 줄 차례(파일 전체의 문제가 먼저 · 같은 줄은 찾은 차례)로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개만 늘 지킨다. + * 다 읽은 뒤에 더하는 앞쪽 줄의 문제(1행 제목 길이 · 머리 아래 줄 없음 등)도 그 자리에 들어가고 뒤의 것이 밀려난다. + * 메모리는 상한만큼 — 쓰레기 파일이어도 일정하다. 상한이 작아(20) 자리를 뒤에서부터 훑어도 문제마다 일정한 비용이다 + */ +class ProblemList { + private readonly kept: KeptProblem[] = []; + private count = 0; + + /** 문제가 하나라도 있었나 (지킨 것 밖으로 밀려난 것까지) */ + get hasProblems(): boolean { + return this.count > 0; + } + + add(line: number | null, reason: ReleaseNoteProblemReason): void { + this.count += 1; + const order = line ?? 0; + // 같은 줄은 먼저 찾은 것이 앞 — 새 것은 차례 키가 더 작을 때만 앞으로 간다 + let index = this.kept.length; + while (index > 0 && order < this.kept[index - 1].order) index -= 1; + if (index >= RELEASE_NOTE_PROBLEMS_MAX) return; + this.kept.splice(index, 0, { problem: { line, reason }, order }); + if (this.kept.length > RELEASE_NOTE_PROBLEMS_MAX) this.kept.pop(); + } + + /** 글 조각(제목 · 머리 · 줄)에 메모 · 보이지 않는 글자가 없는지 */ + checkText(text: string, line: number): void { + if (text.includes(MEMO_START)) this.add(line, 'memo-inside-text'); + if (hasInvisibleCharacter(text)) this.add(line, 'control-character'); + } + + /** 줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개 */ + sorted(): ReleaseNoteProblem[] { + return this.kept.map((entry) => entry.problem); + } +} + +/** 한 줄씩 읽으며 제목 · 묶음 · 줄을 모은다 (다듬기 · 길이는 다 읽은 뒤) */ +class ReleaseNoteReader { + readonly problems = new ProblemList(); + readonly sections: DraftSection[] = []; + /** 제목 — 첫 글 줄이 `# ` 가 아니면 null */ + title: { readonly text: string; readonly line: number } | null = null; + /** 글 줄(빈 줄 · 메모 밖)을 하나라도 보았나 — 첫 글 줄이 제목이어야 한다 */ + private sawContent = false; + private section: DraftSection | null = null; + /** 이어 쓰기가 붙을 줄 — 머리 · 제목 줄에서 끊긴다(빈 줄 · 메모는 끊지 않는다) */ + private item: DraftItem | null = null; + /** 열린 메모의 시작 줄 */ + private memoLine: number | null = null; + + read(text: string): void { + const source = text.startsWith(BYTE_ORDER_MARK) ? text.slice(1) : text; + const lines = source.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const raw = lines[index]; + const line = raw.endsWith(CARRIAGE_RETURN) ? raw.slice(0, -1) : raw; + this.readLine(line, index + 1); + } + if (this.memoLine !== null) + this.problems.add(this.memoLine, 'memo-unclosed'); + if (!this.sawContent) this.problems.add(null, 'title-missing'); + } + + private readLine(line: string, lineNumber: number): void { + if (this.memoLine !== null) { + const end = line.indexOf(MEMO_END); + if (end < 0) return; + this.memoLine = null; + this.checkAfterMemo(line, end, lineNumber); + return; + } + const trimmed = line.trim(); + if (trimmed === '') return; + if (trimmed.startsWith(MEMO_START)) { + const start = line.indexOf(MEMO_START); + const end = line.indexOf(MEMO_END, start + MEMO_END_SEARCH_OFFSET); + if (end < 0) { + this.memoLine = lineNumber; + return; + } + this.checkAfterMemo(line, end, lineNumber); + return; + } + + const kind = lineKindOf(line); + if (!this.sawContent) { + this.sawContent = true; + if (kind === 'title') { + const text = line.slice(TITLE_MARKER_LENGTH); + this.problems.checkText(text, lineNumber); + this.title = { text, line: lineNumber }; + return; + } + this.problems.add(lineNumber, 'title-missing'); + } + + switch (kind) { + case 'title': + this.problems.add(lineNumber, 'title-again'); + this.item = null; + return; + case 'heading': { + const heading = line.slice(HEADING_MARKER_LENGTH); + this.problems.checkText(heading, lineNumber); + this.section = { line: lineNumber, heading, items: [] }; + this.sections.push(this.section); + this.item = null; + return; + } + case 'item': { + const text = line.slice(ITEM_MARKER_LENGTH); + this.problems.checkText(text, lineNumber); + if (this.section === null) { + // 머리 없이 바로 쓴 줄 — 머리 없는 묶음 (글의 맨 앞에만 생긴다) + this.section = { line: null, heading: null, items: [] }; + this.sections.push(this.section); + } + this.item = { line: lineNumber, parts: [text.trim()] }; + this.section.items.push(this.item); + return; + } + case 'continuation': + if (this.item === null) { + this.problems.add(lineNumber, 'continuation-without-item'); + } else if (ITEM_LINE.test(trimmed)) { + // 들여 쓴 `- ` — 마크다운에서는 하위 목록이지만 화면은 한 단계만 그린다 + this.problems.add(lineNumber, 'nested-list'); + } else { + this.problems.checkText(trimmed, lineNumber); + this.item.parts.push(trimmed); + } + return; + case 'other': + this.problems.add(lineNumber, 'unknown-line'); + return; + } + } + + /** 메모를 닫은 뒤 같은 줄에는 빈칸만 */ + private checkAfterMemo(line: string, end: number, lineNumber: number): void { + if (line.slice(end + MEMO_END.length).trim() !== '') { + this.problems.add(lineNumber, 'memo-trailing'); + } + } +} + +/** + * 글 파일의 글(UTF-8 을 푼 것)을 해석한다 — 앞의 BOM · 줄 끝의 CR 은 지운다. + * 맞으면 다듬은 제목 · 묶음(파일에 쓴 차례), 틀리면 줄 번호를 단 문제(줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개) + */ +export function parseReleaseNoteText(text: string): ReleaseNoteTextParse { + const reader = new ReleaseNoteReader(); + reader.read(text); + const { problems } = reader; + + let title = ''; + if (reader.title !== null) { + title = tidy(reader.title.text); + if (title === '') problems.add(reader.title.line, 'title-empty'); + if (title.length > RELEASE_NOTE_TITLE_MAX_LENGTH) { + problems.add(reader.title.line, 'title-too-long'); + } + } + + let itemCount = 0; + const sections = reader.sections.map((draft): ReleaseNoteSection => { + let heading: string | null = null; + if (draft.heading !== null && draft.line !== null) { + heading = tidy(draft.heading); + if (heading === '') problems.add(draft.line, 'heading-empty'); + if (heading.length > RELEASE_NOTE_HEADING_MAX_LENGTH) { + problems.add(draft.line, 'heading-too-long'); + } + if (draft.items.length === 0) { + problems.add(draft.line, 'heading-without-items'); + } + } + const items = draft.items.map((item) => { + const text = tidy(item.parts.join(' ')); + if (text === '') problems.add(item.line, 'item-empty'); + if (text.length > RELEASE_NOTE_ITEM_MAX_LENGTH) { + problems.add(item.line, 'item-too-long'); + } + return text; + }); + itemCount += items.length; + return { heading, items }; + }); + if (itemCount === 0) problems.add(null, 'no-items'); + + if (problems.hasProblems) { + return { ok: false, problems: problems.sorted() }; + } + return { ok: true, title, sections }; +} + +/** 문제 하나를 로그 · 시험 출력용 글로 — `12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))` (글 내용은 없다) */ +export function describeReleaseNoteProblem( + problem: ReleaseNoteProblem, +): string { + const where = problem.line === null ? '파일' : `${problem.line}행`; + return `${where} ${problem.reason}(${RELEASE_NOTE_PROBLEM_TEXTS[problem.reason]})`; +} diff --git a/backend/src/modules/release-notes/release-note-id.pipe.spec.ts b/backend/src/modules/release-notes/release-note-id.pipe.spec.ts new file mode 100644 index 0000000..2799971 --- /dev/null +++ b/backend/src/modules/release-notes/release-note-id.pipe.spec.ts @@ -0,0 +1,34 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { ReleaseNoteIdPipe } from './release-note-id.pipe'; + +describe('ReleaseNoteIdPipe — 경로의 글 id', () => { + const pipe = new ReleaseNoteIdPipe(); + + it('글 id 모양이면 그대로', () => { + for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-99']) { + expect(pipe.transform(id)).toBe(id); + } + }); + + it('모양이 틀리면 400 이 아니라 404 RES_001 — 없는 글과 같다(경로로 쓰지 않는다)', () => { + for (const value of [ + '', + 'latest', + '2026-11-18-1', + '2026-11-18.md', + '../../etc/passwd', + '..%2F2026-11-18', + 'x'.repeat(33), + 42, + undefined, + ]) { + let code: string | undefined; + try { + pipe.transform(value); + } catch (error) { + if (error instanceof BusinessException) code = error.code; + } + expect(code).toBe('RES_001'); + } + }); +}); diff --git a/backend/src/modules/release-notes/release-note-id.pipe.ts b/backend/src/modules/release-notes/release-note-id.pipe.ts new file mode 100644 index 0000000..ed87fe9 --- /dev/null +++ b/backend/src/modules/release-notes/release-note-id.pipe.ts @@ -0,0 +1,19 @@ +import { Injectable, PipeTransform } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { isReleaseNoteId } from './release-note-format'; + +/** + * 경로의 업데이트 내역 글 id (`/api/release-notes/{id}`) — `YYYY-MM-DD` · `YYYY-MM-DD-N`(N 은 2~99) · 32자까지. + * 모양이 틀린 id 도 "그런 글이 없다"와 같으므로 400 이 아니라 404 RES_001 이다(손으로 고친 주소도 화면은 "없는 글"). + * 파일 경로로 쓰지 않는다 — 서비스가 읽어 둔 목록에서 찾는다(경로 탈출 없음) + */ +@Injectable() +export class ReleaseNoteIdPipe implements PipeTransform { + transform(value: unknown): string { + if (isReleaseNoteId(value)) return value; + throw new BusinessException('RES_001', { + context: { reason: 'malformed-release-note-id' }, + }); + } +} diff --git a/backend/src/modules/release-notes/release-notes-folder.spec.ts b/backend/src/modules/release-notes/release-notes-folder.spec.ts new file mode 100644 index 0000000..d8851da --- /dev/null +++ b/backend/src/modules/release-notes/release-notes-folder.spec.ts @@ -0,0 +1,347 @@ +import { Logger } from '@nestjs/common'; +import { + mkdirSync, + mkdtempSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + utimesSync, + writeFileSync, +} from 'fs'; +import { open } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { RELEASE_NOTE_FILE_MAX_BYTES } from './release-notes.constants'; +import { + ReleaseNotesFolder, + readReleaseNoteFile, + readUpTo, +} from './release-notes-folder'; + +/** 맞는 글 하나 — 제목 · 줄 */ +const note = (title: string, item = '한 줄') => `# ${title}\n- ${item}\n`; + +describe('ReleaseNotesFolder — 글 폴더 읽기', () => { + let dir: string; + let errorLog: jest.SpyInstance; + let warnLog: jest.SpyInstance; + + const pathOf = (name: string) => join(dir, name); + const write = (name: string, text: string | Buffer) => + writeFileSync(pathOf(name), text); + /** 바뀐 시각을 옮긴다 (초) — 같은 크기로 고친 파일을 "바뀌었다"고 알리게 */ + const touch = (name: string, seconds: number) => { + const { atime, mtime } = statSync(pathOf(name)); + utimesSync( + pathOf(name), + atime, + new Date(mtime.getTime() + seconds * 1_000), + ); + }; + const loggedText = (spy: jest.SpyInstance) => + spy.mock.calls.map((call: unknown[]) => String(call[0])).join('\n'); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-')); + errorLog = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + warnLog = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('글 파일만 최근 글부터 읽는다 — 쓰는 사람용 안내(README.md)는 건너뛰고 로그도 없다', async () => { + write('README.md', '# 안내\n아무 글이나 써도 된다\n'); + write('2026-11-05.md', note('화면의 안내 글을 다듬었습니다')); + write('2026-11-18.md', note('휴대폰 앱이 나왔습니다')); + write('2026-11-18-2.md', note('같은 날 두 번째 글')); + + const snapshot = await new ReleaseNotesFolder(dir).read(); + + expect(snapshot.problems).toEqual([]); + expect(snapshot.notes).toEqual([ + { + id: '2026-11-18-2', + date: '2026-11-18', + sequence: 2, + title: '같은 날 두 번째 글', + sections: [{ heading: null, items: ['한 줄'] }], + }, + { + id: '2026-11-18', + date: '2026-11-18', + sequence: 1, + title: '휴대폰 앱이 나왔습니다', + sections: [{ heading: null, items: ['한 줄'] }], + }, + { + id: '2026-11-05', + date: '2026-11-05', + sequence: 1, + title: '화면의 안내 글을 다듬었습니다', + sections: [{ heading: null, items: ['한 줄'] }], + }, + ]); + expect(errorLog).not.toHaveBeenCalled(); + expect(warnLog).not.toHaveBeenCalled(); + }); + + it('이름 · 크기 · 바뀐 시각이 같으면 앞서 읽은 결과를 쓴다 — 하나라도 바뀌면 그 파일을 다시 읽는다', async () => { + // 바뀐 시각을 초 단위의 같은 값으로 둔다 — 파일 시스템의 ns 시각을 그대로 되돌리지는 못한다 + const fixed = new Date('2026-11-18T00:00:00.000Z'); + const folder = new ReleaseNotesFolder(dir); + write('2026-11-18.md', note('가나다')); + utimesSync(pathOf('2026-11-18.md'), fixed, fixed); + const first = await folder.read(); + + // 아무것도 바뀌지 않았다 — 앞의 결과 그대로 + expect(await folder.read()).toBe(first); + + // 같은 크기 · 같은 바뀐 시각으로 글만 고치면 알아채지 못한다 — 이름 · 크기 · 시각만 본다는 증거 + write('2026-11-18.md', note('가나라')); + utimesSync(pathOf('2026-11-18.md'), fixed, fixed); + expect((await folder.read()).notes[0].title).toBe('가나다'); + + // 바뀐 시각이 다르면 다시 읽는다 + touch('2026-11-18.md', 10); + expect((await folder.read()).notes[0].title).toBe('가나라'); + + // 새 파일 · 지운 파일 + write('2026-11-19.md', note('새 글')); + expect((await folder.read()).notes.map((item) => item.id)).toEqual([ + '2026-11-19', + '2026-11-18', + ]); + unlinkSync(pathOf('2026-11-18.md')); + expect((await folder.read()).notes.map((item) => item.id)).toEqual([ + '2026-11-19', + ]); + }); + + it('틀린 글 · 글이 아닌 파일 · 폴더는 건너뛰고 바뀔 때마다 error 로그 한 번 — 파일 이름 · 줄 번호 · 까닭만(글 내용은 없다)', async () => { + const folder = new ReleaseNotesFolder(dir); + write('2026-11-18.md', note('맞는 글')); + write('2026-11-19.md', '# 틀린 글\n비밀스러운 맨 글\n- 한 줄\n'); + write('notes.txt', '메모'); + write('2026-02-30.md', note('없는 날짜')); + mkdirSync(pathOf('2026-11-20.md')); + + const snapshot = await folder.read(); + + expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']); + expect(snapshot.problems).toEqual([ + { file: '2026-02-30.md', line: null, reason: 'date' }, + { file: '2026-11-19.md', line: 2, reason: 'unknown-line' }, + { file: '2026-11-20.md', line: null, reason: 'not-a-file' }, + { file: 'notes.txt', line: null, reason: 'file-name' }, + ]); + expect(errorLog).toHaveBeenCalledTimes(4); + const logged = loggedText(errorLog); + expect(logged).toContain('file="2026-11-19.md"'); + expect(logged).toContain('2행 unknown-line'); + expect(logged).toContain('[SYS_001]'); + expect(logged).not.toContain('비밀스러운'); + expect(logged).not.toContain('틀린 글'); + + // 그대로면 다시 남기지 않는다 + await folder.read(); + expect(errorLog).toHaveBeenCalledTimes(4); + + // 그 파일이 바뀌면 그 파일만 다시 읽고 다시 남긴다 + touch('2026-11-19.md', 10); + await folder.read(); + expect(errorLog).toHaveBeenCalledTimes(5); + + // 고치면 목록에 들어온다 + write('2026-11-19.md', note('고친 글')); + touch('2026-11-19.md', 20); + expect((await folder.read()).notes.map((item) => item.id)).toEqual([ + '2026-11-19', + '2026-11-18', + ]); + expect(errorLog).toHaveBeenCalledTimes(5); + }); + + it('UTF-8 이 아닌 글은 encoding, 너무 큰 파일은 읽지 않고 too-large', async () => { + // EUC-KR 의 "가"(0xB0 0xA1) — UTF-8 로는 틀린 바이트 + write('2026-11-18.md', Buffer.from([0x23, 0x20, 0xb0, 0xa1, 0x0a])); + write( + '2026-11-19.md', + `# 큰 글\n- ${'가'.repeat(RELEASE_NOTE_FILE_MAX_BYTES)}\n`, + ); + + const snapshot = await new ReleaseNotesFolder(dir).read(); + + expect(snapshot.notes).toEqual([]); + expect(snapshot.problems).toEqual([ + { file: '2026-11-18.md', line: null, reason: 'encoding' }, + { file: '2026-11-19.md', line: null, reason: 'too-large' }, + ]); + }); + + it('폴더가 없으면 빈 목록 + warn 한 번 — 생기면 읽고, 또 없어지면 다시 warn', async () => { + const missing = join(dir, 'release-notes'); + const folder = new ReleaseNotesFolder(missing); + + expect(await folder.read()).toEqual({ notes: [], problems: [] }); + expect(await folder.read()).toEqual({ notes: [], problems: [] }); + expect(warnLog).toHaveBeenCalledTimes(1); + expect(loggedText(warnLog)).toContain(missing); + + mkdirSync(missing); + writeFileSync(join(missing, '2026-11-18.md'), note('생긴 글')); + expect((await folder.read()).notes.map((item) => item.id)).toEqual([ + '2026-11-18', + ]); + + rmSync(missing, { recursive: true }); + expect(await folder.read()).toEqual({ notes: [], problems: [] }); + expect(warnLog).toHaveBeenCalledTimes(2); + expect(errorLog).not.toHaveBeenCalled(); + }); + + it('함께 온 요청은 한 번 읽은 결과를 나눈다 — 틀린 글의 로그도 한 번', async () => { + write('2026-11-18.md', note('맞는 글')); + write('2026-11-19.md', '맨 글\n'); + const folder = new ReleaseNotesFolder(dir); + + const [a, b, c] = await Promise.all([ + folder.read(), + folder.read(), + folder.read(), + ]); + + expect(b).toBe(a); + expect(c).toBe(a); + expect(errorLog).toHaveBeenCalledTimes(1); + }); + + it('링크로 둔 글 파일은 따라가지 않고 not-a-file (링크를 만들 수 없는 곳에서는 건너뛴다)', async () => { + const outside = mkdtempSync(join(tmpdir(), 'arasystem-release-outside-')); + try { + writeFileSync(join(outside, 'target.md'), note('폴더 밖의 글')); + if (!trySymlink(join(outside, 'target.md'), pathOf('2026-11-21.md'))) { + return; + } + write('2026-11-18.md', note('맞는 글')); + + const snapshot = await new ReleaseNotesFolder(dir).read(); + + expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']); + expect(snapshot.problems).toEqual([ + { file: '2026-11-21.md', line: null, reason: 'not-a-file' }, + ]); + expect(await readReleaseNoteFile(pathOf('2026-11-21.md'))).toEqual( + // Windows 에는 O_NOFOLLOW 가 없다 — 그곳에서 링크를 만들 수 있었으면 여기서 따라간다(Linux · CI 가 본다) + process.platform === 'win32' + ? expect.objectContaining({ ok: true }) + : { ok: false, reason: 'not-a-file' }, + ); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it('기동 때 한 번 읽어 틀린 글을 바로 로그에 남긴다 — 읽지 못해도 던지지 않는다', async () => { + write('2026-11-19.md', '맨 글\n'); + const folder = new ReleaseNotesFolder(dir); + + await folder.onApplicationBootstrap(); + expect(errorLog).toHaveBeenCalledTimes(1); + expect(loggedText(errorLog)).toContain('file="2026-11-19.md"'); + + // 요청 때는 기동 때 읽은 결과 그대로 — 다시 남기지 않는다 + await folder.read(); + expect(errorLog).toHaveBeenCalledTimes(1); + + jest.spyOn(folder, 'read').mockRejectedValue(new Error('EACCES 흉내')); + await expect(folder.onApplicationBootstrap()).resolves.toBeUndefined(); + expect(errorLog).toHaveBeenCalledTimes(2); + expect(loggedText(errorLog)).toContain('기동 때 읽지 못했습니다'); + }); +}); + +/** 링크를 만든다 — 권한이 없는 곳(Windows 의 보통 계정)에서는 false */ +function trySymlink(target: string, path: string): boolean { + try { + symlinkSync(target, path); + return true; + } catch { + return false; + } +} + +describe('readReleaseNoteFile — 한 번 열어 그 파일로만 본다', () => { + let dir: string; + const pathOf = (name: string) => join(dir, name); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'arasystem-release-file-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('일반 파일은 바이트 그대로', async () => { + writeFileSync(pathOf('2026-11-18.md'), note('글')); + + const read = await readReleaseNoteFile(pathOf('2026-11-18.md')); + + expect(read).toEqual({ ok: true, bytes: Buffer.from(note('글')) }); + }); + + it('폴더는 not-a-file, 없는 파일은 unreadable', async () => { + mkdirSync(pathOf('2026-11-19.md')); + + expect(await readReleaseNoteFile(pathOf('2026-11-19.md'))).toEqual({ + ok: false, + reason: 'not-a-file', + }); + expect(await readReleaseNoteFile(pathOf('2026-11-20.md'))).toEqual({ + ok: false, + reason: 'unreadable', + }); + }); + + it('크기 한도를 넘으면 연 파일의 크기(fstat)로 too-large — 한도 그대로는 읽는다', async () => { + writeFileSync( + pathOf('large.md'), + Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES + 1, 0x61), + ); + writeFileSync( + pathOf('limit.md'), + Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES, 0x61), + ); + + expect(await readReleaseNoteFile(pathOf('large.md'))).toEqual({ + ok: false, + reason: 'too-large', + }); + const limit = await readReleaseNoteFile(pathOf('limit.md')); + expect(limit.ok && limit.bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES); + }); + + it('readUpTo 는 정한 바이트까지만 읽는다 — 그사이 커진 파일도 끝까지 읽지 않는다', async () => { + writeFileSync( + pathOf('grown.md'), + Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES * 2, 0x61), + ); + const handle = await open(pathOf('grown.md'), 'r'); + try { + const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1); + expect(bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES + 1); + expect((await readUpTo(handle, 10)).toString()).toBe('a'.repeat(10)); + } finally { + await handle.close(); + } + }); +}); diff --git a/backend/src/modules/release-notes/release-notes-folder.ts b/backend/src/modules/release-notes/release-notes-folder.ts new file mode 100644 index 0000000..2f0fe7a --- /dev/null +++ b/backend/src/modules/release-notes/release-notes-folder.ts @@ -0,0 +1,333 @@ +import { Logger, type OnApplicationBootstrap } from '@nestjs/common'; +import { constants } from 'fs'; +import { lstat, open, readdir, type FileHandle } from 'fs/promises'; +import { join } from 'path'; + +import { traceTag } from '@/shared/logger/request-context'; +import { + classifyReleaseNoteFile, + compareReleaseNotes, + describeReleaseNoteProblem, + parseReleaseNoteText, + type ReleaseNoteProblem, + type ReleaseNoteProblemReason, +} from './release-note-format'; +import { + RELEASE_NOTE_FILE_MAX_BYTES, + RELEASE_NOTES_DIR, +} from './release-notes.constants'; +import type { ReleaseNote } from './release-notes.types'; + +/** 건너뛴 파일의 문제 하나 — 파일 이름 · 줄 번호(파일 전체면 null) · 까닭. 글 내용은 없다 */ +export interface ReleaseNoteFileProblem { + readonly file: string; + readonly line: number | null; + readonly reason: ReleaseNoteProblemReason; +} + +/** 폴더를 읽은 결과 — 글(최근 글부터)과 건너뛴 파일의 문제(파일 이름 차례) */ +export interface ReleaseNotesSnapshot { + readonly notes: readonly ReleaseNote[]; + readonly problems: readonly ReleaseNoteFileProblem[]; +} + +const EMPTY_SNAPSHOT: ReleaseNotesSnapshot = Object.freeze({ + notes: Object.freeze([]), + problems: Object.freeze([]), +}); + +/** 폴더에서 본 이름 하나 — 이름 · 보통 파일인지 · 크기 · 바뀐 시각(ms) */ +interface FolderEntry { + readonly name: string; + readonly isFile: boolean; + readonly size: number; + readonly mtimeMs: number; +} + +/** 이름 하나를 읽은 결과 — 글 · 안내(README — 건너뜀) · 문제 */ +type EntryOutcome = + | { readonly kind: 'note'; readonly note: ReleaseNote } + | { readonly kind: 'skipped' } + | { + readonly kind: 'problems'; + readonly problems: readonly ReleaseNoteProblem[]; + }; + +interface CachedEntry { + readonly entry: FolderEntry; + readonly outcome: EntryOutcome; +} + +/** UTF-8 만 — 틀린 바이트가 있으면 던진다(EUC-KR 로 저장한 글을 깨진 글로 내보내지 않게). 앞의 BOM 은 지운다 */ +const UTF8 = new TextDecoder('utf-8', { fatal: true }); + +/** + * 글 파일 열기 — 링크를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다). + * 일반 파일인지 · 크기는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0) — modules/app-releases/apk-file.ts 와 같다 + */ +const OPEN_FLAGS = + constants.O_RDONLY | + (constants.O_NOFOLLOW ?? 0) | + (constants.O_NONBLOCK ?? 0); + +function errorCodeOf(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code; +} + +function reasonOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 글 파일 하나를 읽은 결과 — 바이트, 또는 글로 읽지 않는 까닭 */ +export type ReleaseNoteFileRead = + | { readonly ok: true; readonly bytes: Buffer } + | { + readonly ok: false; + readonly reason: 'not-a-file' | 'too-large' | 'unreadable'; + }; + +/** 연 파일의 처음부터 `limit` 바이트까지만 읽는다 — 더 길어도 거기서 멈춘다(넘었는지는 부르는 쪽이 길이로 본다) */ +export async function readUpTo( + handle: FileHandle, + limit: number, +): Promise { + const buffer = Buffer.alloc(limit); + let filled = 0; + while (filled < limit) { + const { bytesRead } = await handle.read( + buffer, + filled, + limit - filled, + filled, + ); + if (bytesRead === 0) break; + filled += bytesRead; + } + return buffer.subarray(0, filled); +} + +/** 열지 못한 까닭 — 링크(ELOOP) · 폴더 · 특수 파일은 글이 아니고, 그 밖(권한 · 그사이 지워짐 등)은 읽지 못함 */ +function openFailureReason(error: unknown): 'not-a-file' | 'unreadable' { + switch (errorCodeOf(error)) { + case 'ELOOP': + case 'EISDIR': + case 'ENXIO': + return 'not-a-file'; + default: + return 'unreadable'; + } +} + +/** + * 글 파일을 한 번 열어(OPEN_FLAGS) 그 열린 파일로만 본다 — 일반 파일인지 · 크기(fstat), 그리고 크기 한도 + 1 바이트까지만 읽는다. + * 이름을 본(lstat) 뒤 같은 경로가 링크로 바뀌어도 따라가지 않고, 그사이 커진 파일도 끝까지 읽지 않는다 + */ +export async function readReleaseNoteFile( + path: string, +): Promise { + let handle: FileHandle; + try { + handle = await open(path, OPEN_FLAGS); + } catch (error) { + return { ok: false, reason: openFailureReason(error) }; + } + try { + const stats = await handle.stat(); + if (!stats.isFile()) return { ok: false, reason: 'not-a-file' }; + if (stats.size > RELEASE_NOTE_FILE_MAX_BYTES) { + return { ok: false, reason: 'too-large' }; + } + const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1); + if (bytes.length > RELEASE_NOTE_FILE_MAX_BYTES) { + return { ok: false, reason: 'too-large' }; + } + return { ok: true, bytes }; + } catch { + return { ok: false, reason: 'unreadable' }; + } finally { + await handle.close(); + } +} + +function problemsOf(reason: ReleaseNoteProblemReason): EntryOutcome { + return { kind: 'problems', problems: [{ line: null, reason }] }; +} + +function sameEntry(a: FolderEntry, b: FolderEntry): boolean { + return a.isFile === b.isFile && a.size === b.size && a.mtimeMs === b.mtimeMs; +} + +/** 읽어 둔 결과로 글 목록 · 문제를 만든다 — 캐시는 파일 이름 차례로 넣었다 */ +function snapshotOf( + cache: ReadonlyMap, +): ReleaseNotesSnapshot { + const notes: ReleaseNote[] = []; + const problems: ReleaseNoteFileProblem[] = []; + for (const [file, { outcome }] of cache) { + if (outcome.kind === 'note') notes.push(outcome.note); + if (outcome.kind === 'problems') { + for (const { line, reason } of outcome.problems) { + problems.push({ file, line, reason }); + } + } + } + notes.sort(compareReleaseNotes); + return { notes, problems }; +} + +/** + * 업데이트 내역 글 폴더 (계약 7절 "읽기") — 요청 때 폴더를 본다: 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다 + * (폴더가 통째로 그대로면 앞의 결과 그대로 — 파일 수십 개라 이름 보기 · lstat 비용은 작다). 개발 서버는 다시 띄우지 않아도 새 글이 보이고, + * 운영은 이미지 안의 폴더라 배포 때 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다. + * + * 틀린 글 · 글이 아닌 파일(다른 이름 · 폴더 · 링크)은 건너뛰고 바뀔 때마다 error 로그 한 번(파일 이름 · 줄 번호 · 까닭 — 글 내용은 싣지 않는다). + * 기동은 막지 않는다 — 기동 때 한 번 읽어 틀린 글의 로그가 배포 직후에 보인다. 폴더가 없으면 빈 목록 + warn 한 번. + * 시험(CI)은 실제 폴더를 읽어 문제가 하나도 없어야 한다(release-notes.content.spec.ts). + * 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 시험은 임시 폴더를 넘긴다 + */ +export class ReleaseNotesFolder implements OnApplicationBootstrap { + private readonly logger = new Logger(ReleaseNotesFolder.name); + private cache = new Map(); + /** 앞서 본 폴더의 모양(이름 · 크기 · 바뀐 시각) — 같으면 앞의 결과를 그대로 */ + private signature: string | null = null; + private snapshot: ReleaseNotesSnapshot = EMPTY_SNAPSHOT; + private loading: Promise | null = null; + private warnedMissing = false; + + constructor(readonly dir: string = RELEASE_NOTES_DIR) {} + + /** + * 기동 때 한 번 읽는다 — 틀린 글의 error 로그가 첫 요청이 아니라 배포 직후의 로그에 보이게. + * 읽지 못해도 던지지 않는다(기동을 막지 않는다 — 요청 때 다시 읽는다) + */ + async onApplicationBootstrap(): Promise { + try { + await this.read(); + } catch (error) { + this.logger.error( + `[SYS_001] 업데이트 내역 폴더를 기동 때 읽지 못했습니다 — 요청 때 다시 읽습니다: dir=${this.dir}, reason=${reasonOf(error)}`, + ); + } + } + + /** 지금 폴더의 글 · 문제 — 읽는 중이면 그 결과를 함께 기다린다 */ + read(): Promise { + if (this.loading !== null) return this.loading; + const loading = this.load().finally(() => { + this.loading = null; + }); + this.loading = loading; + return loading; + } + + private async load(): Promise { + const entries = await this.listEntries(); + if (entries === null) return this.folderMissing(); + this.warnedMissing = false; + + const signature = JSON.stringify(entries); + if (signature === this.signature) return this.snapshot; + + const next = new Map(); + for (const entry of entries) { + const cached = this.cache.get(entry.name); + next.set( + entry.name, + cached !== undefined && sameEntry(cached.entry, entry) + ? cached + : { entry, outcome: await this.readEntry(entry) }, + ); + } + this.cache = next; + this.signature = signature; + this.snapshot = snapshotOf(next); + return this.snapshot; + } + + /** 폴더의 이름들(이름 차례) · 크기 · 바뀐 시각 — 폴더가 없으면 null. 링크는 따라가지 않는다(lstat) */ + private async listEntries(): Promise { + let names: string[]; + try { + names = await readdir(this.dir); + } catch (error) { + const code = errorCodeOf(error); + if (code === 'ENOENT' || code === 'ENOTDIR') return null; + throw error; + } + names.sort(); + const entries: FolderEntry[] = []; + for (const name of names) { + try { + const stats = await lstat(join(this.dir, name)); + entries.push({ + name, + isFile: stats.isFile(), + size: stats.size, + mtimeMs: stats.mtimeMs, + }); + } catch (error) { + // 이름을 본 뒤 지워졌다 — 없는 것으로 + if (errorCodeOf(error) === 'ENOENT') continue; + throw error; + } + } + return entries; + } + + /** 바뀐(또는 처음 본) 이름 하나를 읽는다 — 문제가 있으면 여기서 한 번 error 로그 */ + private async readEntry(entry: FolderEntry): Promise { + const outcome = await this.readOutcome(entry); + if (outcome.kind === 'problems') { + this.logger.error( + `[SYS_001]${traceTag()} 업데이트 내역 글을 건너뜁니다 — 글 파일을 고쳐 주세요: file=${JSON.stringify(entry.name)}, problems=${outcome.problems.map(describeReleaseNoteProblem).join(' · ')}`, + ); + } + return outcome; + } + + private async readOutcome(entry: FolderEntry): Promise { + const kind = classifyReleaseNoteFile(entry.name); + if (kind.kind === 'readme') return { kind: 'skipped' }; + if (!entry.isFile) return problemsOf('not-a-file'); + if (kind.kind === 'not-a-note') return problemsOf(kind.reason); + if (entry.size > RELEASE_NOTE_FILE_MAX_BYTES) + return problemsOf('too-large'); + + // 이름을 본 뒤 링크로 바뀌었거나 커졌을 수 있다 — 연 파일로 다시 본다 + const read = await readReleaseNoteFile(join(this.dir, entry.name)); + if (!read.ok) return problemsOf(read.reason); + let text: string; + try { + text = UTF8.decode(read.bytes); + } catch { + return problemsOf('encoding'); + } + + const parsed = parseReleaseNoteText(text); + if (!parsed.ok) return { kind: 'problems', problems: parsed.problems }; + return { + kind: 'note', + note: { + id: kind.id, + date: kind.date, + sequence: kind.sequence, + title: parsed.title, + sections: parsed.sections, + }, + }; + } + + /** 폴더가 없다 — 빈 목록, warn 은 없어질 때마다 한 번 */ + private folderMissing(): ReleaseNotesSnapshot { + if (!this.warnedMissing) { + this.warnedMissing = true; + this.logger.warn( + `[SYS_001]${traceTag()} 업데이트 내역 폴더가 없어 빈 목록으로 답합니다: dir=${this.dir}`, + ); + } + this.cache = new Map(); + this.signature = null; + this.snapshot = EMPTY_SNAPSHOT; + return EMPTY_SNAPSHOT; + } +} diff --git a/backend/src/modules/release-notes/release-notes.constants.ts b/backend/src/modules/release-notes/release-notes.constants.ts new file mode 100644 index 0000000..11e1247 --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.constants.ts @@ -0,0 +1,54 @@ +import { join } from 'path'; + +/** + * 업데이트 내역 (계약 7절 "업데이트 내역") — 한도 · 형식 · 자리를 한 곳에 모은다. + * 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판이고, 글은 운영자가 저장소의 글 파일로 쓴다(올리는 API 는 없다). + * 글자 수는 앞뒤 빈칸을 지운 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다. + */ + +/** 라우트 경로 (`/api/release-notes`) */ +export const RELEASE_NOTES_ROUTE = 'release-notes'; + +/** + * 글 폴더 — 저장소의 `backend/release-notes/`(src 밖). `__dirname` 기준이라 ts(src/modules/release-notes)와 + * 빌드 산출물(dist/modules/release-notes) 양쪽에서 같은 곳이다 — 운영 이미지는 `/app/release-notes`(backend/Dockerfile 의 COPY), + * 개발 컨테이너는 backend 를 `/app` 에 바인드 마운트하므로 파일을 더하면 다시 띄우지 않아도 보인다 + */ +export const RELEASE_NOTES_DIR = join( + __dirname, + '..', + '..', + '..', + 'release-notes', +); + +/** 쓰는 사람용 안내 — 글이 아니라 건너뛴다(로그도 남기지 않는다) */ +export const RELEASE_NOTES_README = 'README.md'; + +/** 글 파일의 확장자 — 이름은 `YYYY-MM-DD.md` · 같은 날 둘째부터 `YYYY-MM-DD-2.md` … (이름 = 글 id) */ +export const RELEASE_NOTE_FILE_EXTENSION = '.md'; + +/** + * 글 id — 글 파일 이름에서 확장자를 뗀 것. 같은 날 둘째부터 `-2` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이). + * 경로의 id 도 이 모양(과 길이)만 본다 — 파일 경로로 쓰지 않고 읽어 둔 목록에서 찾는다 + */ +export const RELEASE_NOTE_ID_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})(?:-([2-9]|[1-9]\d))?$/; +/** 경로 id 의 길이 한도 — 모양을 보기 전에 본다 */ +export const RELEASE_NOTE_ID_MAX_LENGTH = 32; +/** 번호 없는 글(그날 첫 글)의 차례 번호 */ +export const RELEASE_NOTE_FIRST_SEQUENCE = 1; + +/** 길이 한도 — 제목 100자 · 묶음 머리 40자 · 한 줄 500자 (이어 붙인 뒤) */ +export const RELEASE_NOTE_TITLE_MAX_LENGTH = 100; +export const RELEASE_NOTE_HEADING_MAX_LENGTH = 40; +export const RELEASE_NOTE_ITEM_MAX_LENGTH = 500; + +/** 글 파일 하나의 크기 한도 — 넘으면 읽지 않고 건너뛴다(글은 몇 KB — 잘못 넣은 큰 파일을 막는다) */ +export const RELEASE_NOTE_FILE_MAX_BYTES = 256 * 1024; + +/** 글 하나에서 알려 주는 문제 수 — 고치고 다시 보면 나머지가 나온다 (로그 한 줄이 끝없이 길어지지 않게) */ +export const RELEASE_NOTE_PROBLEMS_MAX = 20; + +/** 목록 커서의 범위 — 거르는 값이 없는 목록 하나 (shared/utils/offset-cursor.ts) */ +export const RELEASE_NOTES_CURSOR_SCOPE = 'release-notes'; diff --git a/backend/src/modules/release-notes/release-notes.content.spec.ts b/backend/src/modules/release-notes/release-notes.content.spec.ts new file mode 100644 index 0000000..c35a7d1 --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.content.spec.ts @@ -0,0 +1,43 @@ +import { Logger } from '@nestjs/common'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { describeReleaseNoteProblem } from './release-note-format'; +import { + RELEASE_NOTES_DIR, + RELEASE_NOTES_README, +} from './release-notes.constants'; +import { ReleaseNotesFolder } from './release-notes-folder'; + +/** + * 실제 글 폴더(backend/release-notes)를 엄격하게 읽는다 — 운영자가 더한 글의 오타를 CI 가 잡는다. + * 서버는 틀린 글을 건너뛰고 뜨지만(로그만), 여기서는 틀린 글 · 글이 아닌 파일이 하나라도 있으면 실패한다. + * 실패하면 "파일 이름 줄 번호 까닭(설명)"이 보인다 — 글을 고치는 법은 그 폴더의 README.md + */ +describe('업데이트 내역 글 폴더 (backend/release-notes — 엄격하게)', () => { + beforeEach(() => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('틀린 글 · 글이 아닌 파일이 하나도 없고 글이 하나 이상 있다', async () => { + const snapshot = await new ReleaseNotesFolder(RELEASE_NOTES_DIR).read(); + + expect( + snapshot.problems.map( + (problem) => `${problem.file} ${describeReleaseNoteProblem(problem)}`, + ), + ).toEqual([]); + expect(snapshot.notes.length).toBeGreaterThan(0); + }); + + it('쓰는 사람용 안내(README.md)가 있다', () => { + expect(existsSync(join(RELEASE_NOTES_DIR, RELEASE_NOTES_README))).toBe( + true, + ); + }); +}); diff --git a/backend/src/modules/release-notes/release-notes.controller.ts b/backend/src/modules/release-notes/release-notes.controller.ts new file mode 100644 index 0000000..bc3dc6f --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.controller.ts @@ -0,0 +1,87 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiSessionErrors, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { NoStore } from '@/common/decorators/no-store.decorator'; +import { + CursorPaginationQueryDto, + type PaginatedResult, +} from '@/common/dto/pagination.dto'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { ReleaseNoteIdPipe } from './release-note-id.pipe'; +import { + RELEASE_NOTE_ID_MAX_LENGTH, + RELEASE_NOTES_ROUTE, +} from './release-notes.constants'; +import { ReleaseNotesService } from './release-notes.service'; +import type { + ReleaseNoteDetail, + ReleaseNoteSummary, +} from './release-notes.types'; +import { ReleaseNoteDto, ReleaseNotePageDto } from './dto/release-note.dto'; + +/** + * 업데이트 내역 (계약 7절) — 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판. 읽기만 있다 — + * 글은 운영자가 저장소의 `backend/release-notes/` 에 글 파일로 쓴다(올리는 API 는 없다). + * 로그인한 직원만(웹 쿠키 · 앱 Bearer — JwtAuthGuard). 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다. + * 요청 수는 기본 한도. 응답은 private, no-store — 글을 고친 배포가 바로 보이게 + */ +@ApiTags('ReleaseNotes') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors() +@Controller(RELEASE_NOTES_ROUTE) +export class ReleaseNotesController { + constructor(private readonly releaseNotes: ReleaseNotesService) {} + + @Get() + @NoStore() + @ApiOperation({ + summary: '업데이트 내역 목록 (커서 페이지네이션 · total 포함)', + description: + '최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). total 은 글 수. ' + + '커서는 위치라 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. 형식이 틀린 글 파일은 건너뛴다(목록에 없다). ' + + 'Cache-Control: private, no-store.', + }) + @ApiStandardResponse(ReleaseNotePageDto, { + description: '업데이트 내역 목록', + }) + @ApiErrorResponse( + 'VAL_001', + '잘못된 커서 · limit 범위 밖(1~100) · 모르는 쿼리', + ) + list( + @Query() query: CursorPaginationQueryDto, + ): Promise> { + return this.releaseNotes.list(query); + } + + @Get(':id') + @NoStore() + @ApiOperation({ + summary: '업데이트 내역 글 하나 (묶음 · 이전 글 · 다음 글)', + description: + 'previous 는 바로 더 옛 글(목록에서 이 글 아래), next 는 바로 더 새 글(목록에서 이 글 위) — 없으면 null. ' + + '글은 서버가 다듬은 글 그대로다 — 마크다운 · HTML 로 해석하지 않고 글로만 그린다. ' + + '없는 글 · 모양이 틀린 id 모두 404 RES_001(400 이 아니다 — 손으로 고친 주소도 "없는 글"). Cache-Control: private, no-store.', + }) + @ApiParam({ + name: 'id', + description: `글 id — 글 파일 이름(\`YYYY-MM-DD\` · 같은 날 두 번째 글부터 \`YYYY-MM-DD-2\` …, ${RELEASE_NOTE_ID_MAX_LENGTH}자까지)`, + example: '2026-11-18', + }) + @ApiStandardResponse(ReleaseNoteDto, { description: '업데이트 내역 글' }) + @ApiErrorResponse('RES_001', '없는 글 (모양이 틀린 id 포함)') + get(@Param('id', ReleaseNoteIdPipe) id: string): Promise { + return this.releaseNotes.get(id); + } +} diff --git a/backend/src/modules/release-notes/release-notes.module.ts b/backend/src/modules/release-notes/release-notes.module.ts new file mode 100644 index 0000000..8298b6a --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +import { ReleaseNotesController } from './release-notes.controller'; +import { ReleaseNotesFolder } from './release-notes-folder'; +import { ReleaseNotesService } from './release-notes.service'; + +/** + * 업데이트 내역 모듈 (계약 7절 — `GET /api/release-notes` · `/api/release-notes/{id}`) — 읽기만. + * 글은 저장소의 `backend/release-notes/`(글 파일 하나가 글 하나 — release-notes.constants.ts 의 RELEASE_NOTES_DIR). + * 폴더는 useFactory 로 만든다 — 시험은 ReleaseNotesFolder 를 임시 폴더로 바꿔 끼운다(env 를 따로 두지 않는다). + * 이 모듈은 다른 기능 모듈을 가져오지 않는다 (DB 표 없음) + */ +@Module({ + controllers: [ReleaseNotesController], + providers: [ + { + provide: ReleaseNotesFolder, + useFactory: () => new ReleaseNotesFolder(), + }, + ReleaseNotesService, + ], +}) +export class ReleaseNotesModule {} diff --git a/backend/src/modules/release-notes/release-notes.service.spec.ts b/backend/src/modules/release-notes/release-notes.service.spec.ts new file mode 100644 index 0000000..7ad2659 --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.service.spec.ts @@ -0,0 +1,186 @@ +import { Logger } from '@nestjs/common'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { encodeOffsetCursor } from '@/shared/utils/offset-cursor'; +import { ReleaseNotesFolder } from './release-notes-folder'; +import { ReleaseNotesService } from './release-notes.service'; + +/** 목록 쿼리 — 전역 파이프가 만든 것처럼(limit 기본 20) */ +function queryOf( + values: Partial> = {}, +): CursorPaginationQueryDto { + return Object.assign(new CursorPaginationQueryDto(), values); +} + +/** 던진 업무 오류의 코드 */ +async function codeOf(work: Promise): Promise { + try { + await work; + } catch (error) { + if (error instanceof BusinessException) return error.code; + throw error; + } + throw new Error('던지지 않았다'); +} + +describe('ReleaseNotesService — 목록 · 글 (실제 폴더를 읽는다)', () => { + let dir: string; + let service: ReleaseNotesService; + + const write = (name: string, text: string) => + writeFileSync(join(dir, name), text); + /** 제목 · 묶음 하나의 글 */ + const note = (title: string) => + `# ${title}\n\n## 묶음\n- ${title}의 첫 줄\n- 둘째 줄\n`; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-')); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + service = new ReleaseNotesService(new ReleaseNotesFolder(dir)); + // 파일을 만든 차례와 상관없이 날짜 · 번호로 줄 세운다 + write('2026-11-05.md', note('11월 5일 글')); + write('2026-11-18-2.md', note('18일 두 번째 글')); + write('2026-11-03.md', note('11월 3일 글')); + write('2026-11-18.md', note('18일 첫 글')); + write('2026-11-18-10.md', note('18일 열 번째 글')); + }); + + afterEach(() => { + jest.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('목록은 최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저, 칸은 id · date · title 만, total 은 글 수', async () => { + const page = await service.list(queryOf()); + + expect(page).toEqual({ + items: [ + { id: '2026-11-18-10', date: '2026-11-18', title: '18일 열 번째 글' }, + { id: '2026-11-18-2', date: '2026-11-18', title: '18일 두 번째 글' }, + { id: '2026-11-18', date: '2026-11-18', title: '18일 첫 글' }, + { id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' }, + { id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' }, + ], + pageInfo: { hasNext: false, nextCursor: null, total: 5 }, + }); + }); + + it('쪽 나누기 — nextCursor 로 끝까지 받고, 마지막 쪽은 hasNext false · nextCursor null', async () => { + const first = await service.list(queryOf({ limit: 2 })); + expect(first.items.map((item) => item.id)).toEqual([ + '2026-11-18-10', + '2026-11-18-2', + ]); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 }); + expect(typeof first.pageInfo.nextCursor).toBe('string'); + + const second = await service.list( + queryOf({ limit: 2, cursor: first.pageInfo.nextCursor ?? undefined }), + ); + expect(second.items.map((item) => item.id)).toEqual([ + '2026-11-18', + '2026-11-05', + ]); + + const last = await service.list( + queryOf({ limit: 2, cursor: second.pageInfo.nextCursor ?? undefined }), + ); + expect(last).toEqual({ + items: [{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' }], + pageInfo: { hasNext: false, nextCursor: null, total: 5 }, + }); + }); + + it('틀린 커서 · 다른 목록의 커서는 폴더를 읽기 전에 400 VAL_001', async () => { + for (const cursor of [ + 'not-a-cursor', + 'x'.repeat(300), + encodeOffsetCursor(2, 'contacts'), + ]) { + expect(await codeOf(service.list(queryOf({ cursor })))).toBe('VAL_001'); + } + }); + + it('글 — 묶음과 previous(바로 더 옛 글) · next(바로 더 새 글), 칸은 계약의 것만(차례 번호 없음)', async () => { + const detail = await service.get('2026-11-18'); + + expect(detail).toEqual({ + id: '2026-11-18', + date: '2026-11-18', + title: '18일 첫 글', + sections: [{ heading: '묶음', items: ['18일 첫 글의 첫 줄', '둘째 줄'] }], + previous: { id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' }, + next: { + id: '2026-11-18-2', + date: '2026-11-18', + title: '18일 두 번째 글', + }, + }); + expect(Object.keys(detail)).toEqual([ + 'id', + 'date', + 'title', + 'sections', + 'previous', + 'next', + ]); + }); + + it('맨 위 글은 next 가 null, 맨 아래 글은 previous 가 null', async () => { + expect(await service.get('2026-11-18-10')).toMatchObject({ + previous: { id: '2026-11-18-2' }, + next: null, + }); + expect(await service.get('2026-11-03')).toMatchObject({ + previous: null, + next: { id: '2026-11-05' }, + }); + }); + + it('없는 글은 404 RES_001', async () => { + for (const id of ['2026-11-19', '2026-11-18-3', '2026-02-30']) { + expect(await codeOf(service.get(id))).toBe('RES_001'); + } + }); + + it('틀린 글은 목록 · 글에 없다 — 앞뒤 글은 맞는 글끼리 잇는다', async () => { + write('2026-11-10.md', '# 제목만 있는 글\n'); + + const page = await service.list(queryOf()); + expect(page.items.map((item) => item.id)).not.toContain('2026-11-10'); + expect(page.pageInfo.total).toBe(5); + expect(await codeOf(service.get('2026-11-10'))).toBe('RES_001'); + expect(await service.get('2026-11-18')).toMatchObject({ + previous: { id: '2026-11-05' }, + }); + }); + + it('글 파일을 더하면 다시 띄우지 않아도 다음 요청이 본다', async () => { + expect((await service.list(queryOf())).pageInfo.total).toBe(5); + + write('2026-11-25.md', note('새 글')); + + const page = await service.list(queryOf({ limit: 1 })); + expect(page.items).toEqual([ + { id: '2026-11-25', date: '2026-11-25', title: '새 글' }, + ]); + expect(page.pageInfo.total).toBe(6); + }); + + it('폴더가 없으면 빈 목록 — 글은 404 RES_001', async () => { + const empty = new ReleaseNotesService( + new ReleaseNotesFolder(join(dir, 'none')), + ); + + expect(await empty.list(queryOf())).toEqual({ + items: [], + pageInfo: { hasNext: false, nextCursor: null, total: 0 }, + }); + expect(await codeOf(empty.get('2026-11-18'))).toBe('RES_001'); + }); +}); diff --git a/backend/src/modules/release-notes/release-notes.service.ts b/backend/src/modules/release-notes/release-notes.service.ts new file mode 100644 index 0000000..e66cb1d --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; + +import { + type CursorPaginationQueryDto, + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; +import { RELEASE_NOTES_CURSOR_SCOPE } from './release-notes.constants'; +import { ReleaseNotesFolder } from './release-notes-folder'; +import type { + ReleaseNote, + ReleaseNoteDetail, + ReleaseNoteSummary, +} from './release-notes.types'; + +/** 응답에 싣는 칸만 — 읽어 둔 글의 차례 번호는 싣지 않는다 */ +function summaryOf(note: ReleaseNote): ReleaseNoteSummary { + return { id: note.id, date: note.date, title: note.title }; +} + +/** 커서의 위치 — 없으면 0, 틀리면 400 VAL_001 (폴더를 읽기 전에 본다) */ +function cursorOffset(cursor: string | undefined): number { + if (cursor === undefined) return 0; + const reading = decodeOffsetCursor(cursor, RELEASE_NOTES_CURSOR_SCOPE); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-release-note-cursor:${reading.problem}` }, + }); + } + return reading.offset; +} + +/** + * 업데이트 내역 (계약 7절) — 글 폴더(ReleaseNotesFolder)를 읽어 목록 · 글을 준다. 올리고 고치는 길은 없다(운영자가 글 파일을 더해 배포). + * 차례는 최근 글부터(날짜 내림차순, 같은 날은 뒤 번호가 먼저) — previous 는 바로 더 옛 글(목록의 아래), next 는 바로 더 새 글(목록의 위). + * 커서는 위치(offset-cursor) — 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(주소록과 같다) + */ +@Injectable() +export class ReleaseNotesService { + constructor(private readonly folder: ReleaseNotesFolder) {} + + /** 목록 한 쪽 — total 은 글 수 */ + async list( + query: CursorPaginationQueryDto, + ): Promise> { + const start = cursorOffset(query.cursor); + const { notes } = await this.folder.read(); + const page = notes.slice(start, start + query.limit).map(summaryOf); + const next = start + page.length; + const hasNext = next < notes.length; + return toPaginatedResult(page, { + hasNext, + nextCursor: hasNext + ? encodeOffsetCursor(next, RELEASE_NOTES_CURSOR_SCOPE) + : null, + total: notes.length, + }); + } + + /** 글 하나와 앞뒤 글 — 없으면 404 RES_001 (id 의 모양은 파이프가 보았다 — 여기서는 읽어 둔 목록에서만 찾는다) */ + async get(id: string): Promise { + const { notes } = await this.folder.read(); + const index = notes.findIndex((note) => note.id === id); + if (index < 0) { + throw new BusinessException('RES_001', { + context: { reason: 'release-note-not-found', id }, + }); + } + const note = notes[index]; + return { + id: note.id, + date: note.date, + title: note.title, + sections: note.sections.map((section) => ({ + heading: section.heading, + items: [...section.items], + })), + previous: index + 1 < notes.length ? summaryOf(notes[index + 1]) : null, + next: index > 0 ? summaryOf(notes[index - 1]) : null, + }; + } +} diff --git a/backend/src/modules/release-notes/release-notes.types.ts b/backend/src/modules/release-notes/release-notes.types.ts new file mode 100644 index 0000000..f9dd08f --- /dev/null +++ b/backend/src/modules/release-notes/release-notes.types.ts @@ -0,0 +1,30 @@ +/** + * 업데이트 내역의 모양 (계약 7절) — 응답과 읽어 둔 글이 함께 쓴다. + * `date` 는 달력 날짜 글(`YYYY-MM-DD` — 파일 이름에서)이고 시각이 아니다. 화면은 시간대를 바꾸지 않고 그대로 쓴다 + */ + +/** 목록의 한 줄 · 글 화면의 다른 글 줄 */ +export interface ReleaseNoteSummary { + readonly id: string; + readonly date: string; + readonly title: string; +} + +/** 묶음 — 머리(`## 머리`, 머리 없이 쓴 묶음은 null)와 줄(`- 한 줄`, 하나 이상) */ +export interface ReleaseNoteSection { + readonly heading: string | null; + readonly items: readonly string[]; +} + +/** 글 하나 — 응답 (previous = 바로 더 옛 글, next = 바로 더 새 글 — 없으면 null) */ +export interface ReleaseNoteDetail extends ReleaseNoteSummary { + readonly sections: readonly ReleaseNoteSection[]; + readonly previous: ReleaseNoteSummary | null; + readonly next: ReleaseNoteSummary | null; +} + +/** 읽어 둔 글 — 차례 번호(`sequence` — 같은 날 둘째부터 2 …, 번호 없는 글은 1)는 줄 세우기에만 쓰고 응답에 싣지 않는다 */ +export interface ReleaseNote extends ReleaseNoteSummary { + readonly sequence: number; + readonly sections: readonly ReleaseNoteSection[]; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 09601d9..65aebbf 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -3,7 +3,14 @@ import { Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import { createHash, createHmac, randomBytes, randomUUID } from 'crypto'; -import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'fs'; import { get as httpGet, type IncomingMessage, type Server } from 'http'; import { connect } from 'net'; import { tmpdir } from 'os'; @@ -179,6 +186,7 @@ import { type PushSendOutcome, type PushSender, } from '@/modules/push/push-sender.port'; +import { ReleaseNotesFolder } from '@/modules/release-notes/release-notes-folder'; import { SystemSettingsService, type SystemSettingsPatch, @@ -2114,6 +2122,9 @@ describe('응답 계약 (e2e)', () => { // 휴대폰 앱 받기 — APK 파일은 이 시험의 임시 폴더에 (개발 업로드 볼륨에 남기지 않는다) .overrideProvider(AppReleaseStorage) .useValue(new AppReleaseStorage(join(fileDir, 'app-releases'))) + // 업데이트 내역 — 글은 이 시험의 임시 폴더에서 읽는다 (실제 backend/release-notes 의 글이 바뀌어도 시험은 그대로) + .overrideProvider(ReleaseNotesFolder) + .useValue(new ReleaseNotesFolder(join(fileDir, 'release-notes'))) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) @@ -11354,6 +11365,275 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 업데이트 내역 (계약 7절) — 글은 이 시험의 임시 폴더(위 overrideProvider 의 ReleaseNotesFolder)에 쓴다. + * 해석 규칙(형식 · 한도 · 이어 붙임 · 메모 · 날짜)은 release-note-format.spec.ts, 캐시 · 로그는 release-notes-folder.spec.ts, + * 실제 backend/release-notes 의 글은 release-notes.content.spec.ts 가 본다 — 여기서는 HTTP 형식 · 상태 코드를 본다 + */ + describe('업데이트 내역 (/api/release-notes · /api/release-notes/{id})', () => { + const ME = 'release.notes@e2e.arasystem.test'; + const LIST = '/api/release-notes'; + let notesDir = ''; + + interface NoteSummary { + id: string; + date: string; + title: string; + } + interface NotePage { + items: NoteSummary[]; + pageInfo: { + hasNext: boolean; + nextCursor: string | null; + total: number | null; + }; + } + interface NoteDetail extends NoteSummary { + sections: { heading: string | null; items: string[] }[]; + previous: NoteSummary | null; + next: NoteSummary | null; + } + + const as = (test: HttpTest) => test.set('Cookie', cookieOf(ME)); + const writeNote = (name: string, text: string) => + writeFileSync(join(notesDir, name), text); + + const TOP: NoteSummary = { + id: '2026-11-18-2', + date: '2026-11-18', + title: '같은 날 두 번째 글', + }; + const RELEASE: NoteSummary = { + id: '2026-11-18', + date: '2026-11-18', + title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', + }; + const COPY: NoteSummary = { + id: '2026-11-05', + date: '2026-11-05', + title: '화면의 안내 글을 다듬었습니다', + }; + const OLDEST: NoteSummary = { + id: '2026-11-03', + date: '2026-11-03', + title: '2단계 인증과 앱 비밀번호를 쓸 수 있습니다', + }; + + beforeAll(() => { + notesDir = join(fileDir, 'release-notes'); + mkdirSync(notesDir, { recursive: true }); + writeNote('README.md', '# 쓰는 사람용 안내\n아무 글이나\n'); + writeNote('2026-11-03.md', `# ${OLDEST.title}\n- 한 줄\n`); + writeNote( + '2026-11-18.md', + [ + `# ${RELEASE.title}`, + '', + '', + '## 휴대폰 앱', + '- 안드로이드 휴대폰 앱이 나왔습니다.', + '- 새 메일이 오면 휴대폰으로 알려 드립니다.', + ' 알림에는 새 메일이 몇 통인지만 나옵니다.', + '', + '## 메일 검색', + '- 찾은 말에는 노란 표시가 붙습니다.', + '', + ].join('\n'), + ); + writeNote('2026-11-18-2.md', `# ${TOP.title}\n- 머리 없는 줄\n`); + writeNote('2026-11-05.md', `# ${COPY.title}\n- 한 줄\n`); + // 틀린 글 · 글이 아닌 파일 — 서버는 건너뛴다(error 로그만 — 기동 · 다른 글은 그대로) + writeNote('2026-11-10.md', '# 줄이 하나도 없는 글\n'); + writeNote('notes.txt', '글 파일이 아니다'); + }); + + it('로그인하지 않으면 401 AUTH_001 (목록 · 글)', async () => { + for (const path of [LIST, `${LIST}/2026-11-18`]) { + const res = await request(server).get(path).expect(401); + expect(body(res).error?.code).toBe('AUTH_001'); + } + }); + + it('목록 200 { items: [{ id, date, title }], pageInfo } — 최근 글부터(같은 날은 뒤 번호가 먼저), total 은 글 수, 틀린 글 · 글이 아닌 파일은 없다 · private, no-store', async () => { + const res = await as(request(server).get(LIST)).expect(200); + + expect(res.headers['cache-control']).toBe('private, no-store'); + expect(body(res).success).toBe(true); + expect(dataOf(res)).toEqual({ + items: [TOP, RELEASE, COPY, OLDEST], + pageInfo: { hasNext: false, nextCursor: null, total: 4 }, + }); + }); + + it('쪽 나누기 — limit 과 nextCursor 로 끝까지, 마지막 쪽은 hasNext false · nextCursor null', async () => { + const first = dataOf( + await as(request(server).get(LIST).query({ limit: 3 })).expect(200), + ); + expect(first.items).toEqual([TOP, RELEASE, COPY]); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 }); + expect(typeof first.pageInfo.nextCursor).toBe('string'); + + const second = await as( + request(server) + .get(LIST) + .query({ limit: 3, cursor: first.pageInfo.nextCursor ?? '' }), + ).expect(200); + expect(dataOf(second)).toEqual({ + items: [OLDEST], + pageInfo: { hasNext: false, nextCursor: null, total: 4 }, + }); + }); + + it('틀린 커서 · limit 범위 밖(1~100) · 모르는 쿼리는 400 VAL_001', async () => { + for (const query of [ + { cursor: 'not-a-cursor' }, + { limit: 0 }, + { limit: 101 }, + { limit: 'many' }, + { q: '검색' }, + ]) { + const res = await as(request(server).get(LIST).query(query)).expect( + 400, + ); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('글 200 — 묶음(메모 없음 · 이어 쓴 줄은 띄어쓰기 하나로) · previous(바로 더 옛 글) · next(바로 더 새 글) · private, no-store', async () => { + const res = await as(request(server).get(`${LIST}/2026-11-18`)).expect( + 200, + ); + + expect(res.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(res)).toEqual({ + ...RELEASE, + sections: [ + { + heading: '휴대폰 앱', + items: [ + '안드로이드 휴대폰 앱이 나왔습니다.', + '새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나옵니다.', + ], + }, + { + heading: '메일 검색', + items: ['찾은 말에는 노란 표시가 붙습니다.'], + }, + ], + previous: COPY, + next: TOP, + }); + }); + + it('맨 위 글은 next null(머리 없는 묶음은 heading null), 맨 아래 글은 previous null', async () => { + const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect( + 200, + ); + expect(dataOf(top)).toEqual({ + ...TOP, + sections: [{ heading: null, items: ['머리 없는 줄'] }], + previous: RELEASE, + next: null, + }); + + const oldest = await as( + request(server).get(`${LIST}/${OLDEST.id}`), + ).expect(200); + expect(dataOf(oldest)).toMatchObject({ + previous: null, + next: COPY, + }); + }); + + it('없는 글 · 건너뛴 틀린 글 · 모양이 틀린 id 는 모두 404 RES_001 — id 를 파일 경로로 쓰지 않는다', async () => { + for (const id of [ + '2026-11-19', + '2026-11-10', + '2026-11-18-1', + '2026-11-18.md', + 'README', + 'latest', + '..%2F..%2Fpackage.json', + 'x'.repeat(40), + ]) { + const res = await as(request(server).get(`${LIST}/${id}`)).expect(404); + expect(body(res).error?.code).toBe('RES_001'); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + } + }); + + it('글 파일을 더하면 서버를 다시 띄우지 않아도 다음 요청이 본다', async () => { + const added: NoteSummary = { + id: '2026-11-25', + date: '2026-11-25', + title: '새로 더한 글', + }; + writeNote(`${added.id}.md`, `# ${added.title}\n- 한 줄\n`); + try { + const list = await as( + request(server).get(LIST).query({ limit: 1 }), + ).expect(200); + expect(dataOf(list).items).toEqual([added]); + expect(dataOf(list).pageInfo).toMatchObject({ + hasNext: true, + total: 5, + }); + + const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect( + 200, + ); + expect(dataOf(top).next).toEqual(added); + } finally { + unlinkSync(join(notesDir, `${added.id}.md`)); + } + const after = await as(request(server).get(LIST)).expect(200); + expect(dataOf(after).pageInfo.total).toBe(4); + }); + + it('앱의 Bearer 토큰으로도 받는다 (웹 쿠키와 같은 가드)', async () => { + const token = sign( + { + sub: ME, + typ: 'access', + name: '테스트 직원', + cli: 'app', + sid: randomUUID(), + }, + 600, + ); + const res = await request(server) + .get(`${LIST}/2026-11-18`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(dataOf(res).id).toBe('2026-11-18'); + }); + + it('제한된 세션(새 비밀번호를 정하기 전)은 403 AUTH_010 — 허용 목록에 넣지 않는다', async () => { + const RESTRICTED = 'release.notes.expired@e2e.arasystem.test'; + expiredPasswords.add(RESTRICTED); + try { + const login = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.41.1') + .send({ email: RESTRICTED, password: CORRECT_PASSWORD }) + .expect(200); + expect(body(login).data).toMatchObject({ + restriction: 'passwordChange', + }); + const cookie = `access_token=${cookieValue(setCookie(login, 'access_token'))}`; + for (const path of [LIST, `${LIST}/2026-11-18`]) { + const res = await request(server) + .get(path) + .set('Cookie', cookie) + .expect(403); + expect(body(res).error?.code).toBe('AUTH_010'); + } + } finally { + expiredPasswords.delete(RESTRICTED); + } + }); + }); + /** * 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절). * 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다