diff --git a/docs/superpowers/plans/2026-07-24-mcp-error-contract-and-date-stable-tests.md b/docs/superpowers/plans/2026-07-24-mcp-error-contract-and-date-stable-tests.md new file mode 100644 index 0000000..614046b --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-mcp-error-contract-and-date-stable-tests.md @@ -0,0 +1,278 @@ +# MCP Error Contract and Date-Stable Tests Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax (`- [ ]`) for tracking. + +**Goal:** MCP 오류 응답이 성공 출력 스키마 검증에 걸리지 않게 하고, 429 원장 테스트가 실행 날짜와 무관하게 안정적으로 통과하게 한다. + +**Architecture:** `ServiceRegistry`의 성공·오류 반환 경로를 분리해 성공 결과에만 `structuredContent`를 생성한다. 오류는 MCP 표준 `isError`와 text content만 보존한다. 날짜 의존 테스트는 운영 코드를 바꾸지 않고 테스트별 `Date.now()`를 이벤트의 KST 날짜로 고정한다. + +**Tech Stack:** TypeScript 6, Vitest 4, MCP TypeScript SDK 1.29, Cloudflare Workers, GitHub Actions + +--- + +## Task 1: 429 원장 테스트의 현재 시각 고정 + +**Files:** + +- Modify: `tests/durableObjects/dailyRateLimiter.test.ts:123` +- Modify: `tests/durableObjects/dailyRateLimiter.test.ts:264` +- Modify: `tests/durableObjects/dailyRateLimiter.test.ts:359` + +- [ ] **Step 1: 날짜 의존 실패 기준선을 확인한다** + +Run: + +```bash +npx vitest run tests/durableObjects/dailyRateLimiter.test.ts -t "POST /blocked-events는 완전한 이벤트 저장이 끝난 뒤 빈 204를 반환한다|GET /stats는 생략된 서비스 필터를 저장소에 전달하지 않는다|통계 조회 실패를 0 집계로 바꾸지 않는다" +``` + +Expected: 세 테스트가 현재 KST 날짜와 `2026-07-22`의 불일치 때문에 409 또는 예상과 다른 오류로 실패한다. + +- [ ] **Step 2: 각 테스트에서 현재 시각을 이벤트 날짜로 고정한다** + +각 테스트의 준비 구문 첫 줄에 아래 코드를 추가한다. + +```typescript +vi.spyOn(Date, 'now').mockReturnValue(VALID_EVENT.occurredAt); +``` + +전역 fake timer나 공통 `beforeEach`는 사용하지 않는다. 파일의 `afterEach`에 있는 `vi.restoreAllMocks()`가 테스트별 mock을 정리한다. + +- [ ] **Step 3: 대상 테스트와 전체 파일을 검증한다** + +Run: + +```bash +npx vitest run tests/durableObjects/dailyRateLimiter.test.ts +``` + +Expected: 파일의 모든 테스트가 통과한다. + +- [ ] **Step 4: 테스트 안정화 변경을 커밋한다** + +```bash +git add tests/durableObjects/dailyRateLimiter.test.ts +git commit -m "test: 429 원장 테스트의 KST 기준 시각 고정" +``` + +## Task 2: MCP 오류 계약 회귀 테스트를 RED로 추가 + +**Files:** + +- Modify: `tests/core/registry.test.ts:487` +- Create: `tests/core/registry-mcp-error-contract.test.ts` + +- [ ] **Step 1: 기존 단위 테스트의 오류 계약 기대값을 바꾼다** + +처리된 오류, `Error` 예외, 문자열 예외 테스트가 다음을 검증하도록 바꾼다. + +```typescript +expect(result).toMatchObject({ + isError: true, + content: [{ type: 'text', text: 'upstream failed' }], +}); +expect(result).not.toHaveProperty('structuredContent'); +``` + +처리된 오류에는 text `handled`, 문자열 예외에는 text `알 수 없는 오류가 발생했습니다.`를 각각 기대한다. + +- [ ] **Step 2: 실제 MCP SDK 클라이언트 회귀 테스트를 작성한다** + +`tests/core/registry-mcp-error-contract.test.ts`에 `McpServer`, `Client`, `InMemoryTransport`를 사용한 통합 테스트를 만든다. + +```typescript +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +const server = new McpServer({ name: 'registry-contract-server', version: '1.0.0' }); +const client = new Client({ name: 'registry-contract-client', version: '1.0.0' }); + +registry.applyToServer(server); +await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + +const result = await client.callTool({ + name: 'contract_error_tool', + arguments: {}, +}); + +expect(result).toMatchObject({ + isError: true, + content: [{ type: 'text', text: 'upstream failed' }], +}); +expect(result).not.toHaveProperty('structuredContent'); +``` + +등록 도구에는 성공용 `outputSchema: { value: z.string() }`를 지정하고 핸들러는 `isError: true`와 오류 text를 반환한다. `afterEach` 또는 `finally`에서 client와 server를 닫아 리소스를 정리한다. + +같은 파일에 정상 도구가 아래 계약을 유지하는 테스트도 둔다. + +```typescript +expect(result).toMatchObject({ + content: [{ type: 'text', text: '{"value":"ok"}' }], + structuredContent: { value: 'ok' }, +}); +expect(result).not.toHaveProperty('isError'); +``` + +- [ ] **Step 3: 구현 전 RED를 확인한다** + +Run: + +```bash +npx vitest run tests/core/registry.test.ts tests/core/registry-mcp-error-contract.test.ts +``` + +Expected: 오류 결과에 현재 `structuredContent`가 포함되거나 실제 SDK 클라이언트가 `-32602`를 던져 새 테스트가 실패한다. 기존 성공 계약 테스트는 통과한다. + +## Task 3: 오류 결과에서 성공용 structuredContent 생략 + +**Files:** + +- Modify: `src/core/registry.ts:8` +- Modify: `src/core/registry.ts:225` + +- [ ] **Step 1: 예외 반환 경로를 최소 오류 응답으로 바꾼다** + +`toStandardErrorDiagnostics` import를 제거하고 catch 반환값을 아래처럼 바꾼다. + +```typescript +return { + isError: true, + content: [{ type: 'text' as const, text: message }], +}; +``` + +- [ ] **Step 2: 처리된 오류와 성공 결과를 분기한다** + +먼저 content를 MCP 형식으로 변환한 다음 오류면 즉시 반환한다. + +```typescript +const content = result.content.map((item) => ({ + type: item.type as 'text', + text: item.text, +})); + +if (result.isError) { + return { + isError: true, + content, + }; +} + +return { + content, + structuredContent: buildStructuredContent(result), +}; +``` + +성공 결과의 `buildStructuredContent`, 표준 컬렉션 정규화, output schema 생성은 수정하지 않는다. + +- [ ] **Step 3: MCP 단위·통합 테스트를 GREEN으로 만든다** + +Run: + +```bash +npx vitest run tests/core/registry.test.ts tests/core/registry-mcp-error-contract.test.ts +``` + +Expected: 두 파일의 모든 테스트가 통과하며 실제 SDK 클라이언트가 오류를 `-32602` 없이 받는다. + +- [ ] **Step 4: MCP 계약 변경을 커밋한다** + +```bash +git add src/core/registry.ts tests/core/registry.test.ts tests/core/registry-mcp-error-contract.test.ts +git commit -m "fix: MCP 오류에서 성공용 구조화 응답 생략" +``` + +## Task 4: 전체 품질 검증 + +**Files:** + +- Verify: `src/core/registry.ts` +- Verify: `tests/core/registry.test.ts` +- Verify: `tests/core/registry-mcp-error-contract.test.ts` +- Verify: `tests/durableObjects/dailyRateLimiter.test.ts` + +- [ ] **Step 1: 정적 검사와 전체 테스트를 실행한다** + +```bash +npm run check +``` + +Expected: format, ESLint, Biome, TypeScript, Vitest가 모두 통과한다. 전체 병렬 부하에서 기존 HTTP MCP 테스트가 timeout을 내면 해당 파일을 단독 재실행해 실제 회귀인지 구분하고, 필요하면 전체 테스트를 낮은 worker 수로 다시 실행한다. + +- [ ] **Step 2: 커버리지와 배포 빌드를 검증한다** + +```bash +npm run test:coverage +npm run build +npx wrangler deploy --dry-run +``` + +Expected: 커버리지 기준, TypeScript/OpenAPI 빌드, Worker 번들 dry-run이 모두 통과한다. + +- [ ] **Step 3: 의존성·변경 품질을 확인한다** + +```bash +npm audit --audit-level=low +npm audit --omit=dev --audit-level=low +wc -l src/core/registry.ts tests/core/registry.test.ts tests/core/registry-mcp-error-contract.test.ts tests/durableObjects/dailyRateLimiter.test.ts +git diff --check origin/main...HEAD +git status --short +``` + +Expected: 감사 취약점 0개, 변경 파일 whitespace 오류 없음, 의도하지 않은 파일 없음. 신규 파일과 운영 TypeScript 파일은 프로젝트의 450줄 제한 이내다. + +## Task 5: PR, 배포, 운영 검증 + +**Files:** + +- Update after deployment: `/Users/hm/Documents/personal-agent/projects/daiso-mcp/PROJECT.md` + +- [ ] **Step 1: 브랜치를 push하고 PR을 만든다** + +```bash +git push -u origin fix/mcp-error-and-date-tests +gh pr create --base main --head fix/mcp-error-and-date-tests \ + --title "fix: MCP 오류 계약과 날짜 안정 테스트" \ + --body-file /tmp/daiso-mcp-pr-body.md +``` + +PR 본문에는 문제, 변경, TDD 증거, 전체 검증, Zyte 설정을 바꾸지 않았음을 적는다. + +- [ ] **Step 2: CI, Coverage, CodeQL을 확인하고 병합한다** + +```bash +gh pr checks --watch +gh pr merge --merge +``` + +Expected: 모든 필수 검사가 성공하고 merge commit이 `main`에 생성된다. + +- [ ] **Step 3: 배포 워크플로와 Cloudflare 버전을 확인한다** + +```bash +gh run list --branch main --limit 10 +deploy_run_id=$(gh run list --branch main --workflow "Deploy to Cloudflare Workers" \ + --limit 1 --json databaseId --jq '.[0].databaseId') +gh run watch "$deploy_run_id" +npx wrangler versions list +``` + +Expected: 병합 커밋의 배포 워크플로가 성공하고 새 Worker 버전이 활성화된다. + +- [ ] **Step 4: 운영 환경에서 계약을 검증한다** + +```bash +curl -fsS https://mcp.aka.page/health +curl -fsS https://daiso-mcp.hmmhmmhm.workers.dev/health +``` + +실제 SDK 클라이언트로 `https://mcp.aka.page/mcp`에 연결해 다음을 확인한다. + +- Daiso 정상 도구는 성공 결과와 `structuredContent`를 반환한다. +- Zyte가 정지된 GS25 오류 도구는 `-32602`를 던지지 않는다. +- GS25 결과는 `isError: true`, text content, `structuredContent` 부재다. +- 429 내부 통계 엔드포인트의 무인증 요청은 401이며 인증 요청은 정상 집계를 반환한다. + +- [ ] **Step 5: 프로젝트 메모리와 로컬 체크아웃을 정리한다** + +`PROJECT.md`에 PR, merge commit, 배포 run/version, 전체 테스트, 운영 오류 계약 확인 결과를 기록한다. 기본 checkout을 최신 `main`으로 맞추고 작업 worktree와 로컬 기능 브랜치를 안전하게 제거한다. diff --git a/docs/superpowers/specs/2026-07-24-mcp-error-contract-and-date-stable-tests-design.md b/docs/superpowers/specs/2026-07-24-mcp-error-contract-and-date-stable-tests-design.md new file mode 100644 index 0000000..56d0615 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-mcp-error-contract-and-date-stable-tests-design.md @@ -0,0 +1,84 @@ +# MCP 오류 계약 및 날짜 안정 테스트 설계 + +## 배경 + +2026-07-23 정기점검에서 두 가지 회귀가 확인됐다. + +첫째, MCP 도구가 `isError: true`와 함께 성공 응답용 `structuredContent`를 반환한다. MCP SDK 서버는 오류 결과의 출력 검증을 건너뛰지만, SDK 클라이언트는 `structuredContent`가 존재하면 성공용 `outputSchema`로 검증한다. 이 때문에 GS25 upstream 오류가 원래 오류 결과로 전달되지 않고 `-32602 Structured content does not match the tool's output schema`로 바뀐다. + +둘째, `tests/durableObjects/dailyRateLimiter.test.ts`의 세 테스트가 `2026-07-22` 이벤트와 `asOf`를 사용하면서 `Date.now()`를 고정하지 않는다. 실제 KST 날짜가 2026-07-23 이후가 되자 운영 코드가 의도한 만료 409를 반환해 테스트가 실패한다. + +## 목표 + +- MCP 도구 오류가 성공 출력 스키마 검증에 걸리지 않게 한다. +- 오류의 `isError`와 text content는 유지한다. +- 정상 MCP 응답의 `outputSchema`와 `structuredContent` 계약은 바꾸지 않는다. +- 날짜 경계 테스트가 실행 날짜와 관계없이 같은 결과를 내게 한다. +- 실제 MCP SDK 클라이언트로 회귀를 검증한다. + +## 비목표 + +- 모든 도구의 성공 출력 스키마를 성공·오류 union으로 바꾸지 않는다. +- MCP smoke가 Zyte 정지 중에도 성공한 것으로 처리되게 하지 않는다. +- 일일 호출 제한이나 429 원장 운영 로직을 변경하지 않는다. +- Zyte 결제 한도나 외부 서비스 설정을 변경하지 않는다. + +## 검토한 대안 + +### 1. 오류 응답에서 `structuredContent` 생략 + +`isError: true`일 때 text content만 반환한다. MCP SDK는 오류 결과에 `structuredContent`가 없어도 허용하므로 성공 스키마 검증을 피할 수 있다. 정상 결과는 기존 동작을 그대로 유지한다. + +장점은 변경 범위가 작고 MCP SDK 계약에 맞으며 Home Assistant가 읽는 성공 스키마에 영향이 없다는 점이다. 단점은 오류를 `structuredContent`에서 기계적으로 읽던 비표준 소비자가 있다면 text content를 사용해야 한다는 점이다. + +### 2. 모든 출력 스키마를 성공·오류 union으로 확장 + +표준 오류 객체를 모든 도구의 `outputSchema`에 포함한다. 기계 판독 오류를 유지할 수 있지만 40개 도구의 공개 스키마가 복잡해지고 Home Assistant 변환 호환성을 다시 검증해야 한다. + +### 3. smoke 클라이언트에서 출력 검증 우회 + +운영 서버는 그대로 두고 smoke만 `-32602`를 허용한다. 실제 MCP 클라이언트의 오류가 계속 왜곡되므로 문제를 해결하지 못한다. + +## 선택한 설계 + +대안 1을 적용한다. + +`ServiceRegistry.registerTool`은 핸들러 결과를 다음 순서로 변환한다. + +1. 핸들러가 예외를 던지면 `isError: true`와 기존 오류 메시지 text content만 반환한다. +2. 핸들러가 `isError: true` 결과를 반환하면 해당 text content와 `isError`만 유지한다. +3. 정상 결과에만 `buildStructuredContent`를 적용한다. +4. 정상 결과의 표준 상품·매장 컬렉션 정규화와 output schema는 변경하지 않는다. + +날짜 테스트는 운영 코드를 바꾸지 않는다. 성공 결과나 내부 오류 전파를 확인하는 세 테스트에서 `Date.now()`를 `VALID_EVENT.day`와 같은 KST 날짜로 고정한다. `afterEach`의 `vi.restoreAllMocks()`가 각 테스트의 시각 mock을 정리하므로 다른 테스트와 상태를 공유하지 않는다. + +## 테스트 설계 + +### MCP 오류 계약 + +- 기존 registry 단위 테스트를 먼저 바꿔 처리된 오류와 예외 오류에 `structuredContent`가 없기를 기대하게 한다. +- 수정 전 테스트가 현재의 `structuredContent` 때문에 실패하는 RED를 확인한다. +- 별도 MCP SDK 통합 테스트에서 `InMemoryTransport.createLinkedPair()`로 실제 `McpServer`와 `Client`를 연결한다. +- 성공용 output schema를 가진 도구가 오류를 반환할 때 `client.callTool()`이 `-32602`를 던지지 않고 `isError: true`, 원래 text content, `structuredContent` 부재 결과를 반환하는지 검증한다. +- 정상 도구 호출은 기존처럼 `structuredContent`를 반환하는지 함께 검증한다. + +### 날짜 안정성 + +- 현재 실패하는 세 테스트를 그대로 RED 기준선으로 사용한다. +- 각 테스트에 고정 KST 시각을 추가한 뒤 세 테스트와 전체 Durable Object 테스트 파일이 통과하는지 확인한다. +- 실제 만료 409를 검증하는 자정 전환 테스트는 기존 시각 mock과 기대값을 유지한다. + +### 전체 검증 + +- `npm run check` +- `npm run test:coverage` +- `npm run build` +- `npx wrangler deploy --dry-run` +- `npm audit --audit-level=low` +- 변경된 TypeScript 파일 450줄 제한과 `git diff --check` + +## 배포와 운영 확인 + +변경을 기능 브랜치에 커밋하고 PR의 CI, Coverage, CodeQL을 통과시킨 뒤 `main`에 병합한다. 배포 워크플로 성공과 Cloudflare 버전 전환을 확인한다. + +Zyte 정지 상태에서 GS25 MCP smoke 자체는 계속 실패할 수 있다. 배포 후 MCP SDK 클라이언트로 GS25 오류 호출을 실행해 `-32602` 대신 정상적인 `isError` 결과가 전달되는지 확인한다. `/health`, 429 통계 인증 경계와 기존 성공 도구 호출도 함께 확인한다. diff --git a/src/core/registry.ts b/src/core/registry.ts index 4d17215..8bb4204 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -7,7 +7,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ServiceProvider, ServiceFactory } from './interfaces.js'; import type { ServiceInfo, ToolRegistration } from './types.js'; -import { getErrorMessage, toStandardErrorDiagnostics } from './errors.js'; +import { getErrorMessage } from './errors.js'; import { createToolOutputSchema } from './outputSchema.js'; function parseJsonText(text: string): Record | null { @@ -231,29 +231,25 @@ export class ServiceRegistry { return { isError: true, content: [{ type: 'text' as const, text: message }], - structuredContent: { - error: toStandardErrorDiagnostics('TOOL_EXECUTION_FAILED', message, { - operation: tool.name, - }), - }, }; } - const response: { - content: Array<{ type: 'text'; text: string }>; - structuredContent?: Record; - isError?: boolean; - } = { - content: result.content.map((item) => ({ - type: item.type as 'text', - text: item.text, - })), - structuredContent: buildStructuredContent(result), - }; + const content = result.content.map((item) => ({ + type: item.type as 'text', + text: item.text, + })); + if (result.isError) { - response.isError = true; + return { + isError: true, + content, + }; } - return response; + + return { + content, + structuredContent: buildStructuredContent(result), + }; }); } diff --git a/tests/core/registry-mcp-error-contract.test.ts b/tests/core/registry-mcp-error-contract.test.ts new file mode 100644 index 0000000..06c5e0a --- /dev/null +++ b/tests/core/registry-mcp-error-contract.test.ts @@ -0,0 +1,91 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod'; +import type { ServiceProvider } from '../../src/core/interfaces.js'; +import { ServiceRegistry } from '../../src/core/registry.js'; +import type { ToolRegistration } from '../../src/core/types.js'; + +function createService(tool: ToolRegistration): ServiceProvider { + return { + metadata: { + id: 'contract-test', + name: 'Contract Test', + version: '1.0.0', + }, + getTools: () => [tool], + }; +} + +async function callRegisteredTool(tool: ToolRegistration) { + const registry = new ServiceRegistry(); + registry.register(() => createService(tool)); + + const server = new McpServer({ + name: 'registry-contract-server', + version: '1.0.0', + }); + const client = new Client({ + name: 'registry-contract-client', + version: '1.0.0', + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + registry.applyToServer(server); + + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return await client.callTool({ + name: tool.name, + arguments: {}, + }); + } finally { + await client.close(); + await server.close(); + } +} + +describe('ServiceRegistry MCP SDK 오류 계약', () => { + it('성공 출력 스키마가 있어도 예외 오류를 -32602 없이 전달한다', async () => { + const result = await callRegisteredTool({ + name: 'contract_error_tool', + metadata: { + title: 'Contract Error Tool', + description: 'MCP 오류 계약을 검증한다.', + inputSchema: {}, + outputSchema: { value: z.string() }, + }, + handler: async () => { + throw new Error('upstream failed'); + }, + }); + + expect(result).toMatchObject({ + isError: true, + content: [{ type: 'text', text: 'upstream failed' }], + }); + expect(result).not.toHaveProperty('structuredContent'); + }); + + it('정상 결과에는 성공 출력 스키마에 맞는 structuredContent를 유지한다', async () => { + const result = await callRegisteredTool({ + name: 'contract_success_tool', + metadata: { + title: 'Contract Success Tool', + description: 'MCP 성공 계약을 검증한다.', + inputSchema: {}, + outputSchema: { value: z.string() }, + }, + handler: async () => ({ + content: [{ type: 'text', text: '{"value":"ok"}' }], + }), + }); + + expect(result).toMatchObject({ + content: [{ type: 'text', text: '{"value":"ok"}' }], + structuredContent: { value: 'ok' }, + }); + expect(result).not.toHaveProperty('isError'); + }); +}); diff --git a/tests/core/registry.test.ts b/tests/core/registry.test.ts index 06f5337..0c0d018 100644 --- a/tests/core/registry.test.ts +++ b/tests/core/registry.test.ts @@ -490,6 +490,7 @@ describe('ServiceRegistry', () => { handler: async () => ({ isError: true, content: [{ type: 'text', text: 'handled' }], + structuredContent: { error: 'must not use success output channel' }, }), }; registry.register(() => createMockService('test', [tool])); @@ -501,15 +502,16 @@ describe('ServiceRegistry', () => { registry.applyToServer(mockServer as never); const registeredHandler = mockServer.registerTool.mock.calls[0][2]; - await expect(registeredHandler({})).resolves.toEqual( - expect.objectContaining({ - isError: true, - structuredContent: { text: 'handled' }, - }), - ); + const result = await registeredHandler({}); + + expect(result).toMatchObject({ + isError: true, + content: [{ type: 'text', text: 'handled' }], + }); + expect(result).not.toHaveProperty('structuredContent'); }); - it('도구 예외를 표준 MCP 에러 구조로 반환한다', async () => { + it('도구 예외를 text 기반 MCP 오류 결과로 반환한다', async () => { const tool: ToolRegistration = { ...createMockTool('error-tool'), handler: async () => { @@ -531,19 +533,12 @@ describe('ServiceRegistry', () => { expect.objectContaining({ isError: true, content: [{ type: 'text', text: 'upstream failed' }], - structuredContent: { - error: expect.objectContaining({ - code: 'TOOL_EXECUTION_FAILED', - message: 'upstream failed', - operation: 'error-tool', - retryable: true, - }), - }, }), ); + expect(result).not.toHaveProperty('structuredContent'); }); - it('문자열 예외도 표준 MCP 에러 구조로 반환한다', async () => { + it('문자열 예외도 text 기반 MCP 오류 결과로 반환한다', async () => { const tool: ToolRegistration = { ...createMockTool('string-error-tool'), handler: async () => { @@ -560,7 +555,11 @@ describe('ServiceRegistry', () => { const registeredHandler = mockServer.registerTool.mock.calls[0][2]; const result = await registeredHandler({}); - expect(result.structuredContent.error.message).toBe('알 수 없는 오류가 발생했습니다.'); + expect(result).toMatchObject({ + isError: true, + content: [{ type: 'text', text: '알 수 없는 오류가 발생했습니다.' }], + }); + expect(result).not.toHaveProperty('structuredContent'); }); }); diff --git a/tests/durableObjects/dailyRateLimiter.test.ts b/tests/durableObjects/dailyRateLimiter.test.ts index a09a641..112d154 100644 --- a/tests/durableObjects/dailyRateLimiter.test.ts +++ b/tests/durableObjects/dailyRateLimiter.test.ts @@ -121,6 +121,7 @@ describe('DailyRateLimiter', () => { }); it('POST /blocked-events는 완전한 이벤트 저장이 끝난 뒤 빈 204를 반환한다', async () => { + vi.spyOn(Date, 'now').mockReturnValue(VALID_EVENT.occurredAt); const fixture = createState(); const limiter = new DailyRateLimiter(fixture.state); const response = await limiter.fetch( @@ -262,6 +263,7 @@ describe('DailyRateLimiter', () => { }); it('GET /stats는 생략된 서비스 필터를 저장소에 전달하지 않는다', async () => { + vi.spyOn(Date, 'now').mockReturnValue(VALID_EVENT.occurredAt); const fixture = createState(); const query = vi.spyOn(RateLimitMetricsStore.prototype, 'query'); const limiter = new DailyRateLimiter(fixture.state); @@ -357,6 +359,7 @@ describe('DailyRateLimiter', () => { }); it('통계 조회 실패를 0 집계로 바꾸지 않는다', async () => { + vi.spyOn(Date, 'now').mockReturnValue(VALID_EVENT.occurredAt); const fixture = createState(); const limiter = new DailyRateLimiter(fixture.state); vi.spyOn(RateLimitMetricsStore.prototype, 'query').mockRejectedValueOnce(