generated from ttipo/project-base
Compare commits
2 Commits
6dc2777834
...
349c312989
| Author | SHA1 | Date | |
|---|---|---|---|
| 349c312989 | |||
| 2cd2764da8 |
@@ -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'])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user