feat(backend): 대화 묶기를 더한다 — 대화 키 · 같은 대화 · 대화로 묶은 목록 · 설정

14단계 3차다. 같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 보이고, 목록을 대화로
묶어 볼 수 있게 한다. 색인이 준비된 직원은 우리 대화 키로, 채우는 중인 직원은 엔진의
스레드로 묶는다(응답의 basis — 첫 쪽에서 정하고 커서가 잇는다).

- 대화 키: 색인 쓰기 때 넣은 메일의 부모 · 자식(References · In-Reply-To) 가운데 말머리
  (회신 · 답장 · 전달 포함)를 뗀 제목이 같은 것을 가장 작은 키로 합친다. 늦게 온 원본도
  잇고, 다시 넣어도 키는 풀리지 않는다. 색인 판 4
- GET /api/messages/{id}/conversation — 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과
  발송 중인 내부 초안은 빼고 지금 메일은 늘 넣는다
- GET /api/conversations?mailboxId= — 그 메일함의 대화 한 줄씩(참여자 10명 · 통 수 ·
  messageIds 최신 500 · pendingSendIds). 직원 주소 btree 로 먼저 좁힌다(10명 흉내 큰 메일함
  첫 쪽 81ms)
- 설정 groupByConversation(처음 끔) — 서버는 저장만 하고 묶을지는 화면이 정한다
- 마이그레이션 AddMailSearchSender — 참여자는 따로 둔 첫 보낸 사람 칸에서만 읽는다

