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 = (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 codes: Record = {}; 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 => { 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 => data( 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 => 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); 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 }, ], }); for (const who of people) codes[who] = (await sharing(who)).inviteCode; }); 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(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(rule).id; }); it('B 는 코드를 미리 보고 연동한다 (대소문자 무관)', async () => { const preview = await http() .get(`/api/calendar-invitations/${codes.a.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', codes.a).expect(201); expect(data(joined).linked).toBe(true); expect(memberIds(data(joined))).toEqual([ids.b, ids.a]); expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b]); const after = await http() .get(`/api/calendar-invitations/${codes.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(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', codes.a).expect(201); expect(memberIds(data(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); expect(errorCode(again)).toBe('SCHED_005'); } const own = await join('a', codes.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); expect(errorCode(full)).toBe('SCHED_002'); const busy = await join('b', codes.e).expect(409); expect(errorCode(busy)).toBe('SCHED_004'); await leave('d'); 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(mine); expect((await eventsOf('a')).map((e) => e.id)).toContain(bEvent.id); await leave('b'); 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 await leave('c'); }); it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보고 정리한다', async () => { await join('e', codes.d).expect(201); await http().delete('/api/users/me').set(as('d')).expect(204); const e = await sharing('e'); expect(e.linked).toBe(false); expect(memberIds(e)).toEqual([ids.e]); 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'); }); 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'); }); });