Files
rup/backend/test/calendar-sharing.e2e-spec.ts
T
ttipo 02c7ec0079 fix(backend): 리뷰 반영 — 코드 남용 막기·인증 기본 닫힘·한도 동시성·파일 정리
브랜치 리뷰(main..14e2bdd)에서 나온 서버 쪽 문제를 고친다.
- 일정 연동 코드는 한 번 연동에 쓰이면(코드 주인), 나가면(나간 사람) 바꾼다 — 예전에 받은 코드로 다시 들어올 수 없다
- 코드 조회·참여 7개 엔드포인트가 회원 단위 한 바구니(분당 30회)를 같이 쓴다 (@InviteCodeLookup, 인증 뒤에 세는 가드)
- 펫 명함 상세히 공유는 마지막으로 공유한 뒤 7일만 — 그 뒤에는 간략히로 보여 주고 저장한다
- 개발용 로그인·개발용 서명 키는 NODE_ENV 가 development·test 일 때만 (그 밖은 JWT_SECRET 필수, 기동 차단)
- 다이어리 6권·명함 10장·받은 명함 한도를 회원별 advisory lock 으로 세어 동시 요청에 뚫리지 않게 한다
- 받은 명함: 같은 명함 중복(CARD_004)·보관함 100장(CARD_005), 사본 복사 실패 시 만든 사본 정리
- 탈퇴하면 내 다이어리의 다른 멤버 사진도 정리하고, 24시간 넘게 쓰이지 않는 파일을 매시간 지운다
- 대상 id 소문자 저장, GET /calendar-sharing 은 쓰지 않음(코드는 가입 때 만들고 기존 회원은 채움), 파일·작성자 FK 인덱스

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:21:04 +09:00

