Compare commits

..

2 Commits

Author SHA1 Message Date
ttipo 349c312989 test(frontend): 대용량 파일을 다 올린 뒤 보낸다 — 조각 SHA-256 이 스레드 풀이라 부하에서 늦다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Failing after 10m31s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 14m21s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m18s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m30s
쓰기 화면의 두 테스트는 대용량 파일을 고른 뒤 양보(flushPromises) 두 번 만에 다 올렸다고
보았다. 조각의 SHA-256 은 jsdom 이 아니라 Node 의 Web Crypto(libuv 스레드 풀)라, CI 부하에서
그 안에 끝나지 않았다. 다 올리기 전에 누른 [보내기]는 막혀 보내기 요청이 끝내 나가지 않아
payload 가 undefined 였다(2026-11-03 CI — ComposePage.spec.ts 첨부 합계 테스트).

- 두 테스트(첨부 합계 · 링크로 보내기)는 대용량 줄이 ready 가 될 때까지 vi.waitFor 로 본다.
  첨부 합계 테스트는 보내기 전에 막는 까닭이 없음도 단언한다.
- test-utils/timeouts.ts 에 WAIT_FOR_TIMEOUT_MS(15초 — 테스트 한도 20초보다 짧게)를 둔다.
- digest 를 늦춘 실험으로 고치기 전에는 깨지고 고친 뒤에는 통과함을 확인했다(실험은 되돌림).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:26:28 +09:00
ttipo 2cd2764da8 test(backend): jmap.client spec — 가짜 응답 없는 fetch 는 연결 실패로 막고 빠진 응답을 잡는다
이 spec 은 fetch 를 spyOn 으로 감싸기만 해, 가짜 응답을 넣지 않은 호출이 진짜
http://mail:8080 으로 나갔다. 13단계부터 메일 계정이 없는 세션은 계정 정보를 한 번
더 묻는데, 그 요청이 CI 컨테이너에서 이름 풀기를 기다리다 테스트 5초를 넘겼다
(2026-11-03 CI — 개발 컨테이너에서는 개발 엔진이 실제로 받았다).

- 가짜 응답이 없는 fetch 는 곧바로 연결 실패로 거절하고 주소를 남긴다 — afterEach 가
  남은 주소가 있으면 그 테스트를 실패시킨다. jest-mock 30 의 mockReset 은 구현까지
  지우므로 resetFetchMock 이 기본을 다시 건다.
