generated from ttipo/project-base
688e72bf04
다이어리를 가족·지인과 함께 쓸 수 있게 한다. - diary_members 로 함께 쓰는 사람을 두고(주인 포함 최대 4명), 알림 설정을 사람별로 옮긴다 - 초대 코드(RUP-6자) 미리보기·참여, 개인↔공유 전환, 내보내기·나가기 — 내보내거나 나가면 코드를 바꾼다 - 목록·쓰기는 멤버, 수정·삭제는 쓴 사람, 다이어리 정보·삭제는 주인만 - 응답에 멤버·대상 요약·작성자를 담는다 - 파일 열람 정책 — 함께 쓰는 다이어리의 표지·기록 사진·아이 사진·멤버 프로필 사진은 멤버도 받을 수 있다 - 에러 코드 DIARY_003(코드 없음)·004(가득 참)·005(이미 참여) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|