445 lines
14 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
/**
* 일정 연동 — 초대 코드로 묶이고, 서로의 일정을 보고 고치고, 해제하면 나만 빠진다.
* 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다.
*/
/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
interface SharingBody {
inviteCode: string;
linked: boolean;
maxMembers: number;
members: { userId: string; nickname: string | null; isMe: boolean }[];
}
interface Target {
id: string;
name: string | null;
photoFileId: string | null;
tint: string | null;
}
interface EventBody {
id: string;
title: string;
isDone: boolean;
ownerId: string;
owner: { userId: string; nickname: string | null };
targets: Target[];
}
interface RuleBody {
id: string;
ownerId: string;
targets: Target[];
}
/** 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 = ['a', 'b', 'c', 'd', 'e'] as const;
type Person = (typeof people)[number];
const as = (who: Person) => ({ Authorization: `Bearer ${tokens[who]}` });
const http = () => request(app.getHttpServer());
let petId = '';
let petPhotoId = '';
let profilePhotoId = '';
let eventId = '';
let ruleId = '';
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 sharing = async (who: Person): Promise<SharingBody> =>
data<SharingBody>(
await http().get('/api/calendar-sharing').set(as(who)).expect(200),
);
const join = (who: Person, code: string) =>
http().post('/api/calendar-memberships').set(as(who)).send({ code });
const leave = (who: Person) =>
http().delete('/api/calendar-membership').set(as(who)).expect(204);
const eventsOf = async (who: Person): Promise<EventBody[]> =>
data<{ items: EventBody[] }>(
await http()
.get('/api/schedule-events?limit=100')
.set(as(who))
.expect(200),
).items;
const memberIds = (body: SharingBody) => body.members.map((m) => m.userId);
/** 지금의 초대 코드 — 연동·해제에 쓰이면 바뀌므로 매번 읽는다 */
const codeOf = async (who: Person): Promise<string> =>
(await sharing(who)).inviteCode;
/** 쓴 코드는 한 번뿐 — 예전 코드로는 찾을 수 없고(SCHED_001) 새 코드가 생긴다 */
const expectRotated = async (who: Person, previous: string) => {
const stale = await http()
.get(`/api/calendar-invitations/${previous}`)
.set(as(who))
.expect(404);
expect(errorCode(stale)).toBe('SCHED_001');
const current = await codeOf(who);
expect(current).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(current).not.toBe(previous);
};
beforeAll(async () => {
app = await createE2eApp();
for (const who of people) {
tokens[who] = await devLogin(app, uniqueDeviceKey(`cal-${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 () => {
const first = await sharing('a');
const again = await sharing('a');
expect(first.inviteCode).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(again.inviteCode).toBe(first.inviteCode);
expect(first).toEqual({
inviteCode: first.inviteCode,
linked: false,
maxMembers: 4,
members: [
{ userId: ids.a, nickname: 'a', photoFileId: null, isMe: true },
],
});
});
it('A 가 자기 아이로 일정·반복 일정을 만든다', async () => {
petPhotoId = await uploadPng('a');
profilePhotoId = await uploadPng('a');
await http()
.patch('/api/users/me')
.set(as('a'))
.send({ photoFileId: profilePhotoId })
.expect(200);
const pet = await http()
.post('/api/pets')
.set(as('a'))
.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 events = await http()
.post('/api/schedule-events')
.set(as('a'))
.send({
events: [
{
date: '2026-10-20',
title: '콩이 산책',
kind: 'general',
category: 'walk',
targetIds: [petId],
},
],
})
.expect(201);
const [event] = data<EventBody[]>(events);
eventId = event.id;
expect(event.ownerId).toBe(ids.a);
const rule = await http()
.post('/api/repeat-rules')
.set(as('a'))
.send({
from: '2026-10-01',
spec: { mode: 'daily', unit: 'day', n: 1 },
details: {
title: '심장사상충 약',
kind: 'general',
category: 'med',
targetIds: [petId],
},
})
.expect(201);
ruleId = data<RuleBody>(rule).id;
});
it('B 는 코드를 미리 보고 연동한다 (대소문자 무관) — 쓴 코드는 바뀐다', async () => {
const aCode = await codeOf('a');
const preview = await http()
.get(`/api/calendar-invitations/${aCode.toLowerCase()}`)
.set(as('b'))
.expect(200);
expect(data(preview)).toEqual({
ownerNickname: 'a',
memberCount: 1,
maxMembers: 4,
isMine: false,
isMember: false,
});
const joined = await join('b', aCode).expect(201);
expect(data<SharingBody>(joined).linked).toBe(true);
expect(memberIds(data<SharingBody>(joined))).toEqual([ids.b, ids.a]);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b]);
await expectRotated('a', aCode);
const after = await http()
.get(`/api/calendar-invitations/${await codeOf('a')}`)
.set(as('b'))
.expect(200);
expect(data(after)).toMatchObject({ memberCount: 2, isMember: true });
});
it('B 는 A 의 일정을 주인·대상과 함께 본다', async () => {
const event = (await eventsOf('b')).find((e) => e.id === eventId);
expect(event?.ownerId).toBe(ids.a);
expect(event?.owner.nickname).toBe('a');
expect(event?.targets).toEqual([
{ id: petId, name: '콩이', photoFileId: petPhotoId, tint: '#FF9B6A' },
]);
const rules = await http()
.get('/api/repeat-rules')
.set(as('b'))
.expect(200);
expect(data<{ items: RuleBody[] }>(rules).items.map((r) => r.id)).toContain(
ruleId,
);
});
it('B 가 A 의 일정을 고치고 완료하면 A 도 그대로 본다', async () => {
const updated = await http()
.put(`/api/schedule-events/${eventId}`)
.set(as('b'))
.send({
title: 'B 가 고친 산책',
kind: 'general',
category: 'walk',
targetIds: [petId, 'self'],
})
.expect(200);
// 대상은 일정 주인(A)의 아이와 A 자신으로 확인·표시한다
expect(data<EventBody>(updated).targets.map((t) => t.name)).toEqual([
'콩이',
'a',
]);
await http()
.put(`/api/schedule-events/${eventId}/completion`)
.set(as('b'))
.expect(204);
await http()
.put(`/api/repeat-rules/${ruleId}/completions/2026-10-11`)
.set(as('b'))
.expect(204);
const seen = (await eventsOf('a')).find((e) => e.id === eventId);
expect(seen?.title).toBe('B 가 고친 산책');
expect(seen?.isDone).toBe(true);
const completions = await http()
.get('/api/repeat-rule-completions')
.set(as('a'))
.expect(200);
expect(
data<{ items: { ruleId: string; date: string }[] }>(completions).items,
).toContainEqual({ ruleId, date: '2026-10-11' });
});
it('새 일정은 내 아이만 대상으로 한다 — B 가 A 의 아이로 만들면 VAL_001', async () => {
const res = await http()
.post('/api/schedule-events')
.set(as('b'))
.send({
events: [
{
date: '2026-10-21',
title: '남의 아이',
kind: 'general',
category: 'walk',
targetIds: [petId],
},
],
})
.expect(400);
expect(errorCode(res)).toBe('VAL_001');
});
it('연동한 사람의 프로필·아이 사진은 받고, 바깥 사람은 RES_001', async () => {
for (const fileId of [petPhotoId, profilePhotoId]) {
await http().get(`/api/files/${fileId}`).set(as('b')).expect(200);
const denied = await http()
.get(`/api/files/${fileId}`)
.set(as('e'))
.expect(404);
expect(errorCode(denied)).toBe('RES_001');
}
});
it('C 도 A 의 코드로 들어와 셋이 된다', async () => {
const joined = await join('c', await codeOf('a')).expect(201);
expect(memberIds(data<SharingBody>(joined))).toEqual([ids.c, ids.a, ids.b]);
});
it('이미 연동된 사람(SCHED_005)·내 코드(SCHED_003)는 거절한다', async () => {
for (const owner of ['a', 'c'] as const) {
const again = await join('b', await codeOf(owner)).expect(409);
expect(errorCode(again)).toBe('SCHED_005');
}
const own = await join('a', await codeOf('a')).expect(400);
expect(errorCode(own)).toBe('SCHED_003');
});
it('4명이 차면 SCHED_002, 다른 사람과 연동 중이면 SCHED_004', async () => {
await join('d', await codeOf('a')).expect(201);
const full = await join('e', await codeOf('b')).expect(409);
expect(errorCode(full)).toBe('SCHED_002');
const busy = await join('b', await codeOf('e')).expect(409);
expect(errorCode(busy)).toBe('SCHED_004');
// 해제하면 나간 사람의 코드가 바뀐다
const dCode = await codeOf('d');
await leave('d');
await expectRotated('d', dCode);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b, ids.c]);
});
it('B 가 해제하면 서로의 일정이 사라지고 B 의 일정은 B 에게 남는다', async () => {
const mine = await http()
.post('/api/schedule-events')
.set(as('b'))
.send({
events: [
{
date: '2026-10-22',
title: 'B 의 일정',
kind: 'general',
category: 'etc',
targetIds: ['self'],
},
],
})
.expect(201);
const [bEvent] = data<EventBody[]>(mine);
expect((await eventsOf('a')).map((e) => e.id)).toContain(bEvent.id);
const bCode = await codeOf('b');
await leave('b');
await expectRotated('b', bCode);
const bSees = (await eventsOf('b')).map((e) => e.id);
expect(bSees).toContain(bEvent.id);
expect(bSees).not.toContain(eventId);
expect((await eventsOf('a')).map((e) => e.id)).not.toContain(bEvent.id);
const edit = await http()
.put(`/api/schedule-events/${eventId}/completion`)
.set(as('b'))
.expect(404);
expect(errorCode(edit)).toBe('RES_001');
expect((await sharing('b')).linked).toBe(false);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.c]);
});
it('둘만 남은 묶음에서 한 명이 나가면 묶음이 지워진다', async () => {
await leave('c');
const a = await sharing('a');
expect(a.linked).toBe(false);
expect(memberIds(a)).toEqual([ids.a]);
const rows = await app
.get(DataSource)
.query<
{ count: string }[]
>('SELECT count(*) FROM calendar_group_members WHERE user_id = ANY($1)', [[ids.a, ids.c]]);
expect(rows[0].count).toBe('0');
// 연동 중이 아니어도 해제는 204 — 빠진 게 없으니 코드도 그대로
const cCode = await codeOf('c');
await leave('c');
expect(await codeOf('c')).toBe(cCode);
});
it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보인다 — 정리는 해제할 때 (GET 은 쓰지 않는다)', async () => {
await join('e', await codeOf('d')).expect(201);
await http().delete('/api/users/me').set(as('d')).expect(204);
const countRows = async () =>
(
await app
.get(DataSource)
.query<
{ count: string }[]
>('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e])
)[0].count;
const e = await sharing('e');
expect(e.linked).toBe(false);
expect(memberIds(e)).toEqual([ids.e]);
expect(await countRows()).toBe('1');
await leave('e');
expect(await countRows()).toBe('0');
});
it('없는 코드나 형식이 틀린 코드는 SCHED_001', async () => {
const preview = await http()
.get('/api/calendar-invitations/RUP-000000')
.set(as('a'))
.expect(404);
expect(errorCode(preview)).toBe('SCHED_001');
const bad = await join('a', '엉뚱한 코드').expect(404);
expect(errorCode(bad)).toBe('SCHED_001');
});
});