generated from ttipo/project-base
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user