feat(backend): 주소록 API — 연락처 · 그룹 · 사내 목록 · 되돌리기, 받는 사람 추천에 주소록

계약 3절. 내 주소록은 엔진의 JMAP Contacts(직원마다 기본 주소록)에, 사내 목록은 엔진 계정 목록을 사내 목록 계정으로만 읽는다.
엔진 검색은 낱말 단위이고 이름 정렬을 못 해서, 직원마다 주소록을 엔진 state 로 확인하는 캐시에 두고 서버가 찾고(초성 · 전화 숫자) 정렬한다.
쓰기는 직원마다 차례로 · ifInState 로 하고, 어긋나거나 엔진이 잠깐 거절하면 다시 읽어 세 번까지.

리뷰로 고친 것:
- 전화번호 정규식이 입력 길이의 제곱이었다(1MB 본문 하나로 약 24분 CPU) — 정규식을 나누고, DTO 검사를 applyDecorators 로 묶어 형식 → 길이 → 모양 차례로 돌린다.
- 고치기는 PATCH(보낸 칸만 검사) — 칸 안의 남의 속성(label · contexts · fax · pref · role · 둘째 조직)은 지금 카드와 합쳐 지킨다(CardDAV 와 함께 쓰려고).
- [되돌리기]는 지우기 직전에 읽은 카드 전체를 5분 기억했다가 같은 uid 로 다시 만든다(되돌리기 표 — 우리 규칙 밖의 값 · 다른 프로그램의 칸도 그대로).
- 큰 주소록이 캐시에 들지 못해 요청마다 엔진을 통째로 다시 읽었다 — 크기 한도 800만을 쓰기에 걸고 캐시의 한 사람 상한과 맞췄다.
- 받는 사람 추천의 q 를 화면과 같은 맞추기(초성 · 조합 중 글자)로 — 주소록이 크면 미리 받은 100줄이 주소록으로 차서 사내 · 최근 주소의 초성 찾기가 사라졌다.
- excludeGroupId · 사내 항목 하나 · q 254자, 판은 읽기 전에 물은 것으로, 한글 이름 조각은 붙여 잇는다.

