generated from ttipo/project-base
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:
@@ -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 다). 준비가 실패했으면 없다 */
|
||||
|
||||
Reference in New Issue
Block a user