generated from ttipo/project-base
feat(backend): 일정 함께 관리 API — 초대 코드로 달력 연동
가족·친구가 한 달력을 함께 쓸 수 있게 한다. - calendar_groups · calendar_group_members (한 사람 한 그룹, 최대 4명), 회원마다 개인 초대 코드(RUP-6자) - 연동 상태·초대 미리보기·코드로 연동·연동 해제 — 해제하면 내 일정만 남고, 1명 남은 그룹은 지운다 - 일정·반복·완료는 그룹 전체가 보고 고칠 수 있다. 응답에 만든 사람·대상 요약을 담고, 남의 일정을 고칠 때 대상은 그 사람의 아이로 확인한다 - 연동·해제는 advisory lock 으로 줄 세워 인원 초과·중복 그룹을 막는다 - 파일 열람 정책 — 연동된 사람의 프로필·아이 사진 - 초대 코드 생성기·고유 충돌 재시도·대상 요약을 공통으로 옮겨 다이어리와 함께 쓴다 - 에러 코드 SCHED_001~005 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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 codes: 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);
|
||||
|
||||
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<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 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<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]);
|
||||
|
||||
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<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', codes.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);
|
||||
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<EventBody[]>(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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user