generated from ttipo/project-base
feat(backend): 공유 다이어리 API — 초대 코드로 함께 쓰기
다이어리를 가족·지인과 함께 쓸 수 있게 한다. - diary_members 로 함께 쓰는 사람을 두고(주인 포함 최대 4명), 알림 설정을 사람별로 옮긴다 - 초대 코드(RUP-6자) 미리보기·참여, 개인↔공유 전환, 내보내기·나가기 — 내보내거나 나가면 코드를 바꾼다 - 목록·쓰기는 멤버, 수정·삭제는 쓴 사람, 다이어리 정보·삭제는 주인만 - 응답에 멤버·대상 요약·작성자를 담는다 - 파일 열람 정책 — 함께 쓰는 다이어리의 표지·기록 사진·아이 사진·멤버 프로필 사진은 멤버도 받을 수 있다 - 에러 코드 DIARY_003(코드 없음)·004(가득 참)·005(이미 참여) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,9 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import type { Response } from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
|
||||
|
||||
/**
|
||||
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
|
||||
@@ -35,7 +30,7 @@ interface ContractBody {
|
||||
const body = (res: Response): ContractBody => res.body as ContractBody;
|
||||
|
||||
/** 매 실행마다 새 회원으로 시작한다 */
|
||||
const E2E_DEVICE_KEY = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const E2E_DEVICE_KEY = uniqueDeviceKey();
|
||||
|
||||
describe('응답 계약 (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
@@ -43,34 +38,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
const auth = () => ({ Authorization: `Bearer ${accessToken}` });
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
|
||||
// main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
|
||||
await app.init();
|
||||
|
||||
// main.ts 와 동일한 전역 설정을 적용한 앱 (support/e2e-app.ts)
|
||||
app = await createE2eApp();
|
||||
// 개발용 로그인으로 테스트 회원을 만든다 (NODE_ENV=test 에서는 열려 있다)
|
||||
const login = await request(app.getHttpServer())
|
||||
.post('/api/auth/dev-sessions')
|
||||
.send({ provider: 'kakao', deviceKey: E2E_DEVICE_KEY })
|
||||
.expect(201);
|
||||
accessToken = (login.body as { data: { accessToken: string } }).data
|
||||
.accessToken;
|
||||
accessToken = await devLogin(app, E2E_DEVICE_KEY);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import type { Response } from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
|
||||
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
|
||||
|
||||
/**
|
||||
* 공유 다이어리 — 초대 코드로 참여하고, 함께 기록·댓글을 남기고, 내보내기·나가기·공유 끄기까지.
|
||||
* 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다.
|
||||
*/
|
||||
|
||||
/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */
|
||||
const TINY_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
interface Member {
|
||||
userId: string;
|
||||
nickname: string | null;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
interface DiaryBody {
|
||||
id: string;
|
||||
shared: boolean;
|
||||
ownerId: string;
|
||||
isOwner: boolean;
|
||||
inviteCode: string | null;
|
||||
mutedNotices: string[];
|
||||
members: Member[];
|
||||
targets: { id: string; name: string | null }[];
|
||||
}
|
||||
|
||||
interface Author {
|
||||
userId: string;
|
||||
nickname: string | null;
|
||||
}
|
||||
|
||||
interface Page<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
|
||||
const data = <T>(res: Response): T => (res.body as { data: T }).data;
|
||||
const errorCode = (res: Response): string | undefined =>
|
||||
(res.body as { error?: { code?: string } }).error?.code;
|
||||
|
||||
describe('공유 다이어리 (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
const tokens: Record<string, string> = {};
|
||||
const ids: Record<string, string> = {};
|
||||
const people = ['owner', 'member', 'outsider', 'third', 'fifth'] as const;
|
||||
type Person = (typeof people)[number];
|
||||
|
||||
const as = (who: Person) => ({ Authorization: `Bearer ${tokens[who]}` });
|
||||
const http = () => request(app.getHttpServer());
|
||||
|
||||
let diaryId = '';
|
||||
let inviteCode = '';
|
||||
let coverFileId = '';
|
||||
let ownerPhotoId = '';
|
||||
let petId = '';
|
||||
let petPhotoId = '';
|
||||
let postId = '';
|
||||
|
||||
const uploadPng = async (who: Person): Promise<string> => {
|
||||
const res = await http()
|
||||
.post('/api/files')
|
||||
.set(as(who))
|
||||
.attach('file', TINY_PNG, {
|
||||
filename: 'photo.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
.expect(201);
|
||||
return data<{ id: string }>(res).id;
|
||||
};
|
||||
const fileStatus = async (who: Person, fileId: string): Promise<number> =>
|
||||
(await http().get(`/api/files/${fileId}`).set(as(who))).status;
|
||||
|
||||
/**
|
||||
* 누가 빠지면 초대 코드가 바뀐다 — 빠진 사람이 예전 코드로 다시 들어오면 DIARY_003, 주인은 새 코드를 본다.
|
||||
* 뒤의 테스트를 위해 새 코드를 기억해 둔다
|
||||
*/
|
||||
const expectCodeRotated = async (leaver: Person): Promise<void> => {
|
||||
const previous = inviteCode;
|
||||
const stale = await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as(leaver))
|
||||
.send({ code: previous })
|
||||
.expect(404);
|
||||
expect(errorCode(stale)).toBe('DIARY_003');
|
||||
|
||||
const list = await http().get('/api/diaries').set(as('owner')).expect(200);
|
||||
const current = data<Page<DiaryBody>>(list).items.find(
|
||||
(d) => d.id === diaryId,
|
||||
)?.inviteCode;
|
||||
expect(current).toMatch(/^RUP-[A-Z2-9]{6}$/);
|
||||
expect(current).not.toBe(previous);
|
||||
inviteCode = current ?? '';
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createE2eApp();
|
||||
for (const who of people) {
|
||||
tokens[who] = await devLogin(app, uniqueDeviceKey(who));
|
||||
const me = await http()
|
||||
.patch('/api/users/me')
|
||||
.set(as(who))
|
||||
.send({ nickname: who })
|
||||
.expect(200);
|
||||
ids[who] = data<{ id: string }>(me).id;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const who of people) {
|
||||
await http().delete('/api/users/me').set(as(who));
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('주인이 공유 다이어리를 만들면 초대 코드가 생긴다', async () => {
|
||||
coverFileId = await uploadPng('owner');
|
||||
ownerPhotoId = await uploadPng('owner');
|
||||
petPhotoId = await uploadPng('owner');
|
||||
await http()
|
||||
.patch('/api/users/me')
|
||||
.set(as('owner'))
|
||||
.send({ photoFileId: ownerPhotoId })
|
||||
.expect(200);
|
||||
const pet = await http()
|
||||
.post('/api/pets')
|
||||
.set(as('owner'))
|
||||
.send({
|
||||
tint: '#FF9B6A',
|
||||
name: '콩이',
|
||||
species: '강아지',
|
||||
breed: '',
|
||||
gender: 'male',
|
||||
neutered: true,
|
||||
birthDate: '2021-09-14',
|
||||
weight: 3.2,
|
||||
weightUnit: 'kg',
|
||||
allergies: [],
|
||||
conditions: [],
|
||||
watchConditions: [],
|
||||
photoFileId: petPhotoId,
|
||||
})
|
||||
.expect(201);
|
||||
petId = data<{ id: string }>(pet).id;
|
||||
|
||||
const res = await http()
|
||||
.post('/api/diaries')
|
||||
.set(as('owner'))
|
||||
.send({
|
||||
name: '함께 쓰는 다이어리',
|
||||
targetIds: ['self', petId],
|
||||
ribbon: '#FF7500',
|
||||
tint: '#FF8A5B',
|
||||
coverFileId,
|
||||
shared: true,
|
||||
})
|
||||
.expect(201);
|
||||
const diary = data<DiaryBody>(res);
|
||||
diaryId = diary.id;
|
||||
inviteCode = diary.inviteCode ?? '';
|
||||
|
||||
expect(inviteCode).toMatch(/^RUP-[A-Z2-9]{6}$/);
|
||||
expect(diary.shared).toBe(true);
|
||||
expect(diary.isOwner).toBe(true);
|
||||
expect(diary.ownerId).toBe(ids.owner);
|
||||
expect(diary.members).toEqual([
|
||||
expect.objectContaining({ userId: ids.owner, isOwner: true }),
|
||||
]);
|
||||
expect(diary.targets).toEqual([
|
||||
{
|
||||
id: 'self',
|
||||
name: 'owner',
|
||||
photoFileId: ownerPhotoId,
|
||||
tint: null,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: petId,
|
||||
name: '콩이',
|
||||
photoFileId: petPhotoId,
|
||||
tint: '#FF9B6A',
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('코드를 받은 사람은 참여 전에 미리 본다 (대소문자 무관)', async () => {
|
||||
const res = await http()
|
||||
.get(`/api/diary-invitations/${inviteCode.toLowerCase()}`)
|
||||
.set(as('member'))
|
||||
.expect(200);
|
||||
|
||||
expect(data(res)).toEqual({
|
||||
diaryId,
|
||||
name: '함께 쓰는 다이어리',
|
||||
ownerNickname: 'owner',
|
||||
memberCount: 1,
|
||||
maxMembers: 4,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('참여하면 목록에 보이고, 주인이 아니므로 초대 코드는 없다', async () => {
|
||||
const joined = await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as('member'))
|
||||
.send({ code: inviteCode })
|
||||
.expect(201);
|
||||
expect(data<DiaryBody>(joined).isOwner).toBe(false);
|
||||
|
||||
const list = await http().get('/api/diaries').set(as('member')).expect(200);
|
||||
const diary = data<Page<DiaryBody>>(list).items.find(
|
||||
(d) => d.id === diaryId,
|
||||
);
|
||||
|
||||
expect(diary?.isOwner).toBe(false);
|
||||
expect(diary?.inviteCode).toBeNull();
|
||||
expect(diary?.members.map((m) => m.userId)).toEqual([
|
||||
ids.owner,
|
||||
ids.member,
|
||||
]);
|
||||
});
|
||||
|
||||
it('이미 함께하고 있으면 DIARY_005 (주인 포함)', async () => {
|
||||
for (const who of ['member', 'owner'] as const) {
|
||||
const res = await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as(who))
|
||||
.send({ code: inviteCode })
|
||||
.expect(409);
|
||||
expect(errorCode(res)).toBe('DIARY_005');
|
||||
}
|
||||
});
|
||||
|
||||
it('주인 포함 4명이 차면 DIARY_004', async () => {
|
||||
for (const who of ['outsider', 'third'] as const) {
|
||||
await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as(who))
|
||||
.send({ code: inviteCode })
|
||||
.expect(201);
|
||||
}
|
||||
const full = await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as('fifth'))
|
||||
.send({ code: inviteCode })
|
||||
.expect(409);
|
||||
expect(errorCode(full)).toBe('DIARY_004');
|
||||
|
||||
// 뒤의 테스트를 위해 둘은 다시 나간다 — outsider 는 바깥 사람으로 쓴다
|
||||
for (const who of ['outsider', 'third'] as const) {
|
||||
await http()
|
||||
.delete(`/api/diaries/${diaryId}/membership`)
|
||||
.set(as(who))
|
||||
.expect(204);
|
||||
await expectCodeRotated(who);
|
||||
}
|
||||
});
|
||||
|
||||
it('함께하는 사람이 남긴 기록·댓글을 주인이 쓴 사람과 함께 본다', async () => {
|
||||
const post = await http()
|
||||
.post(`/api/diaries/${diaryId}/posts`)
|
||||
.set(as('member'))
|
||||
.send({ date: '2026-10-11', text: '오늘 산책했어요' })
|
||||
.expect(201);
|
||||
postId = data<{ id: string }>(post).id;
|
||||
expect(data<{ author: Author }>(post).author).toEqual({
|
||||
userId: ids.member,
|
||||
nickname: 'member',
|
||||
photoFileId: null,
|
||||
});
|
||||
|
||||
await http()
|
||||
.post(`/api/diary-posts/${postId}/comments`)
|
||||
.set(as('member'))
|
||||
.send({ text: '귀여워' })
|
||||
.expect(201);
|
||||
|
||||
const posts = await http()
|
||||
.get('/api/diary-posts')
|
||||
.set(as('owner'))
|
||||
.expect(200);
|
||||
const seen = data<Page<{ id: string; author: Author }>>(posts).items.find(
|
||||
(p) => p.id === postId,
|
||||
);
|
||||
expect(seen?.author.nickname).toBe('member');
|
||||
|
||||
const comments = await http()
|
||||
.get('/api/diary-comments')
|
||||
.set(as('owner'))
|
||||
.expect(200);
|
||||
expect(
|
||||
data<Page<{ postId: string; author: Author }>>(comments).items,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
postId,
|
||||
author: expect.objectContaining({ nickname: 'member' }) as Author,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('함께하는 사람은 표지 사진을 받고, 바깥 사람은 RES_001', async () => {
|
||||
const ok = await http()
|
||||
.get(`/api/files/${coverFileId}`)
|
||||
.set(as('member'))
|
||||
.expect(200);
|
||||
expect(ok.headers['content-type']).toContain('image/png');
|
||||
|
||||
const denied = await http()
|
||||
.get(`/api/files/${coverFileId}`)
|
||||
.set(as('outsider'))
|
||||
.expect(404);
|
||||
expect(errorCode(denied)).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('다이어리에서 보이는 사진(프로필·대상 아이·기록 사진)은 함께하는 사람끼리만 받는다', async () => {
|
||||
const memberPhotoId = await uploadPng('member');
|
||||
const postPhotoId = await uploadPng('member');
|
||||
const unrelatedId = await uploadPng('owner');
|
||||
await http()
|
||||
.patch('/api/users/me')
|
||||
.set(as('member'))
|
||||
.send({ photoFileId: memberPhotoId })
|
||||
.expect(200);
|
||||
await http()
|
||||
.post(`/api/diaries/${diaryId}/posts`)
|
||||
.set(as('member'))
|
||||
.send({ date: '2026-10-10', text: '사진도', photoFileId: postPhotoId })
|
||||
.expect(201);
|
||||
|
||||
// 함께하는 사람: 주인 프로필·대상 아이 사진을 받는다
|
||||
expect(await fileStatus('member', ownerPhotoId)).toBe(200);
|
||||
expect(await fileStatus('member', petPhotoId)).toBe(200);
|
||||
// 주인: 함께하는 사람의 프로필·기록 사진을 받는다
|
||||
expect(await fileStatus('owner', memberPhotoId)).toBe(200);
|
||||
expect(await fileStatus('owner', postPhotoId)).toBe(200);
|
||||
// 다이어리에 보이지 않는 주인의 다른 파일은 받지 못한다
|
||||
expect(await fileStatus('member', unrelatedId)).toBe(404);
|
||||
// 바깥 사람은 어느 것도 받지 못한다
|
||||
for (const id of [ownerPhotoId, petPhotoId, memberPhotoId, postPhotoId]) {
|
||||
expect(await fileStatus('outsider', id)).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('바깥 사람에게는 다이어리가 없는 것과 같다 (RES_001)', async () => {
|
||||
const res = await http()
|
||||
.post(`/api/diaries/${diaryId}/posts`)
|
||||
.set(as('outsider'))
|
||||
.send({ date: '2026-10-11', text: '몰래' })
|
||||
.expect(404);
|
||||
expect(errorCode(res)).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('주인만 할 수 있는 일은 함께하는 사람에게 AUTH_003', async () => {
|
||||
const patch = await http()
|
||||
.patch(`/api/diaries/${diaryId}`)
|
||||
.set(as('member'))
|
||||
.send({ name: '바꿔 볼까' })
|
||||
.expect(403);
|
||||
expect(errorCode(patch)).toBe('AUTH_003');
|
||||
|
||||
const sharing = await http()
|
||||
.put(`/api/diaries/${diaryId}/sharing`)
|
||||
.set(as('member'))
|
||||
.send({ shared: false })
|
||||
.expect(403);
|
||||
expect(errorCode(sharing)).toBe('AUTH_003');
|
||||
});
|
||||
|
||||
it('알림 설정은 사람마다 따로다', async () => {
|
||||
await http()
|
||||
.put(`/api/diaries/${diaryId}/muted-notices`)
|
||||
.set(as('member'))
|
||||
.send({ mutedNotices: ['reminder'] })
|
||||
.expect(204);
|
||||
|
||||
const mine = await http().get('/api/diaries').set(as('member')).expect(200);
|
||||
const owners = await http()
|
||||
.get('/api/diaries')
|
||||
.set(as('owner'))
|
||||
.expect(200);
|
||||
const pick = (res: Response) =>
|
||||
data<Page<DiaryBody>>(res).items.find((d) => d.id === diaryId);
|
||||
|
||||
expect(pick(mine)?.mutedNotices).toEqual(['reminder']);
|
||||
expect(pick(owners)?.mutedNotices).toEqual([]);
|
||||
});
|
||||
|
||||
it('주인은 나갈 수도, 자신을 내보낼 수도 없다 (BIZ_001)', async () => {
|
||||
const leave = await http()
|
||||
.delete(`/api/diaries/${diaryId}/membership`)
|
||||
.set(as('owner'))
|
||||
.expect(400);
|
||||
expect(errorCode(leave)).toBe('BIZ_001');
|
||||
|
||||
const kickSelf = await http()
|
||||
.delete(`/api/diaries/${diaryId}/members/${ids.owner}`)
|
||||
.set(as('owner'))
|
||||
.expect(400);
|
||||
expect(errorCode(kickSelf)).toBe('BIZ_001');
|
||||
});
|
||||
|
||||
it('내보내진 사람은 다이어리에 닿지 못하고 예전 코드로 돌아오지 못한다 (기록은 남는다)', async () => {
|
||||
await http()
|
||||
.delete(`/api/diaries/${diaryId}/members/${ids.member}`)
|
||||
.set(as('owner'))
|
||||
.expect(204);
|
||||
await expectCodeRotated('member');
|
||||
|
||||
const write = await http()
|
||||
.post(`/api/diaries/${diaryId}/posts`)
|
||||
.set(as('member'))
|
||||
.send({ date: '2026-10-12', text: '다시' })
|
||||
.expect(404);
|
||||
expect(errorCode(write)).toBe('RES_001');
|
||||
|
||||
const edit = await http()
|
||||
.patch(`/api/diary-posts/${postId}`)
|
||||
.set(as('member'))
|
||||
.send({ text: '고칠래' })
|
||||
.expect(404);
|
||||
expect(errorCode(edit)).toBe('RES_001');
|
||||
|
||||
const cover = await http()
|
||||
.get(`/api/files/${coverFileId}`)
|
||||
.set(as('member'))
|
||||
.expect(404);
|
||||
expect(errorCode(cover)).toBe('RES_001');
|
||||
|
||||
const posts = await http()
|
||||
.get('/api/diary-posts')
|
||||
.set(as('owner'))
|
||||
.expect(200);
|
||||
expect(data<Page<{ id: string }>>(posts).items.map((p) => p.id)).toContain(
|
||||
postId,
|
||||
);
|
||||
|
||||
// 이미 빠진 사람을 다시 내보내면 RES_001 — 실패했으므로 코드도 그대로다
|
||||
const again = await http()
|
||||
.delete(`/api/diaries/${diaryId}/members/${ids.member}`)
|
||||
.set(as('owner'))
|
||||
.expect(404);
|
||||
expect(errorCode(again)).toBe('RES_001');
|
||||
await http()
|
||||
.get(`/api/diary-invitations/${inviteCode}`)
|
||||
.set(as('member'))
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('공유를 끄면 주인 말고는 모두 빠지고 예전 코드는 쓸 수 없다', async () => {
|
||||
await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as('member'))
|
||||
.send({ code: inviteCode })
|
||||
.expect(201);
|
||||
|
||||
const off = await http()
|
||||
.put(`/api/diaries/${diaryId}/sharing`)
|
||||
.set(as('owner'))
|
||||
.send({ shared: false })
|
||||
.expect(200);
|
||||
const diary = data<DiaryBody>(off);
|
||||
expect(diary.shared).toBe(false);
|
||||
expect(diary.inviteCode).toBeNull();
|
||||
expect(diary.members.map((m) => m.userId)).toEqual([ids.owner]);
|
||||
|
||||
const list = await http().get('/api/diaries').set(as('member')).expect(200);
|
||||
expect(data<Page<DiaryBody>>(list).items.map((d) => d.id)).not.toContain(
|
||||
diaryId,
|
||||
);
|
||||
|
||||
const preview = await http()
|
||||
.get(`/api/diary-invitations/${inviteCode}`)
|
||||
.set(as('member'))
|
||||
.expect(404);
|
||||
expect(errorCode(preview)).toBe('DIARY_003');
|
||||
});
|
||||
|
||||
it('다시 켜면 새 코드가 생기고, 참여한 사람은 스스로 나갈 수 있다', async () => {
|
||||
const on = await http()
|
||||
.put(`/api/diaries/${diaryId}/sharing`)
|
||||
.set(as('owner'))
|
||||
.send({ shared: true })
|
||||
.expect(200);
|
||||
const fresh = data<DiaryBody>(on).inviteCode ?? '';
|
||||
expect(fresh).toMatch(/^RUP-[A-Z2-9]{6}$/);
|
||||
expect(fresh).not.toBe(inviteCode);
|
||||
inviteCode = fresh;
|
||||
|
||||
await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as('member'))
|
||||
.send({ code: fresh })
|
||||
.expect(201);
|
||||
await http()
|
||||
.delete(`/api/diaries/${diaryId}/membership`)
|
||||
.set(as('member'))
|
||||
.expect(204);
|
||||
await expectCodeRotated('member');
|
||||
|
||||
const list = await http().get('/api/diaries').set(as('member')).expect(200);
|
||||
expect(data<Page<DiaryBody>>(list).items.map((d) => d.id)).not.toContain(
|
||||
diaryId,
|
||||
);
|
||||
});
|
||||
|
||||
it('없는 코드는 DIARY_003', async () => {
|
||||
const preview = await http()
|
||||
.get('/api/diary-invitations/RUP-ZZZZZZ')
|
||||
.set(as('member'))
|
||||
.expect(404);
|
||||
expect(errorCode(preview)).toBe('DIARY_003');
|
||||
|
||||
const join = await http()
|
||||
.post('/api/diary-memberships')
|
||||
.set(as('member'))
|
||||
.send({ code: '엉뚱한 코드' })
|
||||
.expect(404);
|
||||
expect(errorCode(join)).toBe('DIARY_003');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
|
||||
/**
|
||||
* e2e 용 앱 — main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다.
|
||||
*
|
||||
* ⚠️ DB 연결이 필요하다 (AppModule 이 TypeORM 을 초기화한다).
|
||||
* 로컬: docker 개발 스택을 띄운 뒤 실행. CI: postgres 서비스 컨테이너 사용.
|
||||
*/
|
||||
export async function createE2eApp(): Promise<INestApplication<App>> {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<INestApplication<App>>();
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
|
||||
/** 매 실행마다 새 회원으로 시작한다 — [label] 은 한 테스트에서 회원을 여럿 만들 때 구분용 */
|
||||
export function uniqueDeviceKey(label = 'user'): string {
|
||||
return `e2e-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/** 개발용 로그인으로 회원을 만들고 액세스 토큰을 받는다 (NODE_ENV=test 에서는 열려 있다) */
|
||||
export async function devLogin(
|
||||
app: INestApplication<App>,
|
||||
deviceKey: string,
|
||||
): Promise<string> {
|
||||
const login = await request(app.getHttpServer())
|
||||
.post('/api/auth/dev-sessions')
|
||||
.send({ provider: 'kakao', deviceKey })
|
||||
.expect(201);
|
||||
return (login.body as { data: { accessToken: string } }).data.accessToken;
|
||||
}
|
||||
Reference in New Issue
Block a user