- 메일 계정이 없는 세션 · 실시간 연결 403 두 테스트에 계정 정보 응답을 넣고 요청을 단언.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:10:44 +09:00
4 changed files with 105 additions and 21 deletions
@@ -80,10 +80,40 @@ function decodeBasic(header: string): string {
return Buffer.from(header.replace(/^Basic /, ''), 'base64').toString('utf8');
}
/** Node fetch 의 연결 실패와 같은 모양 — 엔진에 닿지 못했다 */
function connectionRefused(): TypeError {
return new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
}),
});
}
describe('JmapClient', () => {
let fetchMock: jest.SpyInstance;
let cache: Cache;
let client: JmapClient;
/** 가짜 응답 없이 나간 fetch 의 주소 — 테스트가 끝날 때 비어 있어야 한다 (afterEach) */
let unansweredFetches: string[];
/**
* 가짜 응답을 넣지 않은 fetch 의 기본 — 진짜 네트워크로 나가지 않고 곧바로 연결 실패로 거절하며 주소를 남긴다.
* spy 의 기본(원래 fetch)이면 spec 의 엔진 주소 http://mail:8080 으로 진짜 요청이 나간다 — 개발 컨테이너에서는 개발 엔진이 받고,
* CI 에서는 그 이름을 늦게 풀면 테스트 제한 시간(5초)을 넘긴다 (2026-11-03 CI — 메일 계정이 없는 세션의 계정 정보 조회)
*/
const refuseUnanswered = (url: string): Promise<Response> => {
unansweredFetches.push(url);
return Promise.reject(connectionRefused());
};
/**
* 넣어 둔 가짜 응답 · 기록을 비우고 기본(연결 실패)을 다시 건다 — `fetchMock.mockReset()` 을 바로 부르지 않는다.
* jest-mock 30 의 mockReset 은 spy 의 구현까지 지워, 그 뒤로는 기본도 원래 fetch 도 아닌 undefined 를 돌려준다
*/
const resetFetchMock = (): void => {
fetchMock.mockReset();
fetchMock.mockImplementation(refuseUnanswered);
};
/** n 번째 fetch 호출의 URL·헤더 */
const requestAt = (index: number) => {
@@ -96,7 +126,9 @@ describe('JmapClient', () => {
};
beforeEach(() => {
unansweredFetches = [];
fetchMock = jest.spyOn(global, 'fetch');
fetchMock.mockImplementation(refuseUnanswered);
cache = fakeCache();
client = new JmapClient(settings, cache);
});
@@ -104,6 +136,8 @@ describe('JmapClient', () => {
afterEach(() => {
fetchMock.mockRestore();
jest.useRealTimers();
// 엔진에 묻는 일마다 응답을 직접 넣는다 — 빠진 응답이 있으면 연결 실패로 지나가 버리지 않게 여기서 그 테스트를 실패시킨다
expect(unansweredFetches).toEqual([]);
});
describe('직원 자격 확인 (verifyCredentials)', () => {
@@ -157,13 +191,7 @@ describe('JmapClient', () => {
'MAIL_001',
);
fetchMock.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
}),
}),
);
fetchMock.mockRejectedValueOnce(connectionRefused());
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe(
'MAIL_001',
);
@@ -288,7 +316,7 @@ describe('JmapClient', () => {
it('지금 비밀번호가 틀리면 세션 요청이 401 — 바꾸기를 보내지 않고 current-password-rejected (403 도 로그인 확인처럼 같다)', async () => {
for (const status of [401, 403]) {
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockResolvedValueOnce(json({}, status));
await expect(client.changePassword(ARA, 'wrong', NEXT)).resolves.toBe(
@@ -465,7 +493,7 @@ describe('JmapClient', () => {
'REQ_001',
);
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockResolvedValueOnce(json(sessionBody));
expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe(
'MAIL_001',
@@ -1038,12 +1066,32 @@ describe('JmapClient', () => {
expect(cache.set).not.toHaveBeenCalled();
});
it('메일 계정이 없는 세션은 MAIL_001', async () => {
fetchMock.mockResolvedValueOnce(
json({ ...sessionBody, primaryAccounts: {} }),
);
it('메일 계정이 없는 세션은 MAIL_001 — 먼저 대리 접근으로 계정 정보(권한)를 한 번 더 묻고, 비밀번호 만료의 모양이 아닐 때 (13단계)', async () => {
fetchMock
.mockResolvedValueOnce(json({ ...sessionBody, primaryAccounts: {} }))
// 보통 직원의 권한 — 메일 권한이 있어 비밀번호 만료가 아니다
.mockResolvedValueOnce(
json({
permissions: ['authenticate', 'emailReceive', 'jmapEmailGet'],
}),
);
expect(await codeOf(client.getSession(ARA))).toBe('MAIL_001');
const failure = await client
.getSession(ARA)
.catch((error: unknown) => error);
expect(failure).not.toBeInstanceOf(PasswordExpiredException);
expect((failure as BusinessException).code).toBe('MAIL_001');
// 만료가 아니니 세션이 모자란 것으로 — 계정 정보를 읽지 못해서가 아니다
expect((failure as BusinessException).context).toMatchObject({
reason: 'session-incomplete',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(requestAt(1).url).toBe('http://mail:8080/api/account');
expect(decodeBasic(requestAt(1).authorization)).toBe(
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
);
expect(cache.set).not.toHaveBeenCalled();
});
});
@@ -1291,7 +1339,7 @@ describe('JmapClient', () => {
it('제한 시간을 받으면 세션 조회(캐시에 없을 때)까지 그 안에서 한다 — 세션 조회에 쓴 시간만큼 호출이 짧아진다', async () => {
jest.useFakeTimers();
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((resolve, reject) => {
@@ -1326,7 +1374,7 @@ describe('JmapClient', () => {
it('세션 조회가 받은 제한 시간을 넘기면 거기서 MAIL_001 (timeout) — 호출은 보내지 않는다', async () => {
jest.useFakeTimers();
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
@@ -1672,7 +1720,7 @@ describe('JmapClient', () => {
expect(requestAt(0).url).toBe('http://mail:8080/jmap/session');
expect(requestAt(1).url).toContain('/jmap/eventsource/');
fetchMock.mockReset();
resetFetchMock();
// 부를 때마다 새 응답 — Response 본문은 한 번만 읽힌다
fetchMock.mockImplementation(() =>
Promise.resolve(json(withoutEventSource)),
@@ -1705,13 +1753,21 @@ describe('JmapClient', () => {
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(json({}, 403));
.mockResolvedValueOnce(json({}, 403))
// 게이트웨이 자신의 권한에 impersonate 가 없다 — 설정 문제
.mockResolvedValueOnce(json({ permissions: ['authenticate'] }));
const code = await runWithRequestContext({ requestId: 'trace-es' }, () =>
codeOf(
client.openEventSource(ARA, OPTIONS, new AbortController().signal),
),
);
expect(code).toBe('MAIL_001');
// 게이트웨이 자신의 권한을 읽었다(본인 Basic — 대리 접근이 아니다)
expect(fetchMock).toHaveBeenCalledTimes(6);
expect(requestAt(5).url).toBe('http://mail:8080/api/account');
expect(decodeBasic(requestAt(5).authorization)).toBe(
'mail-gateway@arasystem.test:gateway-secret',
);
expect(errorLog).toHaveBeenCalledWith(
expect.stringMatching(
/^\[MAIL_001\]\[trace-es\] .*대리 접근 권한이 없습니다/,
@@ -21,6 +21,7 @@ import {
} from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import { WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts'
import ComposePage from './ComposePage.vue'
// 쓰기 화면의 대용량 첨부 (11단계 — 디자인 원본 ComposeLargeFiles · 계약 4절 "발송 · 임시 저장과 함께" · "인용 속 링크의 상태")
@@ -367,7 +368,14 @@ describe('ComposePage — 대용량 첨부', () => {
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.uploading('1'))
releaseChunk()
await flushPromises()
// 조각을 받으면 끝내기까지 가 ready 가 된다 — 조각을 보내기 전의 SHA-256 은 Node 의 Web Crypto(스레드 풀)라
// 부하가 크면 양보 두 번으로는 끝나지 않는다 (ComposePage.spec.ts 의 첨부 합계 테스트와 같은 까닭) — ready 가 될 때까지 본다
await vi.waitFor(
() => {
if (phases(wrapper)[0] !== 'ready') throw new Error('대용량 파일을 올리는 중')
},
{ timeout: WAIT_FOR_TIMEOUT_MS },
)
await flushPromises()
expect(phases(wrapper)).toEqual(['ready'])
+14 -2
View File
@@ -36,7 +36,7 @@ import {
} from '@/test-utils/mock-api'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
import { LONG_DRAFT_TIMEOUT_MS, WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts'
import ComposePage from './ComposePage.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
@@ -599,7 +599,17 @@ describe('ComposePage', () => {
configurable: true,
})
await picker.trigger('change')
await flushPromises()
// 도면은 다 올려야(끝내기 응답 뒤 ready) [보내기]가 풀린다 — 그 전에 누른 보내기는 막혀 보내기 요청이 끝내 나가지 않는다.
// 조각의 SHA-256 은 Node 의 Web Crypto(스레드 풀)라 부하가 크면 양보 두 번으로는 끝나지 않았다 (2026-11-03 CI) — ready 가 될 때까지 본다
await vi.waitFor(
() => {
const row = wrapper.find('[data-large-upload]')
if (!row.exists() || row.attributes('data-phase') !== 'ready') {
throw new Error('대용량 파일을 올리는 중')
}
},
{ timeout: WAIT_FOR_TIMEOUT_MS },
)
await flushPromises()
expect(wrapper.findAll('.upload-item').map((item) => item.classes())).toEqual([
@@ -612,6 +622,8 @@ describe('ComposePage', () => {
)
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
// 다 올렸다 — [보내기]를 막는 까닭이 없다 (아래 보내기는 마이크로태스크로만 이어져 양보 한 번이면 요청이 나간다)
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
+8
View File
@@ -9,3 +9,11 @@
* 전체 한도에 가까워 이 테스트들에만 30초를 준다.
*/
export const LONG_DRAFT_TIMEOUT_MS = 30_000
/**
* 조건이 될 때까지 다시 보는 기다림(vi.waitFor)의 기한 — 몇 번 양보(flushPromises)해서는 끝난다고 믿을 수 없는 일을 기다린다.
* 예: 대용량 파일 조각의 SHA-256 — jsdom 이 아니라 Node 의 Web Crypto 라 스레드 풀에서 돌아, 부하가 크면 양보 두 번 안에 끝나지 않았다
* (2026-11-03 CI — 다 올리기 전에 누른 [보내기]는 막혀 보내기 요청이 끝내 나가지 않는다).
* 테스트 한도(vitest.config.ts, 20초)보다 짧게 둬, 끝내 되지 않으면 테스트 한도보다 이 기다림이 먼저 까닭(던진 글)을 알린다.
*/
export const WAIT_FOR_TIMEOUT_MS = 15_000