에러 코드 CONTACT_001(같은 이름 그룹) · CONTACT_002(주소록 한도)를 더하고 클라이언트 사전을 생성했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:09:59 +09:00
parent 59420df1b9
commit 08cf31fbe6
74 changed files with 13427 additions and 187 deletions
+915 -3
View File
@@ -15,6 +15,19 @@ import { AppModule } from '@/app.module';
import { configureApp } from '@/app.setup';
import { BusinessException } from '@/common/errors/business.exception';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants';
import {
CONTACTS_PROVIDER,
DIRECTORY_PROVIDER,
type AddressBookCard,
type AddressBookChanges,
type AddressBookContactCard,
type AddressBookGroupCard,
type AddressBookSnapshot,
type AddressBookWriteOutcome,
type DirectoryAccount,
type RemovedCardOriginal,
} from '@/modules/contacts/contacts.provider';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
@@ -125,6 +138,9 @@ const CORRECT_PASSWORD = 'correct-password-for-e2e';
/** 메일 엔진 관리자 주소 — 로그인할 수 없는 계정 (mail.settings.ts 의 blockedAccounts) */
const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test';
/** 사내 목록 계정 — 로그인할 수 없고 엔진의 계정 목록을 읽는 데만 쓴다 (mail.settings.ts 의 directoryAccount — 8단계) */
const DIRECTORY_ACCOUNT = 'mail-directory@e2e.arasystem.test';
/** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */
const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
@@ -322,9 +338,20 @@ const TEST_MAIL_SETTINGS: MailSettings = {
gatewayPassword: null,
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set([ADMIN_ACCOUNT]),
blockedAccounts: new Set([ADMIN_ACCOUNT, DIRECTORY_ACCOUNT]),
directoryAccount: DIRECTORY_ACCOUNT,
};
/** 목록 응답의 data — 계약의 { items, pageInfo } */
interface Page<T> {
items: T[];
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
}
describe('응답 계약 (e2e)', () => {
let app: NestExpressApplication;
let server: App;
@@ -393,6 +420,144 @@ describe('응답 계약 (e2e)', () => {
closed: boolean;
}[] = [];
/**
* 8단계 — 대역 엔진의 주소록(직원마다 하나) — 판(state)이 같을 때만 쓰고 쓸 때마다 판이 오른다 (ifInState).
* 카드 모양(JSContact) · 엔진의 거절은 jmap-contacts.provider.spec.ts 가 본다
*/
const addressBooks = new Map<
string,
{
state: number;
contacts: Map<string, AddressBookContactCard>;
groups: Map<string, AddressBookGroupCard>;
}
>();
let addressBookSequence = 0;
const addressBookOf = (user: string) => {
let book = addressBooks.get(user);
if (!book) {
book = { state: 1, contacts: new Map(), groups: new Map() };
addressBooks.set(user, book);
}
return book;
};
const fakeContactsProvider = {
getAddressBookVersion: (user: string) =>
Promise.resolve(`v${addressBookOf(user).state}`),
readAddressBook: (user: string): Promise<AddressBookSnapshot> => {
const book = addressBookOf(user);
return Promise.resolve({
version: `v${book.state}`,
contacts: [...book.contacts.values()],
groups: [...book.groups.values()],
});
},
writeAddressBook: (
user: string,
version: string,
changes: AddressBookChanges,
): Promise<AddressBookWriteOutcome> => {
const book = addressBookOf(user);
if (version !== `v${book.state}`) {
return Promise.resolve({ kind: 'conflict' });
}
const createdIds = new Map<string, string>();
const cards = new Map<string, AddressBookCard>();
const removed = new Map<string, RemovedCardOriginal>();
for (const id of changes.destroy) {
const card = book.contacts.get(id) ?? book.groups.get(id);
if (card) {
const source = JSON.stringify(card);
removed.set(id, { source, bytes: Buffer.byteLength(source) });
}
}
for (const card of changes.create) {
addressBookSequence += 1;
const id = `card${addressBookSequence}`;
createdIds.set(card.key, id);
if (card.kind === 'contact') {
const stored = { id, uid: card.uid, ...card.fields };
book.contacts.set(id, stored);
cards.set(id, { kind: 'contact', card: stored });
} else if (card.kind === 'group') {
const stored = {
id,
uid: card.uid,
name: card.name,
memberUids: [...card.memberUids],
};
book.groups.set(id, stored);
cards.set(id, { kind: 'group', card: stored });
} else if (card.card.kind === 'contact') {
const stored = { ...card.card.card, id, uid: card.uid };
book.contacts.set(id, stored);
cards.set(id, { kind: 'contact', card: stored });
} else {
const stored = {
...card.card.card,
id,
uid: card.uid,
memberUids: [...(card.memberUids ?? [])],
};
book.groups.set(id, stored);
cards.set(id, { kind: 'group', card: stored });
}
}
for (const update of changes.update) {
if (update.kind === 'contact') {
const current = book.contacts.get(update.id);
if (current) {
const stored = {
...current,
...Object.fromEntries(
Object.entries(update.fields).filter(
([, value]) => value !== undefined,
),
),
};
book.contacts.set(update.id, stored);
cards.set(update.id, { kind: 'contact', card: stored });
}
} else {
const current = book.groups.get(update.id);
if (current) {
book.groups.set(update.id, {
...current,
name: update.name ?? current.name,
memberUids: update.memberUids
? [...update.memberUids]
: current.memberUids,
});
}
}
}
for (const id of changes.destroy) {
book.contacts.delete(id);
book.groups.delete(id);
}
book.state += 1;
return Promise.resolve({
kind: 'written',
version: `v${book.state}`,
createdIds,
cards,
removed,
});
},
};
/** 8단계 — 대역 엔진의 계정 목록. null 이면 읽지 못한다(사내 목록 계정이 없음 — 받는 사람 추천은 로그인 기록으로 대신한다) */
let directoryAccounts: DirectoryAccount[] | null = null;
const fakeDirectoryProvider = {
listAccounts: () =>
directoryAccounts
? Promise.resolve(directoryAccounts)
: Promise.reject(
new BusinessException('MAIL_001', {
context: { reason: 'e2e-directory-account-missing' },
}),
),
};
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
const sign = (payload: object, expiresIn: number) =>
jwt.sign(payload, {
@@ -817,6 +982,10 @@ describe('응답 계약 (e2e)', () => {
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
.overrideProvider(CONTACTS_PROVIDER)
.useValue(fakeContactsProvider)
.overrideProvider(DIRECTORY_PROVIDER)
.useValue(fakeDirectoryProvider)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -1107,6 +1276,17 @@ describe('응답 계약 (e2e)', () => {
expect(engineChecks.has(ADMIN_ACCOUNT)).toBe(false);
});
it('사내 목록 계정(8단계)도 로그인할 수 없다 — 401 AUTH_004 이고 메일 엔진에 묻지 않는다', async () => {
const res = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '203.0.113.21')
.send({ email: DIRECTORY_ACCOUNT, password: CORRECT_PASSWORD })
.expect(401);
expect(body(res).error?.code).toBe('AUTH_004');
expect(engineChecks.has(DIRECTORY_ACCOUNT)).toBe(false);
});
it('쿠키 없이 /auth/me 는 401 AUTH_001', async () => {
const res = await request(server).get('/api/auth/me').expect(401);
@@ -1888,7 +2068,13 @@ describe('응답 계약 (e2e)', () => {
.query(params)
.set('Cookie', cookieOf(ME));
interface SuggestionPage {
items: { name: string | null; email: string; source: string }[];
items: {
name: string | null;
email: string;
source: string;
company: string | null;
emailLabel: string | null;
}[];
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
@@ -1914,7 +2100,7 @@ describe('응답 계약 (e2e)', () => {
]);
});
it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => {
it('사내(사내 목록을 읽지 못하면 로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => {
// 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다
const res = await suggest({
q: 'e2e.arasystem.test',
@@ -1928,6 +2114,8 @@ describe('응답 계약 (e2e)', () => {
name: '가나다',
email: COLLEAGUE_A,
source: 'directory',
company: null,
emailLabel: null,
});
// 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만
expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan(
@@ -1938,6 +2126,8 @@ describe('응답 계약 (e2e)', () => {
name: '협력사',
email: 'partner@vendor.e2e.arasystem.test',
source: 'recent',
company: null,
emailLabel: null,
});
expect(emails).not.toContain(ME);
expect(emails).not.toContain('mail-gateway@e2e.arasystem.test');
@@ -3466,6 +3656,726 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 8단계 — 주소록 (계약 3절). 엔진 쪽 판단(JSContact 카드 모양 · ifInState · 계정 목록 읽기)은 jmap-contacts.provider.spec.ts ·
* contact-card.spec.ts · jmap-directory.provider.spec.ts, 찾기 · 차례 · 한도 · 그룹 정리는 contacts.service.spec.ts 가 본다 —
* 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 에러 코드 · 응답 형식 · 머리글을 본다
*/
describe('8단계 — 주소록 (/api/contacts · /api/contact-groups · /api/directory-entries)', () => {
const ME = 'contacts.me@e2e.arasystem.test';
interface ContactBody {
id: string;
name: string | null;
emails: { address: string; label: string }[];
phones: { number: string; label: string }[];
company: string | null;
department: string | null;
title: string | null;
note: string | null;
groupIds: string[];
}
interface GroupBody {
id: string;
name: string;
memberCount: number;
}
interface DirectoryEntryBody {
id: string;
name: string;
email: string;
kind: string;
}
const contactInput = (overrides: Record<string, unknown> = {}) => ({
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: '과장',
note: '견적 담당',
groupIds: [],
...overrides,
});
const asUser = (email: string) => cookieOf(email);
const get = (path: string, user = ME) =>
request(server).get(path).set('Cookie', asUser(user));
const post = (path: string, user = ME) =>
request(server).post(path).set('Cookie', asUser(user));
const patch = (path: string, user = ME) =>
request(server).patch(path).set('Cookie', asUser(user));
const del = (path: string, user = ME) =>
request(server).delete(path).set('Cookie', asUser(user));
const expectCode = (res: Response, code: string) => {
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe(code);
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
};
it('로그인 없이는 401 AUTH_001', async () => {
for (const [method, path] of [
['get', '/api/contacts'],
['post', '/api/contacts'],
['get', '/api/contacts/abc'],
['patch', '/api/contacts/abc'],
['delete', '/api/contacts/abc'],
['get', '/api/contact-groups'],
['post', '/api/contact-groups'],
['patch', '/api/contact-groups/abc'],
['delete', '/api/contact-groups/abc'],
['post', '/api/contact-groups/abc/members'],
['post', '/api/contact-restorations'],
['get', '/api/directory-entries'],
['get', '/api/directory-entries/abc'],
] as const) {
const res = await request(server)[method](path).expect(401);
expectCode(res, 'AUTH_001');
}
});
it('사내 목록을 읽지 못하면(사내 목록 계정이 없음) 503 MAIL_001', async () => {
directoryAccounts = null;
const res = await get('/api/directory-entries').expect(503);
expectCode(res, 'MAIL_001');
});
it('사내 목록 — 관리자 · 게이트웨이 · 사내 목록 계정 · 바깥 주소는 빠지고 이름 차례, kind · q(초성) · 커서, Cache-Control: private, no-store', async () => {
directoryAccounts = [
{
id: 'a',
kind: 'person',
displayName: 'System administrator',
email: ADMIN_ACCOUNT,
},
{
id: 'g',
kind: 'person',
displayName: '메일 게이트웨이 (백엔드 대리 접근 전용) [setup.mjs]',
email: 'mail-gateway@e2e.arasystem.test',
},
{
id: 'm',
kind: 'person',
displayName: '사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]',
email: DIRECTORY_ACCOUNT,
},
{
id: 'd',
kind: 'person',
displayName: '이도윤',
email: 'doyun.lee@e2e.arasystem.test',
},
{
id: 'k',
kind: 'person',
displayName: '김아라',
email: 'ara.kim@e2e.arasystem.test',
},
{
id: 's',
kind: 'shared',
displayName: '영업팀',
email: 'sales@e2e.arasystem.test',
},
{
id: 'x',
kind: 'person',
displayName: '바깥',
email: 'outside@partner.e2e.example',
},
];
const res = await get('/api/directory-entries').expect(200);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(body(res).success).toBe(true);
expect(dataOf<Page<DirectoryEntryBody>>(res)).toEqual({
items: [
{
id: 'k',
name: '김아라',
email: 'ara.kim@e2e.arasystem.test',
kind: 'person',
},
{
id: 's',
name: '영업팀',
email: 'sales@e2e.arasystem.test',
kind: 'shared',
},
{
id: 'd',
name: '이도윤',
email: 'doyun.lee@e2e.arasystem.test',
kind: 'person',
},
],
pageInfo: { hasNext: false, nextCursor: null, total: 3 },
});
const idsOf = async (query: Record<string, string | number>) =>
dataOf<Page<DirectoryEntryBody>>(
await get('/api/directory-entries').query(query).expect(200),
).items.map((entry) => entry.id);
expect(await idsOf({ kind: 'shared' })).toEqual(['s']);
expect(await idsOf({ q: 'ㄷㅇ' })).toEqual(['d']);
expect(await idsOf({ q: 'ARA.KIM' })).toEqual(['k']);
const first = dataOf<Page<DirectoryEntryBody>>(
await get('/api/directory-entries').query({ limit: 1 }).expect(200),
);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 });
const cursor = first.pageInfo.nextCursor ?? '';
expect(await idsOf({ limit: 1, cursor })).toEqual(['s']);
for (const query of [
{ cursor, kind: 'person' },
{ cursor: 'not-a-cursor' },
{ kind: 'group' },
{ q: 'x'.repeat(255) },
{ limit: 101 },
]) {
expectCode(
await get('/api/directory-entries').query(query).expect(400),
'VAL_001',
);
}
// 메일 주소 하나(254자)는 찾을 수 있다
expect(await idsOf({ q: 'x'.repeat(254) })).toEqual([]);
// 한 항목 — 기억한 목록에서, 빼는 계정 · 없는 id · 형식이 틀린 id 는 404 RES_001
const one = await get('/api/directory-entries/d').expect(200);
expect(one.headers['cache-control']).toBe('private, no-store');
expect(dataOf<DirectoryEntryBody>(one)).toEqual({
id: 'd',
name: '이도윤',
email: 'doyun.lee@e2e.arasystem.test',
kind: 'person',
});
for (const id of ['a', 'm', 'nope', 'bad.id']) {
expectCode(
await get(`/api/directory-entries/${id}`).expect(404),
'RES_001',
);
}
});
it('사내 목록 excludeGroupId — 내 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다, 없는 그룹은 404 RES_001', async () => {
const PICKER = 'contacts.picker@e2e.arasystem.test';
const group = dataOf<GroupBody>(
await post('/api/contact-groups', PICKER)
.send({ name: '팀' })
.expect(201),
);
const member = dataOf<ContactBody>(
await post('/api/contacts', PICKER)
.send(
contactInput({
emails: [
{ address: 'Doyun.Lee@e2e.arasystem.test', label: 'work' },
],
groupIds: [group.id],
}),
)
.expect(201),
);
expect(member.groupIds).toEqual([group.id]);
const ids = dataOf<Page<DirectoryEntryBody>>(
await get('/api/directory-entries', PICKER)
.query({ excludeGroupId: group.id })
.expect(200),
).items.map((entry) => entry.id);
expect(ids).toEqual(['k', 's']);
expectCode(
await get('/api/directory-entries', PICKER)
.query({ excludeGroupId: 'nogroup' })
.expect(404),
'RES_001',
);
});
it('연락처 추가 201 · 하나 · 목록(초성 · 전화번호 숫자 · 대소문자) · 고치기 · 지우기 — 표준 래퍼, 지운 뒤에는 404 RES_001', async () => {
const created = await post('/api/contacts')
.send(
contactInput({ name: ' 박지훈 ', note: '견적 담당\n월요일 오전' }),
)
.expect(201);
expect(body(created).success).toBe(true);
const contact = dataOf<ContactBody>(created);
expect(contact).toEqual({
id: contact.id,
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: '과장',
note: '견적 담당\n월요일 오전',
groupIds: [],
});
const one = await get(`/api/contacts/${contact.id}`).expect(200);
expect(one.headers['cache-control']).toBe('private, no-store');
expect(dataOf<ContactBody>(one)).toEqual(contact);
for (const q of ['ㅂㅈㅎ', '31925540', 'HANBIT', '구매']) {
const page = dataOf<Page<ContactBody>>(
await get('/api/contacts').query({ q }).expect(200),
);
expect(page.items.map((item) => item.id)).toEqual([contact.id]);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 1,
});
}
// 고치기는 바꾼 칸만 — 보내지 않은 칸은 그대로
const updated = await patch(`/api/contacts/${contact.id}`)
.send({ title: '부장', note: null })
.expect(200);
expect(dataOf<ContactBody>(updated)).toEqual({
...contact,
title: '부장',
note: null,
});
const removed = await del(`/api/contacts/${contact.id}`).expect(200);
const deleted = dataOf<{ contact: ContactBody; restoreToken: string }>(
removed,
);
expect(deleted.contact).toMatchObject({ id: contact.id, title: '부장' });
expect(deleted.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/);
expectCode(
await get(`/api/contacts/${contact.id}`).expect(404),
'RES_001',
);
expectCode(
await del(`/api/contacts/${contact.id}`).expect(404),
'RES_001',
);
// 되돌리기 — 새 id 로 그대로, 표는 한 번만 · 다른 직원은 쓸 수 없다
expectCode(
await post(
'/api/contact-restorations',
'someone.else@e2e.arasystem.test',
)
.send({ token: deleted.restoreToken })
.expect(404),
'RES_001',
);
const restored = await post('/api/contact-restorations')
.send({ token: deleted.restoreToken })
.expect(201);
const back = dataOf<{ kind: string; contact: ContactBody }>(restored);
expect(back.kind).toBe('contact');
expect(back.contact).toEqual({ ...deleted.contact, id: back.contact.id });
expect(back.contact.id).not.toBe(contact.id);
expectCode(
await post('/api/contact-restorations')
.send({ token: deleted.restoreToken })
.expect(404),
'RES_001',
);
for (const bad of [
{},
{ token: 'short' },
{ token: deleted.restoreToken, extra: 1 },
]) {
expectCode(
await post('/api/contact-restorations').send(bad).expect(400),
'VAL_001',
);
}
// 다음 테스트를 위해 비운다
await del(`/api/contacts/${back.contact.id}`).expect(200);
});
it('형식 오류는 400 VAL_001 — 이름도 이메일도 없음 · 같은 주소 두 번 · 전화번호 형식 · 칸이 빠짐 · 모르는 칸 / 형식이 틀린 id 는 404 RES_001', async () => {
const missingNote: Record<string, unknown> = contactInput();
delete missingNote.note;
for (const bad of [
contactInput({ name: null, emails: [] }),
contactInput({ name: ' ', emails: [] }),
contactInput({
emails: [
{ address: 'same@x.e2e.example', label: 'work' },
{ address: 'SAME@X.e2e.example', label: 'other' },
],
}),
contactInput({ phones: [{ number: '내선 12', label: 'work' }] }),
contactInput({
emails: [{ address: 'not-an-address', label: 'work' }],
}),
missingNote,
contactInput({ birthday: '1990-05-01' }),
]) {
expectCode(
await post('/api/contacts').send(bad).expect(400),
'VAL_001',
);
}
expectCode(await get('/api/contacts/bad.id').expect(404), 'RES_001');
expectCode(
await patch('/api/contacts/bad.id').send({ title: 'x' }).expect(404),
'RES_001',
);
// 고치기 — 빈 본문 · 모르는 칸 · 보낸 칸의 형식 오류 · 합친 결과에 이름도 이메일도 없음은 400, PUT 은 없다
const VALIDATOR = 'contacts.validate@e2e.arasystem.test';
const target = dataOf<ContactBody>(
await post('/api/contacts', VALIDATOR)
.send(contactInput({ emails: [] }))
.expect(201),
);
for (const bad of [
{},
{ birthday: '1990-05-01' },
{ phones: [{ number: '내선', label: 'work' }] },
{ emails: null },
{ name: null },
]) {
expectCode(
await patch(`/api/contacts/${target.id}`, VALIDATOR)
.send(bad)
.expect(400),
'VAL_001',
);
}
expectCode(
await request(server)
.put(`/api/contacts/${target.id}`)
.set('Cookie', asUser(VALIDATOR))
.send(contactInput())
.expect(404),
'RES_001',
);
});
it('그룹 — 만들기 201 · 같은 이름(대소문자 무시) 409 CONTACT_001 · 이름 바꾸기 · 구성원 넣기(내 연락처 · 사내 목록) · 그룹의 구성원 목록 · 지우기와 [되돌리기]', async () => {
const group = dataOf<GroupBody>(
await post('/api/contact-groups')
.send({ name: 'Partners' })
.expect(201),
);
expect(group).toEqual({ id: group.id, name: 'Partners', memberCount: 0 });
expectCode(
await post('/api/contact-groups')
.send({ name: ' partners ' })
.expect(409),
'CONTACT_001',
);
expectCode(
await post('/api/contact-groups').send({ name: '' }).expect(400),
'VAL_001',
);
const renamed = await patch(`/api/contact-groups/${group.id}`)
.send({ name: '거래처' })
.expect(200);
expect(dataOf<GroupBody>(renamed)).toEqual({
id: group.id,
name: '거래처',
memberCount: 0,
});
const contact = dataOf<ContactBody>(
await post('/api/contacts').send(contactInput()).expect(201),
);
const added = await post(`/api/contact-groups/${group.id}/members`)
.send({ contactIds: [contact.id], directoryIds: ['d'] })
.expect(200);
expect(dataOf<unknown>(added)).toEqual({
group: { id: group.id, name: '거래처', memberCount: 2 },
addedCount: 2,
createdCount: 1,
});
// 다시 넣으면 세지 않는다
expect(
dataOf<{ addedCount: number }>(
await post(`/api/contact-groups/${group.id}/members`)
.send({ contactIds: [contact.id], directoryIds: ['d'] })
.expect(200),
).addedCount,
).toBe(0);
const members = dataOf<Page<ContactBody>>(
await get('/api/contacts')
.query({ groupId: group.id, limit: 100 })
.expect(200),
);
expect(members.items.map((item) => item.emails[0]?.address)).toEqual([
'jihoon.park@hanbit.e2e.example',
'doyun.lee@e2e.arasystem.test',
]);
expect(
members.items.every((item) => item.groupIds.includes(group.id)),
).toBe(true);
const groups = dataOf<Page<GroupBody>>(
await get('/api/contact-groups').expect(200),
);
expect(groups.items).toContainEqual({
id: group.id,
name: '거래처',
memberCount: 2,
});
expectCode(
await post(`/api/contact-groups/${group.id}/members`)
.send({})
.expect(400),
'VAL_001',
);
for (const bad of [{ contactIds: ['nope'] }, { directoryIds: ['a'] }]) {
expectCode(
await post(`/api/contact-groups/${group.id}/members`)
.send(bad)
.expect(404),
'RES_001',
);
}
expectCode(
await patch('/api/contact-groups/nope').send({ name: 'x' }).expect(404),
'RES_001',
);
const deleted = dataOf<{
id: string;
name: string;
memberIds: string[];
restoreToken: string;
}>(await del(`/api/contact-groups/${group.id}`).expect(200));
expect(deleted).toEqual({
id: group.id,
name: '거래처',
memberIds: members.items.map((item) => item.id),
restoreToken: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/) as string,
});
// 연락처는 그대로 — 그 그룹은 이제 없다
expect(
dataOf<Page<ContactBody>>(
await get('/api/contacts').query({ limit: 100 }).expect(200),
).items.map((item) => item.groupIds),
).toEqual([[], []]);
expectCode(
await get('/api/contacts').query({ groupId: group.id }).expect(404),
'RES_001',
);
// 만들기는 { name } 만 — 지운 그룹은 되돌리기로
expectCode(
await post('/api/contact-groups')
.send({ name: deleted.name, memberIds: deleted.memberIds })
.expect(400),
'VAL_001',
);
const restored = dataOf<{ kind: string; group: GroupBody }>(
await post('/api/contact-restorations')
.send({ token: deleted.restoreToken })
.expect(201),
);
expect(restored).toMatchObject({
kind: 'group',
group: { name: '거래처', memberCount: 2 },
});
expect(restored.group.id).not.toBe(group.id);
// excludeGroupId — 그 그룹의 구성원을 빼고 (groupId 와 함께 쓰면 400)
expect(
dataOf<Page<ContactBody>>(
await get('/api/contacts')
.query({ excludeGroupId: restored.group.id })
.expect(200),
).items,
).toEqual([]);
expectCode(
await get('/api/contacts')
.query({
groupId: restored.group.id,
excludeGroupId: restored.group.id,
})
.expect(400),
'VAL_001',
);
});
it(`한도 — 연락처 ${MAX_CONTACTS}개면 409 CONTACT_002 (아무것도 만들지 않는다)`, async () => {
const FULL = 'contacts.full@e2e.arasystem.test';
const book = addressBookOf(FULL);
for (let index = 0; index < MAX_CONTACTS; index += 1) {
book.contacts.set(`f${index}`, {
id: `f${index}`,
uid: `urn:uuid:f${index}`,
name: `연락처 ${index}`,
emails: [],
phones: [],
company: null,
department: null,
title: null,
note: null,
});
}
expectCode(
await post('/api/contacts', FULL).send(contactInput()).expect(409),
'CONTACT_002',
);
expect(book.contacts.size).toBe(MAX_CONTACTS);
});
it('커서는 받은 그 q · groupId 로만 — 다르면 400 VAL_001, 없는 groupId 는 404 RES_001, limit 범위 밖은 400', async () => {
const LISTER = 'contacts.lister@e2e.arasystem.test';
for (const name of ['가나다', '나비']) {
await post('/api/contacts', LISTER)
.send(contactInput({ name, emails: [] }))
.expect(201);
}
const first = dataOf<Page<ContactBody>>(
await get('/api/contacts', LISTER).query({ limit: 1 }).expect(200),
);
expect(first.items.map((item) => item.name)).toEqual(['가나다']);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
const cursor = first.pageInfo.nextCursor ?? '';
expect(
dataOf<Page<ContactBody>>(
await get('/api/contacts', LISTER)
.query({ limit: 1, cursor })
.expect(200),
).items.map((item) => item.name),
).toEqual(['나비']);
expectCode(
await get('/api/contacts', LISTER)
.query({ cursor, q: '나' })
.expect(400),
'VAL_001',
);
expectCode(
await get('/api/contacts', LISTER).query({ limit: 0 }).expect(400),
'VAL_001',
);
expectCode(
await get('/api/contacts', LISTER)
.query({ groupId: 'nogroup' })
.expect(404),
'RES_001',
);
});
it('받는 사람 추천 — 주소록(회사 · 종류) → 사내(엔진의 사내 목록) → 최근, 연락처를 바꾸면 바로 보인다', async () => {
const SUGGESTER = 'contacts.suggest@e2e.arasystem.test';
const now = new Date().toISOString();
correspondents.set(SUGGESTER, [
{ name: '이도윤', email: 'doyun.lee@e2e.arasystem.test', at: now },
{ name: '협력사', email: 'vendor@partner.e2e.example', at: now },
]);
const suggestions = async () =>
dataOf<
Page<{
name: string | null;
email: string;
source: string;
company: string | null;
emailLabel: string | null;
}>
>(
await request(server)
.get('/api/recipient-suggestions')
.query({ limit: 100 })
.set('Cookie', cookieOf(SUGGESTER))
.expect(200),
).items;
expect(await suggestions()).toEqual([
// 사내 — 주고받은 사람이 먼저, 나머지는 이름 차례
{
name: '이도윤',
email: 'doyun.lee@e2e.arasystem.test',
source: 'directory',
company: null,
emailLabel: null,
},
{
name: '김아라',
email: 'ara.kim@e2e.arasystem.test',
source: 'directory',
company: null,
emailLabel: null,
},
{
name: '영업팀',
email: 'sales@e2e.arasystem.test',
source: 'directory',
company: null,
emailLabel: null,
},
{
name: '협력사',
email: 'vendor@partner.e2e.example',
source: 'recent',
company: null,
emailLabel: null,
},
]);
await post('/api/contacts', SUGGESTER)
.send(
contactInput({
emails: [
{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' },
{ address: 'jihoon@home.e2e.example', label: 'personal' },
],
}),
)
.expect(201);
await post('/api/contacts', SUGGESTER)
.send(
contactInput({
name: '이도윤(거래처)',
company: null,
emails: [
{ address: 'Doyun.Lee@e2e.arasystem.test', label: 'other' },
],
}),
)
.expect(201);
const after = await suggestions();
expect(after.slice(0, 3)).toEqual([
{
name: '박지훈',
email: 'jihoon.park@hanbit.e2e.example',
source: 'contact',
company: '한빛상사',
emailLabel: 'work',
},
{
name: '박지훈',
email: 'jihoon@home.e2e.example',
source: 'contact',
company: '한빛상사',
emailLabel: 'personal',
},
{
name: '이도윤(거래처)',
email: 'Doyun.Lee@e2e.arasystem.test',
source: 'contact',
company: null,
emailLabel: 'other',
},
]);
// 주소록에 있는 주소는 사내 묶음에서 빠진다
expect(after.map((item) => item.source)).toEqual([
'contact',
'contact',
'contact',
'directory',
'directory',
'recent',
]);
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
@@ -3494,6 +4404,8 @@ describe('응답 계약 (e2e)', () => {
['delete', '/api/messages/eaaaaab'],
['post', '/api/message-deletions'],
['post', '/api/mail-attachments'],
['patch', '/api/contacts/abc'],
['post', '/api/contact-restorations'],
] as const)(
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',
async (method, path) => {