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 { items: T[]; } /** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */ const data = (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; const tokens: Record = {}; const ids: Record = {}; 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 => { 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 => (await http().get(`/api/files/${fileId}`).set(as(who))).status; /** * 누가 빠지면 초대 코드가 바뀐다 — 빠진 사람이 예전 코드로 다시 들어오면 DIARY_003, 주인은 새 코드를 본다. * 뒤의 테스트를 위해 새 코드를 기억해 둔다 */ const expectCodeRotated = async (leaver: Person): Promise => { 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>(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(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(joined).isOwner).toBe(false); const list = await http().get('/api/diaries').set(as('member')).expect(200); const diary = data>(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>(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>(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>(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>(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(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>(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(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>(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'); }); });