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>
This commit is contained in:
2026-10-11 23:21:04 +09:00
parent 043e14a3fa
commit 02c7ec0079
44 changed files with 1422 additions and 282 deletions
+49 -23
View File
@@ -55,7 +55,6 @@ describe('일정 연동 (e2e)', () => {
let app: INestApplication<App>;
const tokens: Record<string, string> = {};
const ids: Record<string, string> = {};
const codes: Record<string, string> = {};
const people = ['a', 'b', 'c', 'd', 'e'] as const;
type Person = (typeof people)[number];
@@ -95,6 +94,20 @@ describe('일정 연동 (e2e)', () => {
.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();
@@ -116,7 +129,7 @@ describe('일정 연동 (e2e)', () => {
await app.close();
});
it('초대 코드는 처음 열 때 만들어지고 그대로 유지된다', async () => {
it('초대 코드는 가입할 때 생기고, 읽기만 해서는 바뀌지 않는다', async () => {
const first = await sharing('a');
const again = await sharing('a');
@@ -130,7 +143,6 @@ describe('일정 연동 (e2e)', () => {
{ userId: ids.a, nickname: 'a', photoFileId: null, isMe: true },
],
});
for (const who of people) codes[who] = (await sharing(who)).inviteCode;
});
it('A 가 자기 아이로 일정·반복 일정을 만든다', async () => {
@@ -198,9 +210,10 @@ describe('일정 연동 (e2e)', () => {
ruleId = data<RuleBody>(rule).id;
});
it('B 는 코드를 미리 보고 연동한다 (대소문자 무관)', async () => {
it('B 는 코드를 미리 보고 연동한다 (대소문자 무관) — 쓴 코드는 바뀐다', async () => {
const aCode = await codeOf('a');
const preview = await http()
.get(`/api/calendar-invitations/${codes.a.toLowerCase()}`)
.get(`/api/calendar-invitations/${aCode.toLowerCase()}`)
.set(as('b'))
.expect(200);
expect(data(preview)).toEqual({
@@ -211,13 +224,14 @@ describe('일정 연동 (e2e)', () => {
isMember: false,
});
const joined = await join('b', codes.a).expect(201);
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/${codes.a}`)
.get(`/api/calendar-invitations/${await codeOf('a')}`)
.set(as('b'))
.expect(200);
expect(data(after)).toMatchObject({ memberCount: 2, isMember: true });
@@ -310,28 +324,31 @@ describe('일정 연동 (e2e)', () => {
});
it('C 도 A 의 코드로 들어와 셋이 된다', async () => {
const joined = await join('c', codes.a).expect(201);
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 code of [codes.a, codes.c]) {
const again = await join('b', code).expect(409);
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', codes.a).expect(400);
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', codes.a).expect(201);
const full = await join('e', codes.b).expect(409);
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', codes.e).expect(409);
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]);
});
@@ -354,7 +371,9 @@ describe('일정 연동 (e2e)', () => {
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);
@@ -385,24 +404,31 @@ describe('일정 연동 (e2e)', () => {
>('SELECT count(*) FROM calendar_group_members WHERE user_id = ANY($1)', [[ids.a, ids.c]]);
expect(rows[0].count).toBe('0');
// 연동 중이 아니어도 해제는 204
// 연동 중이 아니어도 해제는 204 — 빠진 게 없으니 코드도 그대로
const cCode = await codeOf('c');
await leave('c');
expect(await codeOf('c')).toBe(cCode);
});
it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보고 정리한다', async () => {
await join('e', codes.d).expect(201);
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');
const rows = await app
.get(DataSource)
.query<
{ count: string }[]
>('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e]);
expect(rows[0].count).toBe('0');
await leave('e');
expect(await countRows()).toBe('0');
});
it('없는 코드나 형식이 틀린 코드는 SCHED_001', async () => {