generated from ttipo/project-base
feat(backend): 메일 검색을 자체 색인으로 바꾸고 조건 검색 · 강조를 더한다
14단계 2차다. 색인이 준비된 직원은 서버의 자체 색인으로, 채우는 중인 직원은 지금처럼 엔진으로 찾는다(방식은 첫 쪽에서 정하고 커서가 잇는다). - 조건 from · to(참조 포함) · subject · body · after · before · hasAttachment — q 와 합친 낱말 8개까지 - 부분 일치(대소문자 · 전각 · NFC), 구절은 줄바꿈 · NBSP 로 갈린 글에도, 한 글자 낱말뿐이면 제목 · 사람 · 첨부 이름에서만(2026-11-05 사용자 확정) - 찾기: GIN(lower(user_email), grams) 후보 → 기본 키로 확인, 한 요청 확인 3,000줄 상한 + 이어 찾기 커서, total 은 첫 쪽에서 1,000 까지(못 세면 null) — 1만 통에서 가장 무거운 검색 0.8초 - 목록 항목 searchHighlight(제목 · 맞은 말 둘레 미리 보기), 읽기 화면은 정화한 HTML 의 글 조각에 <mark> · 제목 · 첨부 이름 · 글 본문 위치 - 마이그레이션 SearchIndexUserGrams(btree_gin), 색인 판 2(공백 접기) — 판이 바뀌면 그 직원의 색인을 지우고 밤에 다시 채운다 리뷰 반영: 옛 판 줄 지우기, 꾸민 커서 · NUL 은 VAL_001 · U+FFFD, 한 글자 길 한 트랜잭션, 다른 직원 줄 격리 e2e. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -128,7 +128,9 @@ import {
|
||||
type MailIndexSource,
|
||||
} from '@/modules/mail-search/mail-index-source';
|
||||
import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
|
||||
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
|
||||
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
|
||||
import { INDEX_FORMAT } from '@/modules/mail-search/mail-search.constants';
|
||||
import { toIndexRow } from '@/modules/mail-search/index-document';
|
||||
import {
|
||||
SystemSettingsService,
|
||||
@@ -602,6 +604,37 @@ function indexDocument(overrides: Partial<IndexDocument>): IndexDocument {
|
||||
};
|
||||
}
|
||||
|
||||
/** 대역 엔진이 색인 검색에서 빼는 메일함 (휴지통 · 스팸메일함 역할 — 14단계 2차) */
|
||||
const SEARCH_EXCLUDED_MAILBOX = 'idxtrash';
|
||||
|
||||
/** 색인 문서의 주소 → 요약의 주소 (주소가 없는 것은 뺀다) */
|
||||
function mailAddressesOf(
|
||||
addresses: readonly { name: string | null; email: string | null }[],
|
||||
): { name: string | null; email: string }[] {
|
||||
return addresses.flatMap(({ name, email }) =>
|
||||
email === null ? [] : [{ name, email }],
|
||||
);
|
||||
}
|
||||
|
||||
/** 대역 엔진의 요약 — 색인 문서와 같은 메일 (14단계 2차 — 색인으로 찾은 쪽의 요약은 엔진에서 읽는다) */
|
||||
function summaryOfDocument(document: IndexDocument): MessageSummary {
|
||||
return {
|
||||
id: document.id,
|
||||
threadId: document.threadId ?? document.id,
|
||||
mailboxIds: [...document.mailboxIds],
|
||||
from: mailAddressesOf(document.from)[0] ?? null,
|
||||
to: mailAddressesOf(document.to),
|
||||
subject: document.subject,
|
||||
preview: document.preview,
|
||||
receivedAt: new Date(document.receivedAt).toISOString(),
|
||||
isRead: document.keywords.includes('$seen'),
|
||||
isFlagged: document.keywords.includes('$flagged'),
|
||||
hasAttachment: document.hasAttachment,
|
||||
size: document.size,
|
||||
pendingSend: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 목록 응답의 data — 계약의 { items, pageInfo } */
|
||||
interface Page<T> {
|
||||
items: T[];
|
||||
@@ -713,6 +746,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
|
||||
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
|
||||
const listQueries: { user: string; query: MessageListQuery }[] = [];
|
||||
/** 14단계 2차 — 대역 엔진이 받은 요약 요청 (색인으로 찾은 쪽) */
|
||||
const summaryRequests: { user: string; ids: readonly string[] }[] = [];
|
||||
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
|
||||
const bulkUpdates: {
|
||||
user: string;
|
||||
@@ -1235,6 +1270,46 @@ describe('응답 계약 (e2e)', () => {
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
// 14단계 2차 — 색인 검색의 범위 · 찾은 쪽의 요약 (엔진 쪽 판단은 jmap-mail.provider.spec.ts 가 본다)
|
||||
searchScope: (
|
||||
_user: string,
|
||||
mailboxId?: string,
|
||||
): Promise<SearchScope> => {
|
||||
if (mailboxId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-mailbox-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
mailboxId === undefined
|
||||
? {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
}
|
||||
: {
|
||||
mailboxId,
|
||||
excludedMailboxIds: [],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
},
|
||||
);
|
||||
},
|
||||
getSummaries: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageSummary[]> => {
|
||||
summaryRequests.push({ user, ids });
|
||||
const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? [];
|
||||
const byId = new Map(emails.map((email) => [email.id, email]));
|
||||
return Promise.resolve(
|
||||
ids.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [summaryOfDocument(email)] : [];
|
||||
}),
|
||||
);
|
||||
},
|
||||
updateMessages: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
@@ -7840,6 +7915,498 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('14단계 2차 — 색인으로 찾기 · 조건 · 강조 (GET /api/messages · GET /api/messages/{id})', () => {
|
||||
const ME = 'search.query@e2e.arasystem.test';
|
||||
const SEOJUN = { name: '박서준', email: 'seojun.park@e2e.arasystem.test' };
|
||||
const DOCUMENTS = [
|
||||
indexDocument({
|
||||
id: 'm-meeting',
|
||||
subject: '회의 일정',
|
||||
bodyText: '내일 회의는 3층 회의실에서 합니다',
|
||||
preview: '내일 회의는 3층 회의실에서 합니다',
|
||||
from: [SEOJUN],
|
||||
to: [{ name: '김아라', email: ME }],
|
||||
receivedAt: '2026-11-05T01:00:00Z',
|
||||
}),
|
||||
indexDocument({
|
||||
id: 'm-contract',
|
||||
subject: '[공지] 11월 계약서',
|
||||
bodyText: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
|
||||
preview: '계약서를 보내 드립니다.',
|
||||
attachmentNames: ['계약서.PDF'],
|
||||
hasAttachment: true,
|
||||
cc: [{ name: 'Partner', email: 'partner@example.com' }],
|
||||
receivedAt: '2026-11-04T01:00:00Z',
|
||||
}),
|
||||
indexDocument({
|
||||
id: 'm-renewal',
|
||||
subject: '회의록',
|
||||
bodyText: '계약 갱신 회의 내용을 정리했습니다',
|
||||
preview: '계약 갱신 회의 내용을 정리했습니다',
|
||||
receivedAt: '2026-11-03T01:00:00Z',
|
||||
}),
|
||||
indexDocument({
|
||||
id: 'm-floor',
|
||||
subject: '7층 공사 안내',
|
||||
bodyText: '공사 기간 동안 엘리베이터를 쓸 수 없습니다',
|
||||
receivedAt: '2026-11-02T01:00:00Z',
|
||||
}),
|
||||
// 휴지통에만 든 메일 — 메일함을 정하지 않은 검색에서 빠진다
|
||||
indexDocument({
|
||||
id: 'm-trash',
|
||||
subject: '지난 계약 해지',
|
||||
bodyText: '계약 해지 통보',
|
||||
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||||
receivedAt: '2026-11-01T01:00:00Z',
|
||||
}),
|
||||
];
|
||||
|
||||
interface ListItem {
|
||||
id: string;
|
||||
subject: string;
|
||||
searchHighlight: {
|
||||
subject: [number, number][];
|
||||
preview: { text: string; ranges: [number, number][] };
|
||||
} | null;
|
||||
}
|
||||
|
||||
const list = (params: Record<string, string | number>) =>
|
||||
request(server)
|
||||
.get('/api/messages')
|
||||
.query(params)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
const idsOf = async (params: Record<string, string | number>) =>
|
||||
dataOf<Page<ListItem>>(await list(params).expect(200)).items.map(
|
||||
(item) => item.id,
|
||||
);
|
||||
const setStatus = (status: 'ready' | 'building') =>
|
||||
dataSource.query(
|
||||
'UPDATE mail_search_states SET status = $2 WHERE user_email = $1',
|
||||
[ME, status],
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
// 상태 줄은 준비 상태를 물을 때 생긴다 — 채우기로 ready (색인 판은 지금 판)
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
fakeIndexSource.mailboxes.set(ME, { state: 'q1', emails: DOCUMENTS });
|
||||
const build = app.get(MailSearchBuildService);
|
||||
expect(await build.build(ME, Date.now() + 120_000)).toBe('ready');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
fakeIndexSource.mailboxes.delete(ME);
|
||||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||||
await dataSource.query(`DELETE FROM "${table}" WHERE user_email = $1`, [
|
||||
ME,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('색인이 준비되면 부분 일치로 찾는다 — 휴지통 · 스팸메일함에만 든 메일은 빼고 받은 시각 최신부터, 요약은 엔진에서 · 첫 쪽의 total', async () => {
|
||||
const queriesBefore = listQueries.length;
|
||||
|
||||
const res = await list({ q: '계약' }).expect(200);
|
||||
const page = dataOf<Page<ListItem>>(res);
|
||||
|
||||
expect(page.items.map((item) => item.id)).toEqual([
|
||||
'm-contract',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
// 엔진의 목록 검색은 묻지 않았다 — 찾은 쪽의 요약만
|
||||
expect(listQueries.length).toBe(queriesBefore);
|
||||
expect(summaryRequests.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['m-contract', 'm-renewal'],
|
||||
});
|
||||
});
|
||||
|
||||
it('강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳 (UTF-16 [시작, 끝))', async () => {
|
||||
const [contract] = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약 갱신' }).expect(200),
|
||||
).items;
|
||||
|
||||
expect(contract.id).toBe('m-contract');
|
||||
expect(contract.searchHighlight).toEqual({
|
||||
subject: [[9, 11]],
|
||||
preview: {
|
||||
text: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
|
||||
ranges: [
|
||||
[0, 2],
|
||||
[14, 16],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('조건 — 보낸 사람 · 받는 사람(참조 포함) · 제목 · 내용 · 기간 · 첨부, 검색어 없이도 · 검색어와 함께면 모두 맞아야', async () => {
|
||||
expect(await idsOf({ from: '박서준' })).toEqual(['m-meeting']);
|
||||
expect(await idsOf({ from: 'seojun' })).toEqual(['m-meeting']);
|
||||
expect(await idsOf({ to: 'partner' })).toEqual(['m-contract']);
|
||||
expect(await idsOf({ subject: '회의' })).toEqual([
|
||||
'm-meeting',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(await idsOf({ body: '회의실' })).toEqual(['m-meeting']);
|
||||
expect(await idsOf({ hasAttachment: 'true' })).toEqual(['m-contract']);
|
||||
expect(
|
||||
await idsOf({
|
||||
after: '2026-11-03T00:00:00.000Z',
|
||||
before: '2026-11-04T01:00:00.000Z',
|
||||
}),
|
||||
).toEqual(['m-renewal']);
|
||||
expect(await idsOf({ q: '회의', from: '이도윤' })).toEqual(['m-renewal']);
|
||||
expect(await idsOf({ q: '"갱신 일정"' })).toEqual(['m-contract']);
|
||||
// 메일함 범위
|
||||
expect(await idsOf({ mailboxId: 'inbox', q: '계약' })).toEqual([
|
||||
'm-contract',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(
|
||||
await idsOf({ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' }),
|
||||
).toEqual(['m-trash']);
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐이면 제목 · 사람 · 첨부 이름에서만 — 본문에만 있는 메일은 빠진다', async () => {
|
||||
// '층' 은 m-floor 의 제목 · m-meeting 의 본문에 있다
|
||||
expect(await idsOf({ q: '층' })).toEqual(['m-floor']);
|
||||
// 두 글자 넘는 낱말과 함께면 본문에서도 확인한다
|
||||
expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']);
|
||||
});
|
||||
|
||||
it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => {
|
||||
const first = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1 }).expect(200),
|
||||
);
|
||||
expect(first.items.map((item) => item.id)).toEqual(['m-contract']);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
|
||||
const cursor = first.pageInfo.nextCursor as string;
|
||||
|
||||
const second = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1, cursor }).expect(200),
|
||||
);
|
||||
expect(second.items.map((item) => item.id)).toEqual(['m-renewal']);
|
||||
expect(second.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: null,
|
||||
});
|
||||
|
||||
const others: Record<string, string>[] = [
|
||||
{ q: '견적', cursor },
|
||||
{ q: '계약', from: '이도윤', cursor },
|
||||
{ q: '계약', mailboxId: 'inbox', cursor },
|
||||
];
|
||||
for (const params of others) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('쪽을 넘기는 중에는 처음 방식대로 — 색인 커서는 다시 채우기에 들어가도 색인으로, 준비 중의 새 검색은 엔진 방식(조건도 넘긴다 · 강조 없음)', async () => {
|
||||
const cursor = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1 }).expect(200),
|
||||
).pageInfo.nextCursor as string;
|
||||
await setStatus('building');
|
||||
try {
|
||||
const queriesBefore = listQueries.length;
|
||||
const next = dataOf<Page<ListItem>>(
|
||||
await list({ q: '계약', limit: 1, cursor }).expect(200),
|
||||
);
|
||||
expect(next.items.map((item) => item.id)).toEqual(['m-renewal']);
|
||||
expect(listQueries.length).toBe(queriesBefore);
|
||||
|
||||
const engine = dataOf<Page<ListItem>>(
|
||||
await list({
|
||||
q: ' 계약 ',
|
||||
from: '이도윤',
|
||||
hasAttachment: 'true',
|
||||
}).expect(200),
|
||||
);
|
||||
expect(engine.items).toEqual([]);
|
||||
expect(listQueries.slice(queriesBefore)).toEqual([
|
||||
{
|
||||
user: ME,
|
||||
query: {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: '계약',
|
||||
conditions: { from: '이도윤', hasAttachment: true },
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await setStatus('ready');
|
||||
}
|
||||
});
|
||||
|
||||
it('다른 직원의 줄은 섞이지 않는다 — 같은 낱말 · 같은 메일함 id 여도 (두 글자 · 확인 · 한 글자 · 조건만 · 메일함 범위 — 리뷰 L6)', async () => {
|
||||
const OTHER = 'search.neighbor@e2e.arasystem.test';
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(OTHER))
|
||||
.expect(200);
|
||||
// 엔진의 메일함 id 는 계정마다 같은 꼴이다(inbox) — 글도 ME 의 것과 같게
|
||||
fakeIndexSource.mailboxes.set(OTHER, {
|
||||
state: 'n1',
|
||||
emails: DOCUMENTS.map((document) => ({
|
||||
...document,
|
||||
id: `x-${document.id}`,
|
||||
})),
|
||||
});
|
||||
try {
|
||||
const build = app.get(MailSearchBuildService);
|
||||
expect(await build.build(OTHER, Date.now() + 120_000)).toBe('ready');
|
||||
|
||||
const searches: Record<string, string>[] = [
|
||||
{ q: '계약' },
|
||||
{ q: '계약서' },
|
||||
{ q: '층' },
|
||||
{ subject: '회의' },
|
||||
{ hasAttachment: 'true' },
|
||||
{ after: '2026-11-01T00:00:00.000Z' },
|
||||
{ mailboxId: 'inbox', q: '회의' },
|
||||
{ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' },
|
||||
];
|
||||
for (const params of searches) {
|
||||
const before = summaryRequests.length;
|
||||
const ids = await idsOf(params);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
// 색인이 찾은 id 그대로(엔진에 요약을 묻기 전) — 다른 직원의 줄이 없다
|
||||
const asked = summaryRequests
|
||||
.slice(before)
|
||||
.flatMap((request) => [...request.ids]);
|
||||
expect(asked.length).toBeGreaterThan(0);
|
||||
expect(asked.filter((id) => id.startsWith('x-'))).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
fakeIndexSource.mailboxes.delete(OTHER);
|
||||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||||
await dataSource.query(
|
||||
`DELETE FROM "${table}" WHERE user_email = $1`,
|
||||
[OTHER],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('검색 글의 NUL 은 U+FFFD 로 바꿔 찾는다 — DB 오류(500)가 아니다, 엔진 방식도 (리뷰 L2)', async () => {
|
||||
const NUL = String.fromCharCode(0);
|
||||
expect(await idsOf({ q: `계약${NUL}` })).toEqual([
|
||||
'm-contract',
|
||||
'm-renewal',
|
||||
]);
|
||||
expect(await idsOf({ q: `계${NUL}약`, subject: `회${NUL}의` })).toEqual(
|
||||
[],
|
||||
);
|
||||
await setStatus('building');
|
||||
try {
|
||||
const res = await list({ q: `계${NUL}약` }).expect(200);
|
||||
expect(dataOf<Page<ListItem>>(res).items).toEqual([]);
|
||||
} finally {
|
||||
await setStatus('ready');
|
||||
}
|
||||
});
|
||||
|
||||
it('검색어와 조건 칸 넷의 낱말은 합쳐 8개까지 — 넘으면 400 VAL_001 (읽기 화면도 같다 — 리뷰 M2)', async () => {
|
||||
const words = (prefix: string, count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(
|
||||
' ',
|
||||
);
|
||||
await list({ q: words('q', 4), to: words('t', 4) }).expect(200);
|
||||
const overLimit: Record<string, string>[] = [
|
||||
{ q: words('q', 4), to: words('t', 5) },
|
||||
{ from: words('f', 3), subject: words('s', 3), body: words('b', 3) },
|
||||
];
|
||||
for (const params of overLimit) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
const detail = await request(server)
|
||||
.get(`/api/messages/${IMAGE_MESSAGE}`)
|
||||
.query({ q: words('q', 5), body: words('b', 4) })
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(400);
|
||||
expect(body(detail).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('판을 되돌리면(서버가 뜰 때) building · 엔진 방식이 되고, 다시 채울 때 첫 묶음이 옛 판 줄을 지운다 — 실제 PostgreSQL (리뷰 M1)', async () => {
|
||||
const RESET = 'search.reset@e2e.arasystem.test';
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(RESET))
|
||||
.expect(200);
|
||||
fakeIndexSource.mailboxes.set(RESET, {
|
||||
state: 'r1',
|
||||
emails: DOCUMENTS.map((document) => ({
|
||||
...document,
|
||||
id: `r-${document.id}`,
|
||||
})),
|
||||
});
|
||||
try {
|
||||
const build = app.get(MailSearchBuildService);
|
||||
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
|
||||
// 옛 판으로 만든 줄처럼 — 판을 내리고 엔진에 없는 줄도 하나 남긴다
|
||||
await dataSource.query(
|
||||
`UPDATE mail_search_states SET index_format = $2 WHERE user_email = $1`,
|
||||
[RESET, INDEX_FORMAT - 1],
|
||||
);
|
||||
await dataSource.query(
|
||||
`UPDATE mail_search_messages SET fold_body = 'old-format' WHERE user_email = $1`,
|
||||
[RESET],
|
||||
);
|
||||
|
||||
// 판이 다르면 되돌리기 전에도 상태는 building (검색은 엔진 방식)
|
||||
expect(
|
||||
dataOf(
|
||||
await request(server)
|
||||
.get('/api/search-index')
|
||||
.set('Cookie', cookieOf(RESET))
|
||||
.expect(200),
|
||||
),
|
||||
).toEqual({ status: 'building', done: 0, total: null });
|
||||
|
||||
const repository = app.get(MailSearchIndexRepository);
|
||||
expect(
|
||||
await repository.resetOutdatedFormats(
|
||||
`@${TEST_MAIL_SETTINGS.domain}`,
|
||||
INDEX_FORMAT,
|
||||
new Date(),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
const [state]: {
|
||||
status: string;
|
||||
index_format: number;
|
||||
build_started_at: Date | null;
|
||||
}[] = await dataSource.query(
|
||||
'SELECT status, index_format, build_started_at FROM mail_search_states WHERE user_email = $1',
|
||||
[RESET],
|
||||
);
|
||||
expect(state).toEqual({
|
||||
status: 'building',
|
||||
index_format: INDEX_FORMAT,
|
||||
build_started_at: null,
|
||||
});
|
||||
|
||||
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
|
||||
const rows: { email_id: string; fold_body: string }[] =
|
||||
await dataSource.query(
|
||||
'SELECT email_id, fold_body FROM mail_search_messages WHERE user_email = $1 ORDER BY email_id',
|
||||
[RESET],
|
||||
);
|
||||
expect(rows.map((row) => row.email_id)).toEqual(
|
||||
DOCUMENTS.map((document) => `r-${document.id}`).sort(),
|
||||
);
|
||||
expect(rows.filter((row) => row.fold_body === 'old-format')).toEqual(
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
fakeIndexSource.mailboxes.delete(RESET);
|
||||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||||
await dataSource.query(
|
||||
`DELETE FROM "${table}" WHERE user_email = $1`,
|
||||
[RESET],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('검색 칸의 검사 — 길이 · 낱말 수 · 기간 형식 · 첨부 값이 틀리면 400 VAL_001 (엔진 · 색인에 묻지 않는다)', async () => {
|
||||
const before = summaryRequests.length;
|
||||
const rejected: Record<string, string>[] = [
|
||||
{ subject: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||||
{
|
||||
body: Array.from(
|
||||
{ length: MAX_SEARCH_WORDS + 1 },
|
||||
(_, index) => `w${index}`,
|
||||
).join(' '),
|
||||
},
|
||||
{ after: '2026-11-01' },
|
||||
{ before: '2026-11-01T00:00:00+09:00' },
|
||||
{ hasAttachment: 'false' },
|
||||
];
|
||||
for (const params of rejected) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(summaryRequests.length).toBe(before);
|
||||
});
|
||||
|
||||
describe('읽기 화면의 강조 (GET /api/messages/{id} 의 q · subject · body)', () => {
|
||||
interface ReadDetail {
|
||||
subject: string;
|
||||
htmlBody: string | null;
|
||||
textBody: string | null;
|
||||
imagesShown: boolean;
|
||||
searchHighlight: {
|
||||
subject: [number, number][];
|
||||
attachments: Record<string, [number, number][]>;
|
||||
textBody: [number, number][];
|
||||
} | null;
|
||||
}
|
||||
const read = (query: Record<string, string>) =>
|
||||
request(server)
|
||||
.get(`/api/messages/${IMAGE_MESSAGE}`)
|
||||
.query(query)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
it('검색 쿼리가 없으면 searchHighlight 는 null', async () => {
|
||||
const detail = dataOf<ReadDetail>(await read({}).expect(200));
|
||||
expect(detail.searchHighlight).toBeNull();
|
||||
expect(detail.htmlBody).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('검색어 — 본문의 글에 <mark>(정화한 뒤 — 태그 · 속성은 그대로) · textBody 의 위치, 보낸 사람 조건은 강조하지 않는다', async () => {
|
||||
const detail = dataOf<ReadDetail>(
|
||||
await read({ q: '안녕', from: '바깥' }).expect(200),
|
||||
);
|
||||
expect(detail.searchHighlight).toEqual({
|
||||
subject: [],
|
||||
attachments: {},
|
||||
textBody: [[0, 2]],
|
||||
});
|
||||
expect(detail.htmlBody).toContain('<p><mark>안녕</mark>하세요</p>');
|
||||
expect(detail.htmlBody).not.toContain('<mark>로고');
|
||||
});
|
||||
|
||||
it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => {
|
||||
const detail = dataOf<ReadDetail>(
|
||||
await read({ images: 'show', subject: '그림', q: '안녕' }).expect(
|
||||
200,
|
||||
),
|
||||
);
|
||||
expect(detail.imagesShown).toBe(true);
|
||||
expect(detail.htmlBody).toContain('/api/mail-images/');
|
||||
expect(detail.htmlBody).toContain('<mark>안녕</mark>');
|
||||
expect(detail.searchHighlight?.subject).toEqual([[0, 2]]);
|
||||
});
|
||||
|
||||
it('검색 칸은 목록과 같은 검사 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다 — 400 VAL_001', async () => {
|
||||
const rejected: Record<string, string>[] = [
|
||||
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||||
{ after: '2026-11-01T00:00:00.000Z' },
|
||||
{ before: '2026-11-30T15:00:00.000Z' },
|
||||
{ hasAttachment: 'true' },
|
||||
{ cursor: 'abc' },
|
||||
{ limit: '20' },
|
||||
{ mailboxId: 'inbox' },
|
||||
];
|
||||
for (const query of rejected) {
|
||||
const res = await read(query).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
|
||||
let systemSettings: SystemSettingsService;
|
||||
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
|
||||
|
||||
@@ -18,8 +18,10 @@ export const FOLD_SAMPLES: readonly (readonly [
|
||||
['ABC123!', 'abc123!'],
|
||||
['RE:회의', 're:회의'],
|
||||
['[공지]', '[공지]'],
|
||||
// 전각 공백(U+3000)은 바꾸지 않는다 — 공백은 위치를 지키려고 그대로 둔다
|
||||
['회의\u3000일정', '회의\u3000일정'],
|
||||
// 공백류(전각 공백 · 줄바꿈 · 탭 · NBSP · CR)는 띄어쓰기 하나로 1:1 (색인 판 2) — 이어진 공백을 줄이지는 않는다(위치를 지킨다)
|
||||
['회의\u3000일정', '회의 일정'],
|
||||
['계약\n갱신\t안내\u00a0끝', '계약 갱신 안내 끝'],
|
||||
['a\r\n\nb', 'a b'],
|
||||
// 소문자가 코드 포인트 하나 · 같은 길이일 때만 — 'İ'(U+0130)는 'i̇' 두 글자가 되므로 그대로
|
||||
['İstanbul', 'İstanbul'],
|
||||
['ÀÉÎÕÜ', 'àéîõü'],
|
||||
@@ -84,6 +86,9 @@ export const MATCH_SAMPLES: readonly (readonly [
|
||||
[3, 5],
|
||||
],
|
||||
],
|
||||
// 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 (공백류 → 띄어쓰기 1:1) — 위치는 원문 그대로
|
||||
['계약\n갱신 안내', ['계약 갱신'], [[0, 5]]],
|
||||
['계약\u00a0갱신', ['계약 갱신'], [[0, 5]]],
|
||||
// 구절은 띄어쓰기까지 그대로
|
||||
['회의 일정 안내', ['회의 일정'], [[0, 5]]],
|
||||
['회의 일정', ['회의 일정'], []],
|
||||
|
||||
Reference in New Issue
Block a user