generated from ttipo/project-base
feat: 업데이트 내역 게시판 · 쓰기 화면 [보내기] 숨음과 목록 고르기 칸 고침 #26
@@ -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 가 쓸 수 있다.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
|
||||
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
|
||||
- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.
|
||||
알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
|
||||
|
||||
## 웹메일과 휴대폰 앱
|
||||
- 메일 검색이 빨라졌습니다. 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
|
||||
- 찾은 말에는 노란 표시가 붙습니다.
|
||||
- 메일을 읽다가 바로 다른 메일함으로 옮길 수 있습니다. [이동]
|
||||
|
||||
## 웹메일
|
||||
- 메일을 열면 같은 대화의 메일을 그 아래에서 함께 볼 수 있습니다. 목록 위 [대화로 묶기]를 켜면 목록도 대화별로 묶입니다.
|
||||
- 목록의 메일을 끌어다 왼쪽 메일함에 놓아도 옮겨집니다.
|
||||
- 메일함을 다른 메일함 아래로 옮기거나, 하위 메일함까지 한꺼번에 지울 수 있습니다.
|
||||
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 함께 정리할 수 있습니다.
|
||||
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. [지금 보내기]
|
||||
|
||||
## 웹메일 · 주소록과 설정
|
||||
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일(vCard, CSV, 엑셀)을 한 번에 가져올 수 있습니다. [주소록] → [가져오기]
|
||||
- 내 주소록을 파일로 내보낼 수 있습니다. [주소록] → [내보내기]
|
||||
- 서명을 10개까지 만들고, 메일을 쓰다가 [서명]으로 바꿀 수 있습니다.
|
||||
- 부재중 자동 응답을 글자 모양을 꾸며 쓸 수 있고, 회사 밖에서 온 메일에는 다르게 답하거나 답하지 않을 수 있습니다.
|
||||
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다. [설정] → [2단계 인증] → [휴대폰 바꾸기]
|
||||
@@ -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`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
|
||||
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, 글 안의 <!-- 는 memo-inside-text', () => {
|
||||
expect(
|
||||
problemsOf(
|
||||
lines(
|
||||
'# 제목 <!-- 제목 메모 -->',
|
||||
'- 한 줄 <!-- 줄 메모 -->',
|
||||
'<!-- 메모 --> 뒤에 글',
|
||||
'<!-- 여러 줄',
|
||||
'x --> 닫은 뒤 글',
|
||||
'- 둘째 줄',
|
||||
'<!-- 닫지 않은 메모',
|
||||
'- 메모 안이라 읽지 않는 줄',
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
{ line: 1, reason: 'memo-inside-text' },
|
||||
{ line: 2, reason: 'memo-inside-text' },
|
||||
{ line: 3, reason: 'memo-trailing' },
|
||||
{ line: 5, reason: 'memo-trailing' },
|
||||
{ line: 7, reason: 'memo-unclosed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('보이지 않는 글자(제어 문자 · 줄 끝이 아닌 CR · 줄 구분 문자)는 control-character', () => {
|
||||
expect(
|
||||
problemsOf(
|
||||
lines(
|
||||
`# 제목${BELL}`,
|
||||
`## 머리\r중간`,
|
||||
`- 한 줄${LINE_SEPARATOR}둘째`,
|
||||
'- 맞는 줄',
|
||||
` 이어 씀${BELL}`,
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
{ line: 1, reason: 'control-character' },
|
||||
{ line: 2, reason: 'control-character' },
|
||||
{ line: 3, reason: 'control-character' },
|
||||
{ line: 5, reason: 'control-character' },
|
||||
]);
|
||||
});
|
||||
|
||||
it(`문제는 줄 차례로 앞의 ${RELEASE_NOTE_PROBLEMS_MAX}개까지 — 파일 전체의 문제가 먼저`, () => {
|
||||
const many = Array.from({ length: 300 }, (_, index) => `맨 글 ${index}`);
|
||||
|
||||
const problems = problemsOf(lines('# 제목', ...many));
|
||||
|
||||
expect(problems).toHaveLength(RELEASE_NOTE_PROBLEMS_MAX);
|
||||
expect(problems[0]).toEqual({ line: null, reason: 'no-items' });
|
||||
expect(problems.slice(1)).toEqual(
|
||||
Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 1 }, (_, index) => ({
|
||||
line: index + 2,
|
||||
reason: 'unknown-line',
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('다 읽은 뒤에 찾는 앞쪽 줄의 문제(1행 제목 길이 · 3행 줄 없는 머리)도 뒤에 줄 문제가 많아 앞의 20개에 든다', () => {
|
||||
const many = Array.from({ length: 500 }, (_, index) => `맨 글 ${index}`);
|
||||
|
||||
const problems = problemsOf(
|
||||
lines(
|
||||
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH + 1)}`,
|
||||
'- 줄',
|
||||
'## 줄 없는 머리',
|
||||
...many,
|
||||
),
|
||||
);
|
||||
|
||||
expect(problems).toEqual([
|
||||
{ line: 1, reason: 'title-too-long' },
|
||||
{ line: 3, reason: 'heading-without-items' },
|
||||
...Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 2 }, (_, index) => ({
|
||||
line: index + 4,
|
||||
reason: 'unknown-line',
|
||||
})),
|
||||
]);
|
||||
});
|
||||
|
||||
it('문제를 글로 — 줄 번호 · 까닭 · 설명만 (글 내용은 없다)', () => {
|
||||
expect(
|
||||
describeReleaseNoteProblem({ line: 12, reason: 'item-too-long' }),
|
||||
).toBe('12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))');
|
||||
expect(describeReleaseNoteProblem({ line: null, reason: 'encoding' })).toBe(
|
||||
'파일 encoding(UTF-8 글이 아닙니다)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('파일 이름 · id · 날짜', () => {
|
||||
it('글 파일 — 이름이 id, 앞 10자가 날짜, 같은 날 둘째부터 번호(2~99)', () => {
|
||||
expect(classifyReleaseNoteFile('2026-11-18.md')).toEqual({
|
||||
kind: 'note',
|
||||
id: '2026-11-18',
|
||||
date: '2026-11-18',
|
||||
sequence: 1,
|
||||
});
|
||||
expect(classifyReleaseNoteFile('2026-11-18-2.md')).toEqual({
|
||||
kind: 'note',
|
||||
id: '2026-11-18-2',
|
||||
date: '2026-11-18',
|
||||
sequence: 2,
|
||||
});
|
||||
expect(classifyReleaseNoteFile('2026-11-18-99.md')).toMatchObject({
|
||||
kind: 'note',
|
||||
sequence: 99,
|
||||
});
|
||||
expect(classifyReleaseNoteFile('2024-02-29.md')).toMatchObject({
|
||||
kind: 'note',
|
||||
date: '2024-02-29',
|
||||
});
|
||||
});
|
||||
|
||||
it('쓰는 사람용 안내(README.md)는 글이 아니고 문제도 아니다', () => {
|
||||
expect(classifyReleaseNoteFile('README.md')).toEqual({ kind: 'readme' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'2026-11-18-1.md',
|
||||
'2026-11-18-0.md',
|
||||
'2026-11-18-02.md',
|
||||
'2026-11-18-100.md',
|
||||
'2026-11-18-2-3.md',
|
||||
'2026-11-18.MD',
|
||||
'2026-11-18.txt',
|
||||
'2026-11-18',
|
||||
'2026-1-18.md',
|
||||
'20261118.md',
|
||||
'readme.md',
|
||||
'notes.md',
|
||||
'.gitkeep',
|
||||
'.DS_Store',
|
||||
])('글 파일 이름이 아니다: %s', (name) => {
|
||||
expect(classifyReleaseNoteFile(name)).toEqual({
|
||||
kind: 'not-a-note',
|
||||
reason: 'file-name',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'2026-02-30.md',
|
||||
'2026-02-29.md',
|
||||
'2100-02-29.md',
|
||||
'2026-13-01.md',
|
||||
'2026-00-10.md',
|
||||
'2026-04-31.md',
|
||||
'2026-11-00.md',
|
||||
'0000-01-01.md',
|
||||
])('없는 날짜: %s', (name) => {
|
||||
expect(classifyReleaseNoteFile(name)).toEqual({
|
||||
kind: 'not-a-note',
|
||||
reason: 'date',
|
||||
});
|
||||
});
|
||||
|
||||
it('있는 날짜 — 윤년(4로 나누어지되 100은 아니고 400은 맞다)', () => {
|
||||
expect(isCalendarDate(2024, 2, 29)).toBe(true);
|
||||
expect(isCalendarDate(2000, 2, 29)).toBe(true);
|
||||
expect(isCalendarDate(1900, 2, 29)).toBe(false);
|
||||
expect(isCalendarDate(2026, 12, 31)).toBe(true);
|
||||
expect(isCalendarDate(2026, 6, 31)).toBe(false);
|
||||
});
|
||||
|
||||
it('경로 id 의 모양 — 맞는 모양만, 32자 넘거나 경로 · 확장자 · 다른 숫자 글자가 섞이면 아니다', () => {
|
||||
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-42']) {
|
||||
expect(isReleaseNoteId(id)).toBe(true);
|
||||
}
|
||||
for (const id of [
|
||||
'',
|
||||
'2026-11-18-1',
|
||||
'2026-11-18.md',
|
||||
'../2026-11-18',
|
||||
'2026-11-18/../x',
|
||||
'2026-11-18 ',
|
||||
`2026-11-18${'-2'.repeat(20)}`,
|
||||
'2026-11-18',
|
||||
'x'.repeat(33),
|
||||
]) {
|
||||
expect(isReleaseNoteId(id)).toBe(false);
|
||||
}
|
||||
for (const value of [20261118, null, undefined, ['2026-11-18']]) {
|
||||
expect(isReleaseNoteId(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('차례 — 최근 글부터(날짜 내림차순), 같은 날은 뒤 번호가 먼저', () => {
|
||||
const notes = [
|
||||
{ id: '2026-11-03', date: '2026-11-03', sequence: 1 },
|
||||
{ id: '2026-11-18', date: '2026-11-18', sequence: 1 },
|
||||
{ id: '2026-11-18-10', date: '2026-11-18', sequence: 10 },
|
||||
{ id: '2025-12-31', date: '2025-12-31', sequence: 1 },
|
||||
{ id: '2026-11-18-2', date: '2026-11-18', sequence: 2 },
|
||||
];
|
||||
|
||||
expect([...notes].sort(compareReleaseNotes).map((note) => note.id)).toEqual(
|
||||
[
|
||||
'2026-11-18-10',
|
||||
'2026-11-18-2',
|
||||
'2026-11-18',
|
||||
'2026-11-03',
|
||||
'2025-12-31',
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReleaseNoteText — 입력 길이에 비례', () => {
|
||||
it('20만 줄(이어 쓰기 · 메모 · 줄)과 빈칸이 많은 1MB 한 줄도 금방 끝난다', () => {
|
||||
const continuation = Array.from({ length: 200_000 }, () => ' 이어 씀');
|
||||
const memo = Array.from({ length: 200_000 }, () => '메모 안 글');
|
||||
const items = Array.from(
|
||||
{ length: 100_000 },
|
||||
(_, index) => `- 줄 ${index}`,
|
||||
);
|
||||
// 배열로 잇는다 — 수십만 줄을 함수 인자로 펼치면 호출 스택이 넘친다
|
||||
const text = ['# 제목', '- 첫 줄']
|
||||
.concat(continuation, ['<!-- 긴 메모'], memo, ['-->'], 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);
|
||||
});
|
||||
});
|
||||
@@ -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':
|
||||
'글 안에 메모(<!--)가 있습니다 — 메모는 글 뒤에 붙이지 않고 줄 앞에서 시작합니다',
|
||||
'control-character': '보이지 않는 글자(제어 문자)가 있습니다',
|
||||
'no-items': '글에 줄(- )이 하나도 없습니다',
|
||||
} as const;
|
||||
|
||||
export type ReleaseNoteProblemReason = keyof typeof RELEASE_NOTE_PROBLEM_TEXTS;
|
||||
|
||||
/** 글 파일의 문제 하나 — 줄 번호는 1부터, 파일 전체의 문제(이름 · 크기 · 글자 · 줄 없음 등)면 null */
|
||||
export interface ReleaseNoteProblem {
|
||||
readonly line: number | null;
|
||||
readonly reason: ReleaseNoteProblemReason;
|
||||
}
|
||||
|
||||
// ── 파일 이름 · id ──────────────────────────────────────────────────────
|
||||
|
||||
/** `YYYY-MM-DD` 의 길이 — id 의 앞부분이 날짜다 */
|
||||
const DATE_LENGTH = 'YYYY-MM-DD'.length;
|
||||
const MONTHS_PER_YEAR = 12;
|
||||
const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
const FEBRUARY = 2;
|
||||
|
||||
interface ReleaseNoteIdParts {
|
||||
readonly year: number;
|
||||
readonly month: number;
|
||||
readonly day: number;
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
/** id 를 날짜 · 차례 번호로 — 모양(길이 · 글자)이 틀리면 null. 있는 날짜인지는 보지 않는다 */
|
||||
function releaseNoteIdParts(id: string): ReleaseNoteIdParts | null {
|
||||
if (id.length > RELEASE_NOTE_ID_MAX_LENGTH) return null;
|
||||
const match = RELEASE_NOTE_ID_PATTERN.exec(id);
|
||||
if (!match) return null;
|
||||
return {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3]),
|
||||
sequence:
|
||||
match[4] === undefined ? RELEASE_NOTE_FIRST_SEQUENCE : Number(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
/** 경로의 글 id 모양인가 — 길이를 먼저 보고 정규식(앞에서 한 번 훑는다). 맞아도 그런 글이 있는지는 목록이 정한다 */
|
||||
export function isReleaseNoteId(value: unknown): value is string {
|
||||
return typeof value === 'string' && releaseNoteIdParts(value) !== null;
|
||||
}
|
||||
|
||||
function isLeapYear(year: number): boolean {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
/** 있는 날짜인가 (그레고리력 — 2026-02-30 · 2026-13-01 · 0000-01-01 은 없다) */
|
||||
export function isCalendarDate(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
): boolean {
|
||||
if (!Number.isInteger(year) || year < 1) return false;
|
||||
if (!Number.isInteger(month) || month < 1 || month > MONTHS_PER_YEAR) {
|
||||
return false;
|
||||
}
|
||||
const days =
|
||||
month === FEBRUARY && isLeapYear(year)
|
||||
? DAYS_PER_MONTH[month - 1] + 1
|
||||
: DAYS_PER_MONTH[month - 1];
|
||||
return Number.isInteger(day) && day >= 1 && day <= days;
|
||||
}
|
||||
|
||||
/** 글 폴더의 파일 이름을 가른다 — 안내(README) · 글 · 글이 아님(이름 모양 · 없는 날짜) */
|
||||
export type ReleaseNoteFileKind =
|
||||
| { readonly kind: 'readme' }
|
||||
| {
|
||||
readonly kind: 'note';
|
||||
readonly id: string;
|
||||
readonly date: string;
|
||||
readonly sequence: number;
|
||||
}
|
||||
| { readonly kind: 'not-a-note'; readonly reason: 'file-name' | 'date' };
|
||||
|
||||
export function classifyReleaseNoteFile(fileName: string): ReleaseNoteFileKind {
|
||||
if (fileName === RELEASE_NOTES_README) return { kind: 'readme' };
|
||||
if (!fileName.endsWith(RELEASE_NOTE_FILE_EXTENSION)) {
|
||||
return { kind: 'not-a-note', reason: 'file-name' };
|
||||
}
|
||||
const id = fileName.slice(0, -RELEASE_NOTE_FILE_EXTENSION.length);
|
||||
const parts = releaseNoteIdParts(id);
|
||||
if (parts === null) return { kind: 'not-a-note', reason: 'file-name' };
|
||||
if (!isCalendarDate(parts.year, parts.month, parts.day)) {
|
||||
return { kind: 'not-a-note', reason: 'date' };
|
||||
}
|
||||
return {
|
||||
kind: 'note',
|
||||
id,
|
||||
date: id.slice(0, DATE_LENGTH),
|
||||
sequence: parts.sequence,
|
||||
};
|
||||
}
|
||||
|
||||
/** 목록 차례 — 최근 글부터: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`) */
|
||||
export function compareReleaseNotes(
|
||||
a: { readonly date: string; readonly sequence: number },
|
||||
b: { readonly date: string; readonly sequence: number },
|
||||
): number {
|
||||
if (a.date !== b.date) return a.date < b.date ? 1 : -1;
|
||||
return b.sequence - a.sequence;
|
||||
}
|
||||
|
||||
// ── 파일 안 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 줄의 꼴 — 앞부분만 본다(빈칸 두 개 · 탭으로 시작하면 이어 쓰기, `###` 은 쓰지 않는 줄) */
|
||||
const CONTINUATION_LINE = /^(?:\t|[ \t]{2})/;
|
||||
const DEEPER_HEADING_LINE = /^###/;
|
||||
const HEADING_LINE = /^##(?:[ \t]|$)/;
|
||||
const TITLE_LINE = /^#(?:[ \t]|$)/;
|
||||
const ITEM_LINE = /^-(?:[ \t]|$)/;
|
||||
const HEADING_MARKER_LENGTH = '##'.length;
|
||||
const TITLE_MARKER_LENGTH = '#'.length;
|
||||
const ITEM_MARKER_LENGTH = '-'.length;
|
||||
|
||||
const MEMO_START = '<!--';
|
||||
const MEMO_END = '-->';
|
||||
/** `<!-->` · `<!--->` 도 닫힌 메모다(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]})`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (isReleaseNoteId(value)) return value;
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-release-note-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<Buffer> {
|
||||
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<ReleaseNoteFileRead> {
|
||||
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<string, CachedEntry>,
|
||||
): 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<string, CachedEntry>();
|
||||
/** 앞서 본 폴더의 모양(이름 · 크기 · 바뀐 시각) — 같으면 앞의 결과를 그대로 */
|
||||
private signature: string | null = null;
|
||||
private snapshot: ReleaseNotesSnapshot = EMPTY_SNAPSHOT;
|
||||
private loading: Promise<ReleaseNotesSnapshot> | null = null;
|
||||
private warnedMissing = false;
|
||||
|
||||
constructor(readonly dir: string = RELEASE_NOTES_DIR) {}
|
||||
|
||||
/**
|
||||
* 기동 때 한 번 읽는다 — 틀린 글의 error 로그가 첫 요청이 아니라 배포 직후의 로그에 보이게.
|
||||
* 읽지 못해도 던지지 않는다(기동을 막지 않는다 — 요청 때 다시 읽는다)
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.read();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 업데이트 내역 폴더를 기동 때 읽지 못했습니다 — 요청 때 다시 읽습니다: dir=${this.dir}, reason=${reasonOf(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 폴더의 글 · 문제 — 읽는 중이면 그 결과를 함께 기다린다 */
|
||||
read(): Promise<ReleaseNotesSnapshot> {
|
||||
if (this.loading !== null) return this.loading;
|
||||
const loading = this.load().finally(() => {
|
||||
this.loading = null;
|
||||
});
|
||||
this.loading = loading;
|
||||
return loading;
|
||||
}
|
||||
|
||||
private async load(): Promise<ReleaseNotesSnapshot> {
|
||||
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<string, CachedEntry>();
|
||||
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<FolderEntry[] | null> {
|
||||
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<EntryOutcome> {
|
||||
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<EntryOutcome> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<PaginatedResult<ReleaseNoteSummary>> {
|
||||
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<ReleaseNoteDetail> {
|
||||
return this.releaseNotes.get(id);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<Pick<CursorPaginationQueryDto, 'cursor' | 'limit'>> = {},
|
||||
): CursorPaginationQueryDto {
|
||||
return Object.assign(new CursorPaginationQueryDto(), values);
|
||||
}
|
||||
|
||||
/** 던진 업무 오류의 코드 */
|
||||
async function codeOf(work: Promise<unknown>): Promise<string> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<PaginatedResult<ReleaseNoteSummary>> {
|
||||
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<ReleaseNoteDetail> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<NotePage>(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<NotePage>(
|
||||
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<NotePage>(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<NoteDetail>(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<NoteDetail>(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<NoteDetail>(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<NotePage>(list).items).toEqual([added]);
|
||||
expect(dataOf<NotePage>(list).pageInfo).toMatchObject({
|
||||
hasNext: true,
|
||||
total: 5,
|
||||
});
|
||||
|
||||
const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect(
|
||||
200,
|
||||
);
|
||||
expect(dataOf<NoteDetail>(top).next).toEqual(added);
|
||||
} finally {
|
||||
unlinkSync(join(notesDir, `${added.id}.md`));
|
||||
}
|
||||
const after = await as(request(server).get(LIST)).expect(200);
|
||||
expect(dataOf<NotePage>(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<NoteDetail>(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 형식 · 검사 · 잠금 · 응답 꼴을 본다
|
||||
|
||||
Reference in New Issue
Block a user