generated from ttipo/project-base
feat(backend): 업데이트 내역 읽기 API 와 첫 글을 더한다
직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판의 서버 몫이다. 글은 운영자가 backend/release-notes/ 에 글마다 파일 하나로 쓰고
(올리는 API · DB 표 · 새 에러 코드 없음), GET /api/release-notes(커서 목록)와 GET /api/release-notes/{id}(글 + 바로 옛 글 · 새 글)로 웹 · 앱에 똑같이 준다.
- 글 폴더는 src 밖이라 개발 컨테이너(바인드 마운트)는 다시 띄우지 않아도 새 글이 보이고, 운영 이미지는 Dockerfile 이 폴더를 함께 복사한다
- 틀린 글은 실행 때 그 글만 건너뛰고 error 로그(파일 이름 · 줄 · 까닭만 — 기동 때 한 번 읽어 배포 직후 보인다), 실제 폴더를 엄격하게 읽는 시험이 CI 에서 운영자의 오타를 잡는다
- 파일은 O_NOFOLLOW 로 한 번 열어 일반 파일 · 256KB 까지만 읽고, 모양이 틀린 id 도 없는 글과 같은 404 RES_001 이다
- 첫 글(2026-11-18.md)은 다음 배포 소식이다 — 파일 이름(날짜)은 배포 날로 고친다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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