리뷰 반영: 엔진 방식에 개수 · 시간 상한(같은 대화 한 요청 1,000통 · 묶은 목록 스레드마다
500 · 한 요청 2,000 · 15초, 요약 속성은 내보낼 메일만), Mailbox/get 장애는 503,
꾸민 커서는 엔진 · DB 보다 먼저 400, 같은 대화 한 문장, 이름 안의 <주소>로 참여자를
꾸미지 못하게, 로그 추적 ID. 검색 표본 표 머리 주석(frontend 로 옮겼다)도 함께 고친다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 05:39:10 +09:00
parent 53a8d7176d
commit 425cbc0e29
39 changed files with 4612 additions and 82 deletions
+672 -1
View File
@@ -221,6 +221,8 @@ const MAIL_STATE = 'c2Iy.c2Fq';
/** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */
const MISSING_MAILBOX = 'nomailbox';
/** 대역 엔진에 없는 메일 (14단계 3차 — 같은 대화의 404) */
const MISSING_MESSAGE = 'nosuchmail';
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */
const GONE_MESSAGE = 'gonemessage';
@@ -748,6 +750,19 @@ describe('응답 계약 (e2e)', () => {
const listQueries: { user: string; query: MessageListQuery }[] = [];
/** 14단계 2차 — 대역 엔진이 받은 요약 요청 (색인으로 찾은 쪽) */
const summaryRequests: { user: string; ids: readonly string[] }[] = [];
/** 14단계 3차 — 대역 엔진이 받은 대화 요청 (엔진의 스레드 — 색인 전) */
const engineConversations: {
user: string;
emailId: string;
page: { limit: number; cursor?: string };
}[] = [];
const engineConversationLists: {
user: string;
query: { mailboxId: string; limit: number; cursor?: string };
}[] = [];
/** 14단계 3차 리뷰 L6 — 대역 엔진의 붙잡힌 메일(보내기 취소 · 예약을 기다리는) id 와 그것을 함께 읽은 요약 요청 */
const fakeHeldIds = new Set<string>();
const heldSummaryRequests: { user: string; ids: readonly string[] }[] = [];
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
const bulkUpdates: {
user: string;
@@ -1296,6 +1311,59 @@ describe('응답 계약 (e2e)', () => {
},
);
},
// 14단계 3차 — 대화의 범위(늘 휴지통을 뺀다) · 엔진의 스레드로 본 대화(색인 전 — 받은 것만 적는다)
conversationScope: (
_user: string,
mailboxId?: string,
): Promise<SearchScope> =>
mailboxId === MISSING_MAILBOX
? Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mailbox-not-found' },
}),
)
: Promise.resolve({
mailboxId: mailboxId ?? null,
excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX],
excludedKeywords: ['$ara-sending'],
}),
getConversation: (
user: string,
emailId: string,
page: { limit: number; cursor?: string },
) => {
engineConversations.push({ user, emailId, page });
return emailId === MISSING_MESSAGE
? Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
)
: Promise.resolve({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
},
listConversations: (
user: string,
query: { mailboxId: string; limit: number; cursor?: string },
) => {
engineConversationLists.push({ user, query });
return query.mailboxId === MISSING_MAILBOX
? Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mailbox-not-found' },
}),
)
: Promise.resolve({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
},
getSummaries: (
user: string,
ids: readonly string[],
@@ -1310,6 +1378,18 @@ describe('응답 계약 (e2e)', () => {
}),
);
},
getSummariesWithHeld: (user: string, ids: readonly string[]) => {
heldSummaryRequests.push({ user, ids });
const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? [];
const byId = new Map(emails.map((email) => [email.id, email]));
return Promise.resolve({
summaries: ids.flatMap((id) => {
const email = byId.get(id);
return email ? [summaryOfDocument(email)] : [];
}),
heldIds: new Set(fakeHeldIds),
});
},
updateMessages: (
user: string,
ids: readonly string[],
@@ -5202,7 +5282,11 @@ describe('응답 계약 (e2e)', () => {
expect(body(res)).toEqual({
success: true,
data: { undoSendSeconds: 10, externalImages: 'ask' },
data: {
undoSendSeconds: 10,
externalImages: 'ask',
groupByConversation: false,
},
});
});
@@ -5213,6 +5297,7 @@ describe('응답 계약 (e2e)', () => {
expect(dataOf<unknown>(first)).toEqual({
undoSendSeconds: 10,
externalImages: 'always',
groupByConversation: false,
});
const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect(
@@ -5221,6 +5306,7 @@ describe('응답 계약 (e2e)', () => {
expect(dataOf<unknown>(second)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
groupByConversation: false,
});
const read = await request(server)
@@ -5230,6 +5316,7 @@ describe('응답 계약 (e2e)', () => {
expect(dataOf<unknown>(read)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
groupByConversation: false,
});
const rows: {
undo_send_seconds: number;
@@ -5243,6 +5330,40 @@ describe('응답 계약 (e2e)', () => {
]);
});
it('대화로 묶어 보기(14단계) — 처음 끔, PATCH 로 켜고 끄며 다른 칸은 그대로 · DB 의 열에 저장', async () => {
const GROUP = 'settings.group@e2e.arasystem.test';
const on = await patchSettings(GROUP, {
groupByConversation: true,
}).expect(200);
expect(dataOf<unknown>(on)).toEqual({
undoSendSeconds: 10,
externalImages: 'ask',
groupByConversation: true,
});
const read = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(GROUP))
.expect(200);
expect(dataOf<{ groupByConversation: boolean }>(read)).toMatchObject({
groupByConversation: true,
});
const [row]: { group_by_conversation: boolean }[] =
await dataSource.query(
'SELECT group_by_conversation FROM mail_settings WHERE user_email = $1',
[GROUP],
);
expect(row).toEqual({ group_by_conversation: true });
const off = await patchSettings(GROUP, {
groupByConversation: false,
undoSendSeconds: 5,
}).expect(200);
expect(dataOf<unknown>(off)).toEqual({
undoSendSeconds: 5,
externalImages: 'ask',
groupByConversation: false,
});
});
it.each<[string, object]>([
['빈 본문(하나 이상)', {}],
['고를 수 없는 초', { undoSendSeconds: 7 }],
@@ -5250,6 +5371,8 @@ describe('응답 계약 (e2e)', () => {
['null 초', { undoSendSeconds: null }],
['모르는 외부 이미지 값', { externalImages: 'never' }],
['null 외부 이미지', { externalImages: null }],
['글로 쓴 대화로 묶기', { groupByConversation: 'true' }],
['null 대화로 묶기', { groupByConversation: null }],
['모르는 칸', { theme: 'dark' }],
])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => {
const res = await patchSettings(ME, payload).expect(400);
@@ -8407,6 +8530,554 @@ describe('응답 계약 (e2e)', () => {
});
});
describe('14단계 3차 — 대화 키 · 같은 대화 · 대화로 묶은 목록 (실제 PostgreSQL)', () => {
const ME = 'thread.me@e2e.arasystem.test';
const OTHER = 'thread.other@e2e.arasystem.test';
const MINE = { name: '김아라', email: ME };
const at = (day: number, hour: number) => `2026-11-0${day}T0${hour}:00:00Z`;
/** 견적 대화 — 회신 · 답장 · 전달 말머리 · 겹친 Re · 꼬리표, 휴지통에만 든 답장 하나 */
const DOCUMENTS = [
indexDocument({
id: 't-orig',
subject: '견적 요청',
messageIds: ['orig@e2e.test'],
receivedAt: at(1, 1),
}),
indexDocument({
id: 't-re',
subject: 'Re: 견적 요청',
messageIds: ['re1@e2e.test'],
inReplyTo: ['orig@e2e.test'],
references: ['orig@e2e.test'],
receivedAt: at(1, 2),
}),
indexDocument({
id: 't-hoesin',
subject: '회신: 견적 요청',
messageIds: ['re2@e2e.test'],
inReplyTo: ['re1@e2e.test'],
references: ['orig@e2e.test', 're1@e2e.test'],
from: [MINE],
mailboxIds: ['sent'],
receivedAt: at(1, 3),
}),
indexDocument({
id: 't-fwd',
subject: 'FW: 전달: [외부] 견적 요청',
messageIds: ['fw@e2e.test'],
references: ['orig@e2e.test'],
receivedAt: at(1, 4),
}),
indexDocument({
id: 't-rere',
subject: 'RE: RE: Re[2]: 견적 요청',
messageIds: ['re3@e2e.test'],
inReplyTo: ['re2@e2e.test'],
keywords: [],
receivedAt: at(1, 5),
}),
indexDocument({
id: 't-trash',
subject: 'Re: 견적 요청',
messageIds: ['re4@e2e.test'],
inReplyTo: ['re3@e2e.test'],
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
receivedAt: at(1, 6),
}),
// 답장 관계는 있지만 제목이 다르다 — 따로
indexDocument({
id: 't-other',
subject: 'Re: 다른 일',
messageIds: ['other@e2e.test'],
inReplyTo: ['orig@e2e.test'],
receivedAt: at(1, 7),
}),
// Message-ID 가 없다 — 혼자
indexDocument({
id: 't-nomid',
subject: 'Re: 견적 요청',
messageIds: [],
inReplyTo: ['orig@e2e.test'],
receivedAt: at(1, 8),
}),
// 원본(lo@)보다 먼저 들어온 답장 둘 — 원본이 오면 합친다
indexDocument({
id: 't-late-r1',
subject: '답장: 회의록 공유',
messageIds: ['lr1@e2e.test'],
inReplyTo: ['lo@e2e.test'],
receivedAt: at(2, 1),
}),
indexDocument({
id: 't-late-r2',
subject: 'Re: 회의록 공유',
messageIds: ['lr2@e2e.test'],
inReplyTo: ['lo@e2e.test'],
receivedAt: at(2, 2),
}),
// 발송 중인 내부 초안 — 견적 대화에 이어지지만 목록 · 다른 메일의 대화에서는 빠진다
indexDocument({
id: 't-sending',
subject: 'Re: 견적 요청',
messageIds: ['re5@e2e.test'],
inReplyTo: ['re3@e2e.test'],
mailboxIds: ['drafts'],
keywords: ['$draft', '$ara-sending'],
receivedAt: at(2, 3),
}),
// 주소 없이 이름만 `대표 <ceo@…>` 꼴인 보낸 사람 — 참여자가 그 주소로 보이지 않는다 (리뷰 L3, 공지 메일함)
indexDocument({
id: 't-notice',
subject: '공지',
messageIds: ['notice@e2e.test'],
from: [{ name: '대표 <ceo@example.com>', email: null }],
mailboxIds: ['notice'],
receivedAt: at(3, 1),
}),
indexDocument({
id: 't-notice-re',
subject: 'Re: 공지',
messageIds: ['notice-re@e2e.test'],
inReplyTo: ['notice@e2e.test'],
mailboxIds: ['notice'],
receivedAt: at(3, 2),
}),
];
const LATE_ORIGINAL = indexDocument({
id: 't-late-orig',
subject: '회의록 공유',
messageIds: ['lo@e2e.test'],
receivedAt: at(1, 9),
});
const keysOf = async (user: string) =>
Object.fromEntries(
(
await dataSource.query<{ email_id: string; thread_key: string }[]>(
'SELECT email_id, thread_key FROM mail_search_messages WHERE user_email = $1',
[user],
)
).map((row) => [row.email_id, row.thread_key]),
);
const conversation = (
id: string,
query: Record<string, string | number> = {},
) =>
request(server)
.get(`/api/messages/${id}/conversation`)
.query(query)
.set('Cookie', cookieOf(ME));
const conversations = (query: Record<string, string | number>) =>
request(server)
.get('/api/conversations')
.query(query)
.set('Cookie', cookieOf(ME));
interface ConversationPage {
items: { id: string }[];
pageInfo: Page<unknown>['pageInfo'];
basis: string;
}
interface ConversationRow {
id: string;
latest: { id: string };
participants: { name: string | null; email: string }[];
messageCount: number;
messageIds: string[];
pendingSendIds: string[];
isRead: boolean;
isFlagged: boolean;
hasAttachment: boolean;
}
interface ConversationListPage {
items: ConversationRow[];
pageInfo: Page<unknown>['pageInfo'];
basis: string;
}
const setStatus = (status: 'ready' | 'building') =>
dataSource.query(
'UPDATE mail_search_states SET status = $2 WHERE user_email = $1',
[ME, status],
);
beforeAll(async () => {
for (const [user, emails] of [
[ME, DOCUMENTS],
[
OTHER,
// 같은 메일을 받은 다른 직원 — 같은 Message-ID · 제목이어도 그 직원의 대화다
[
indexDocument({
id: 'x-orig',
subject: '견적 요청',
messageIds: ['orig@e2e.test'],
receivedAt: at(1, 1),
}),
indexDocument({
id: 'x-re',
subject: 'Re: 견적 요청',
messageIds: ['re1@e2e.test'],
inReplyTo: ['orig@e2e.test'],
receivedAt: at(1, 2),
}),
],
],
] as const) {
await request(server)
.get('/api/search-index')
.set('Cookie', cookieOf(user))
.expect(200);
fakeIndexSource.mailboxes.set(user, {
state: 'c1',
emails: [...emails],
});
expect(
await app
.get(MailSearchBuildService)
.build(user, Date.now() + 120_000),
).toBe('ready');
}
});
afterAll(async () => {
for (const user of [ME, OTHER]) {
fakeIndexSource.mailboxes.delete(user);
for (const table of ['mail_search_messages', 'mail_search_states']) {
await dataSource.query(
`DELETE FROM "${table}" WHERE user_email = $1`,
[user],
);
}
}
});
it('대화 키 — 답장 관계 + 말머리(Re · RE · Re[2] · 회신 · 전달 · FW · [꼬리표])를 뗀 제목이 같으면 가장 작은 키 하나, 제목이 다르거나 Message-ID 가 없으면 혼자 · 다른 직원과 섞이지 않는다', async () => {
const keys = await keysOf(ME);
for (const id of [
't-orig',
't-re',
't-hoesin',
't-fwd',
't-rere',
't-trash',
]) {
expect(keys[id]).toBe('t-fwd');
}
expect(keys['t-other']).toBe('t-other');
expect(keys['t-nomid']).toBe('t-nomid');
// 원본이 아직 없다 — 두 답장은 따로
expect(keys['t-late-r1']).toBe('t-late-r1');
expect(keys['t-late-r2']).toBe('t-late-r2');
expect(await keysOf(OTHER)).toEqual({
'x-orig': 'x-orig',
'x-re': 'x-orig',
});
});
it('늦게 온 원본은 들어올 때 갈려 있던 답장들을 잇는다 — 같은 메일을 다시 넣어도(겹친 채우기) 합친 키를 풀지 않는다', async () => {
const repository = app.get(MailSearchIndexRepository);
fakeIndexSource.mailboxes.get(ME)?.emails.push(LATE_ORIGINAL);
await repository.write(
ME,
{ upserts: [toIndexRow(LATE_ORIGINAL)] },
new Date(),
);
let keys = await keysOf(ME);
for (const id of ['t-late-orig', 't-late-r1', 't-late-r2']) {
expect(keys[id]).toBe('t-late-orig');
}
await repository.write(
ME,
{ upserts: [toIndexRow(DOCUMENTS[1]), toIndexRow(DOCUMENTS[8])] },
new Date(),
);
keys = await keysOf(ME);
expect(keys['t-re']).toBe('t-fwd');
expect(keys['t-late-r1']).toBe('t-late-orig');
});
it('같은 대화 — 오래된 차례 · 보낸편지함 포함 · 휴지통에만 든 메일은 빼되 지금 메일은 넣는다, total · basis index', async () => {
const page = dataOf<ConversationPage>(
await conversation('t-rere').expect(200),
);
expect(page.items.map((item) => item.id)).toEqual([
't-orig',
't-re',
't-hoesin',
't-fwd',
't-rere',
]);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 5,
});
expect(page.basis).toBe('index');
const fromTrash = dataOf<ConversationPage>(
await conversation('t-trash').expect(200),
);
expect(fromTrash.items.map((item) => item.id)).toContain('t-trash');
expect(fromTrash.pageInfo.total).toBe(6);
// 발송 중인 내부 초안도 그 메일을 열면 넣는다 (엔진 방식과 같다)
const sending = dataOf<ConversationPage>(
await conversation('t-sending').expect(200),
);
expect(sending.items.map((item) => item.id)).toEqual([
't-orig',
't-re',
't-hoesin',
't-fwd',
't-rere',
't-sending',
]);
expect(sending.pageInfo.total).toBe(6);
// 한 통뿐인 대화는 그 메일 하나
const alone = dataOf<ConversationPage>(
await conversation('t-other').expect(200),
);
expect(alone.items.map((item) => item.id)).toEqual(['t-other']);
});
it('같은 대화의 쪽 넘기기 — 커서는 그 메일의 대화에서만, limit 처음 값이자 최대 100', async () => {
const first = dataOf<ConversationPage>(
await conversation('t-rere', { limit: 2 }).expect(200),
);
expect(first.items.map((item) => item.id)).toEqual(['t-orig', 't-re']);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 });
const second = dataOf<ConversationPage>(
await conversation('t-rere', {
limit: 2,
cursor: first.pageInfo.nextCursor as string,
}).expect(200),
);
expect(second.items.map((item) => item.id)).toEqual([
't-hoesin',
't-fwd',
]);
expect(second.pageInfo.total).toBeNull();
for (const [id, query] of [
['t-re', { cursor: first.pageInfo.nextCursor as string }],
['t-rere', { limit: 101 }],
['t-rere', { limit: 0 }],
['t-rere', { q: '견적' }],
] as const) {
const res = await conversation(id, query).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('색인 준비 중 · 색인에 없는 메일은 엔진의 스레드(basis engine) — 엔진에도 없으면 404 RES_001', async () => {
const before = engineConversations.length;
const missing = await conversation(MISSING_MESSAGE).expect(404);
expect(body(missing).error?.code).toBe('RES_001');
await setStatus('building');
try {
const engine = dataOf<ConversationPage>(
await conversation('t-re').expect(200),
);
expect(engine.basis).toBe('engine');
} finally {
await setStatus('ready');
}
expect(
engineConversations.slice(before).map((call) => call.emailId),
).toEqual([MISSING_MESSAGE, 't-re']);
});
it('대화로 묶은 목록 — 그 메일함 안 마지막 받은 시각 최신부터, 통 수는 대화 전체(휴지통에만 든 것 빼고) · 그 메일함 안 id · 읽음 · 참여자', async () => {
const page = dataOf<ConversationListPage>(
await conversations({ mailboxId: 'inbox' }).expect(200),
);
expect(page.basis).toBe('index');
expect(page.items.map((row) => row.latest.id)).toEqual([
't-late-r2',
't-nomid',
't-other',
't-rere',
]);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 4,
});
const quote = page.items[3];
expect(quote).toMatchObject({
id: 't-fwd',
messageCount: 5,
messageIds: ['t-rere', 't-fwd', 't-re', 't-orig'],
pendingSendIds: [],
isRead: false,
isFlagged: false,
hasAttachment: false,
});
expect(quote.participants.map((p) => p.email)).toEqual([
'doyun.lee@e2e.arasystem.test',
ME,
]);
expect(page.items[0]).toMatchObject({
id: 't-late-orig',
messageCount: 3,
messageIds: ['t-late-r2', 't-late-r1', 't-late-orig'],
});
// 보낸편지함에서는 그 메일함의 메일만 — 통 수는 대화 전체. 붙잡힌 메일(보내기 취소를 기다리는)은 pendingSendIds 로 (리뷰 L6)
const before = heldSummaryRequests.length;
fakeHeldIds.add('t-hoesin');
try {
const sent = dataOf<ConversationListPage>(
await conversations({ mailboxId: 'sent' }).expect(200),
);
expect(sent.items).toHaveLength(1);
expect(sent.items[0]).toMatchObject({
latest: { id: 't-hoesin' },
messageIds: ['t-hoesin'],
pendingSendIds: ['t-hoesin'],
messageCount: 5,
});
} finally {
fakeHeldIds.clear();
}
// 기다리는 제출은 쪽마다 한 번 — 마지막 메일의 요약과 같은 요청
expect(heldSummaryRequests.slice(before)).toEqual([
{ user: ME, ids: ['t-hoesin'] },
]);
});
it('참여자는 첫 보낸 사람 칸에서 — 이름 안의 `<주소>` 는 참여자 주소가 되지 않는다 (리뷰 L3)', async () => {
const keys = await keysOf(ME);
expect(keys['t-notice-re']).toBe('t-notice');
const senders = await dataSource.query<
{
email_id: string;
from_email: string | null;
from_name: string | null;
}[]
>(
'SELECT email_id, from_email, from_name FROM mail_search_messages WHERE user_email = $1 AND email_id = ANY($2::text[]) ORDER BY email_id',
[ME, ['t-notice', 't-notice-re']],
);
expect(senders).toEqual([
{ email_id: 't-notice', from_email: null, from_name: null },
{
email_id: 't-notice-re',
from_email: 'doyun.lee@e2e.arasystem.test',
from_name: '이도윤',
},
]);
const notice = dataOf<ConversationListPage>(
await conversations({ mailboxId: 'notice' }).expect(200),
);
expect(notice.items).toHaveLength(1);
expect(notice.items[0]).toMatchObject({
messageCount: 2,
messageIds: ['t-notice-re', 't-notice'],
participants: [
{ name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' },
],
});
});
it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음)는 두 경로 모두 400 VAL_001 — 색인에 없는 메일이어도 404 보다 먼저 (리뷰 L1 · L8)', async () => {
const forged = (payload: Record<string, unknown>) =>
Buffer.from(JSON.stringify(payload)).toString('base64url');
for (const [id, cursor] of [
['t-rere', forged({ m: 'ti' })],
['t-rere', forged({ v: 1, m: 'ti', t: 'x', k: '../a', s: 'zz' })],
[MISSING_MESSAGE, forged({ m: 'ti' })],
[MISSING_MESSAGE, forged({ v: 1, m: 'ti', t: 1, k: 'a', s: 'zz' })],
] as const) {
const res = await conversation(id, { cursor }).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
for (const cursor of [
forged({ m: 'li' }),
forged({ v: 1, m: 'li', t: -1, k: 't-fwd', s: 'zz' }),
]) {
const res = await conversations({ mailboxId: 'inbox', cursor }).expect(
400,
);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('묶은 목록의 쪽 넘기기 · 범위 — 커서는 그 메일함에서만, 없는 메일함은 404 · mailboxId 없음은 400', async () => {
const first = dataOf<ConversationListPage>(
await conversations({ mailboxId: 'inbox', limit: 2 }).expect(200),
);
expect(first.items.map((row) => row.latest.id)).toEqual([
't-late-r2',
't-nomid',
]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
const second = dataOf<ConversationListPage>(
await conversations({
mailboxId: 'inbox',
limit: 2,
cursor: first.pageInfo.nextCursor as string,
}).expect(200),
);
expect(second.items.map((row) => row.latest.id)).toEqual([
't-other',
't-rere',
]);
expect(second.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: null,
});
const otherMailbox = await conversations({
mailboxId: 'sent',
cursor: first.pageInfo.nextCursor as string,
}).expect(400);
expect(body(otherMailbox).error?.code).toBe('VAL_001');
const missing = await conversations({
mailboxId: MISSING_MAILBOX,
}).expect(404);
expect(body(missing).error?.code).toBe('RES_001');
const invalid: Record<string, string>[] = [
{},
{ mailboxId: '../a' },
{ mailboxId: 'inbox', q: '견적' },
];
for (const query of invalid) {
const res = await conversations(query).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('색인 준비 중이면 엔진의 스레드(basis engine — collapseThreads) · 다른 직원의 줄은 섞이지 않는다', async () => {
const before = engineConversationLists.length;
await setStatus('building');
try {
const engine = dataOf<ConversationListPage>(
await conversations({ mailboxId: 'inbox' }).expect(200),
);
expect(engine.basis).toBe('engine');
} finally {
await setStatus('ready');
}
expect(engineConversationLists.slice(before)).toEqual([
{
user: ME,
query: { mailboxId: 'inbox', limit: 20, cursor: undefined },
},
]);
const mine = dataOf<ConversationListPage>(
await conversations({ mailboxId: 'inbox' }).expect(200),
);
expect(
mine.items
.flatMap((row) => [row.id, ...row.messageIds])
.filter((id) => id.startsWith('x-')),
).toEqual([]);
});
});
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
let systemSettings: SystemSettingsService;
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
+1 -1
View File
@@ -2,7 +2,7 @@
* 메일 검색의 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다).
* 서버의 색인 · 찾기(src/shared/utils/search-text.ts · subject-base.ts)와 화면의 미리 확인 · 강조(frontend `shared/utils/search-match.ts`)를 같은 표로 본다.
*
* ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로 — 14단계 frontend 차례에 옮긴다).
* ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로).
* 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다).
* 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다).
*/