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:
2026-10-11 20:34:18 +09:00
parent 7701be3795
commit 688e72bf04
25 changed files with 2043 additions and 150 deletions
+6 -35
View File
@@ -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 () => {
+529
View File
@@ -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');
});
});
+55
View File
@@ -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;
}