diff --git a/docs/developer.md b/docs/developer.md index 77213b1..a7c2815 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -61,6 +61,10 @@ git diff -- packages/contracts/openapi.json frontend/src/api/schema.d.ts 旧版 FastAPI 基线保存在 `tests-ts/contracts/fastapi-openapi.json`。现有兼容路由不能无意删除或改方法;有意的破坏性变更应单独说明并更新契约测试。 +### API 响应契约盘点 + +在正式收紧响应 schema 之前,先维护 `tests-ts/contracts/response-inventory.ts`,并在 `tests-ts/contracts/fixtures/` 保存脱敏的响应和事件样例。HTTP 边界测试必须通过 Hono `app.request()` 验证状态码、Content-Type、错误格式和当前字段形状;不要只调用 Core 内部用例。新增或修改 API 时,先更新 inventory、fixture 和边界测试,再在后续改动中更新 `packages/contracts` 的正式 Zod schema。 + ## 测试与构建 提交前的默认门槛: diff --git a/tests-ts/contracts/fixture.ts b/tests-ts/contracts/fixture.ts new file mode 100644 index 0000000..aea6876 --- /dev/null +++ b/tests-ts/contracts/fixture.ts @@ -0,0 +1,9 @@ +const responseFixtureRoot = 'tests-ts/contracts/fixtures/responses/' + +export async function loadResponseFixture(filename: string): Promise { + return await Bun.file(`${responseFixtureRoot}${filename}`).json() as T +} + +export async function loadTextFixture(filename: string): Promise { + return await Bun.file(`tests-ts/contracts/fixtures/${filename}`).text() +} diff --git a/tests-ts/contracts/fixtures/copilot/events.json b/tests-ts/contracts/fixtures/copilot/events.json new file mode 100644 index 0000000..cbeae19 --- /dev/null +++ b/tests-ts/contracts/fixtures/copilot/events.json @@ -0,0 +1 @@ +[{"type":"started","session_id":"session-1"},{"type":"asr_interim","text":"请介绍"},{"type":"asr_final","text":"请介绍一下项目"},{"type":"progress","progress":0.5,"message":"分析中"},{"type":"error","message":"连接已关闭"}] diff --git a/tests-ts/contracts/fixtures/responses/auth-config.json b/tests-ts/contracts/fixtures/responses/auth-config.json new file mode 100644 index 0000000..12240e8 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/auth-config.json @@ -0,0 +1 @@ +{"allow_registration":false} diff --git a/tests-ts/contracts/fixtures/responses/auth-response.json b/tests-ts/contracts/fixtures/responses/auth-response.json new file mode 100644 index 0000000..da85023 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/auth-response.json @@ -0,0 +1 @@ +{"token":"fixture-token","user":{"id":"user-a","email":"user@example.com","name":"Fixture User","is_admin":false}} diff --git a/tests-ts/contracts/fixtures/responses/change-password.json b/tests-ts/contracts/fixtures/responses/change-password.json new file mode 100644 index 0000000..13109c0 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/change-password.json @@ -0,0 +1 @@ +{"status":"ok"} diff --git a/tests-ts/contracts/fixtures/responses/copilot-prep-created.json b/tests-ts/contracts/fixtures/responses/copilot-prep-created.json new file mode 100644 index 0000000..35ed074 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/copilot-prep-created.json @@ -0,0 +1 @@ +{"prep_id":"prep-1"} diff --git a/tests-ts/contracts/fixtures/responses/copilot-prep-status.json b/tests-ts/contracts/fixtures/responses/copilot-prep-status.json new file mode 100644 index 0000000..ffb38dc --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/copilot-prep-status.json @@ -0,0 +1 @@ +{"status":"running","progress":"正在分析","error":"","company":"示例公司","position":"后端工程师"} diff --git a/tests-ts/contracts/fixtures/responses/copilot-preps.json b/tests-ts/contracts/fixtures/responses/copilot-preps.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/copilot-preps.json @@ -0,0 +1 @@ +[] diff --git a/tests-ts/contracts/fixtures/responses/copilot-tree.json b/tests-ts/contracts/fixtures/responses/copilot-tree.json new file mode 100644 index 0000000..fdf550b --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/copilot-tree.json @@ -0,0 +1 @@ +{"root_nodes":[],"nodes":{},"phase_order":[]} diff --git a/tests-ts/contracts/fixtures/responses/data-import.json b/tests-ts/contracts/fixtures/responses/data-import.json new file mode 100644 index 0000000..7941d78 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/data-import.json @@ -0,0 +1 @@ +{"files_copied":0,"files_skipped":0,"db_inserted":0,"db_skipped":0} diff --git a/tests-ts/contracts/fixtures/responses/due-reviews.json b/tests-ts/contracts/fixtures/responses/due-reviews.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/due-reviews.json @@ -0,0 +1 @@ +[] diff --git a/tests-ts/contracts/fixtures/responses/interview-chat.json b/tests-ts/contracts/fixtures/responses/interview-chat.json new file mode 100644 index 0000000..655952a --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-chat.json @@ -0,0 +1 @@ +{"session_id":"resume-session-1","message":"好的,请介绍一下你的项目。","is_finished":false} diff --git a/tests-ts/contracts/fixtures/responses/interview-draft.json b/tests-ts/contracts/fixtures/responses/interview-draft.json new file mode 100644 index 0000000..f64a5dd --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-draft.json @@ -0,0 +1 @@ +{"session_id":"topic-session-1","status":"ongoing","saved":true} diff --git a/tests-ts/contracts/fixtures/responses/interview-end-pending.json b/tests-ts/contracts/fixtures/responses/interview-end-pending.json new file mode 100644 index 0000000..afdbd3d --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-end-pending.json @@ -0,0 +1 @@ +{"session_id":"topic-session-1","mode":"topic_drill","status":"pending"} diff --git a/tests-ts/contracts/fixtures/responses/interview-history.json b/tests-ts/contracts/fixtures/responses/interview-history.json new file mode 100644 index 0000000..bb18268 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-history.json @@ -0,0 +1 @@ +{"items":[],"total":0} diff --git a/tests-ts/contracts/fixtures/responses/interview-job-preview.json b/tests-ts/contracts/fixtures/responses/interview-job-preview.json new file mode 100644 index 0000000..e7c52eb --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-job-preview.json @@ -0,0 +1 @@ +{"preview":{"company":"示例公司","position":"后端工程师","role_summary":"后端服务开发","focus_areas":[],"likely_question_groups":[],"resume_alignment":{"resume_used":false,"matching_evidence":[],"risk_gaps":[],"recommended_stories":[]},"prep_priorities":[],"question_blueprint":[],"jd_excerpt":""}} diff --git a/tests-ts/contracts/fixtures/responses/interview-resume.json b/tests-ts/contracts/fixtures/responses/interview-resume.json new file mode 100644 index 0000000..e8cc563 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-resume.json @@ -0,0 +1 @@ +{"session_id":"resume-session-1","mode":"resume","topic":null,"status":"ongoing","review_error":null,"transcript":[],"questions":[],"target_role":"后端工程师","job_description":"","meta":{},"can_continue":true,"is_finished":false,"has_review":false} diff --git a/tests-ts/contracts/fixtures/responses/interview-review-pending.json b/tests-ts/contracts/fixtures/responses/interview-review-pending.json new file mode 100644 index 0000000..afdbd3d --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-review-pending.json @@ -0,0 +1 @@ +{"session_id":"topic-session-1","mode":"topic_drill","status":"pending"} diff --git a/tests-ts/contracts/fixtures/responses/interview-review.json b/tests-ts/contracts/fixtures/responses/interview-review.json new file mode 100644 index 0000000..b6d762f --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-review.json @@ -0,0 +1 @@ +{"session_id":"topic-session-1","mode":"topic_drill","review":"# 复盘","scores":[],"weak_points":[],"overall":{},"status":"reviewed"} diff --git a/tests-ts/contracts/fixtures/responses/interview-start-jd.json b/tests-ts/contracts/fixtures/responses/interview-start-jd.json new file mode 100644 index 0000000..b872d61 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-start-jd.json @@ -0,0 +1 @@ +{"session_id":"jd-session-1","mode":"jd_prep","questions":[],"preview":{},"company":"示例公司","position":"后端工程师","meta":{}} diff --git a/tests-ts/contracts/fixtures/responses/interview-start-resume.json b/tests-ts/contracts/fixtures/responses/interview-start-resume.json new file mode 100644 index 0000000..b41f091 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-start-resume.json @@ -0,0 +1 @@ +{"session_id":"resume-session-1","mode":"resume","topic":null,"target_role":"后端工程师","job_description":"","message":"请先做自我介绍"} diff --git a/tests-ts/contracts/fixtures/responses/interview-start-topic.json b/tests-ts/contracts/fixtures/responses/interview-start-topic.json new file mode 100644 index 0000000..7ef6199 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-start-topic.json @@ -0,0 +1 @@ +{"session_id":"topic-session-1","mode":"topic_drill","topic":"typescript","questions":[{"id":1,"question":"解释 TypeScript 的结构类型系统","difficulty":3}]} diff --git a/tests-ts/contracts/fixtures/responses/interview-topics.json b/tests-ts/contracts/fixtures/responses/interview-topics.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/interview-topics.json @@ -0,0 +1 @@ +[] diff --git a/tests-ts/contracts/fixtures/responses/knowledge-content.json b/tests-ts/contracts/fixtures/responses/knowledge-content.json new file mode 100644 index 0000000..4933d1b --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/knowledge-content.json @@ -0,0 +1 @@ +{"content":"常见问题"} diff --git a/tests-ts/contracts/fixtures/responses/knowledge-created.json b/tests-ts/contracts/fixtures/responses/knowledge-created.json new file mode 100644 index 0000000..2f75f0d --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/knowledge-created.json @@ -0,0 +1 @@ +{"ok":true,"filename":"core.md"} diff --git a/tests-ts/contracts/fixtures/responses/knowledge-files.json b/tests-ts/contracts/fixtures/responses/knowledge-files.json new file mode 100644 index 0000000..7665845 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/knowledge-files.json @@ -0,0 +1 @@ +[{"filename":"core.md","content":"# TypeScript"}] diff --git a/tests-ts/contracts/fixtures/responses/knowledge-generated.json b/tests-ts/contracts/fixtures/responses/knowledge-generated.json new file mode 100644 index 0000000..2172886 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/knowledge-generated.json @@ -0,0 +1 @@ +{"ok":true,"content":"# Generated knowledge"} diff --git a/tests-ts/contracts/fixtures/responses/ok.json b/tests-ts/contracts/fixtures/responses/ok.json new file mode 100644 index 0000000..31bc455 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/ok.json @@ -0,0 +1 @@ +{"ok":true} diff --git a/tests-ts/contracts/fixtures/responses/personal-agent-chat.json b/tests-ts/contracts/fixtures/responses/personal-agent-chat.json new file mode 100644 index 0000000..3067b86 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/personal-agent-chat.json @@ -0,0 +1 @@ +{"conversation_id":"conversation-1","title":"复习建议","message":{"role":"assistant","content":"建议先复习类型系统。","created_at":"2026-01-01T00:00:00.000Z","sources":[]}} diff --git a/tests-ts/contracts/fixtures/responses/personal-conversation.json b/tests-ts/contracts/fixtures/responses/personal-conversation.json new file mode 100644 index 0000000..9532a11 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/personal-conversation.json @@ -0,0 +1 @@ +{"conversation_id":"conversation-1","title":"复习建议","messages":[],"created_at":"2026-01-01T00:00:00.000Z","updated_at":"2026-01-01T00:00:00.000Z"} diff --git a/tests-ts/contracts/fixtures/responses/personal-conversations.json b/tests-ts/contracts/fixtures/responses/personal-conversations.json new file mode 100644 index 0000000..fb889fc --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/personal-conversations.json @@ -0,0 +1 @@ +{"items":[]} diff --git a/tests-ts/contracts/fixtures/responses/personal-document.json b/tests-ts/contracts/fixtures/responses/personal-document.json new file mode 100644 index 0000000..d0d4bc5 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/personal-document.json @@ -0,0 +1 @@ +{"document_id":"document-1","filename":"notes.md","extension":".md","size_bytes":128,"status":"ready","chunk_count":1} diff --git a/tests-ts/contracts/fixtures/responses/personal-documents.json b/tests-ts/contracts/fixtures/responses/personal-documents.json new file mode 100644 index 0000000..50e8401 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/personal-documents.json @@ -0,0 +1 @@ +{"items":[],"supported_extensions":[".md"],"max_upload_bytes":20971520} diff --git a/tests-ts/contracts/fixtures/responses/profile-feedback.json b/tests-ts/contracts/fixtures/responses/profile-feedback.json new file mode 100644 index 0000000..070786f --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/profile-feedback.json @@ -0,0 +1 @@ +{"point":"跨领域机制解释停留在表面","confidence":0.8,"user_acknowledged":true} diff --git a/tests-ts/contracts/fixtures/responses/profile.json b/tests-ts/contracts/fixtures/responses/profile.json new file mode 100644 index 0000000..a0431f7 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/profile.json @@ -0,0 +1 @@ +{"name":"","target_role":"","updated_at":"","last_consolidation_at":"","topic_mastery":{},"weak_points":[],"strong_points":[],"behavior_signals":{},"communication":{"style":"","habits":[],"suggestions":[]},"thinking_patterns":{"strengths":[],"gaps":[]},"stats":{"total_sessions":0,"resume_sessions":0,"drill_sessions":0,"job_prep_sessions":0,"avg_score":0,"score_history":[]},"due_reviews":[]} diff --git a/tests-ts/contracts/fixtures/responses/question-graph.json b/tests-ts/contracts/fixtures/responses/question-graph.json new file mode 100644 index 0000000..aabbf7b --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/question-graph.json @@ -0,0 +1 @@ +{"nodes":[],"links":[]} diff --git a/tests-ts/contracts/fixtures/responses/quota-status.json b/tests-ts/contracts/fixtures/responses/quota-status.json new file mode 100644 index 0000000..ac87872 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/quota-status.json @@ -0,0 +1 @@ +{"source":"user","used":0,"limit":null,"unit":"token","window":"day"} diff --git a/tests-ts/contracts/fixtures/responses/recording-analyze-pending.json b/tests-ts/contracts/fixtures/responses/recording-analyze-pending.json new file mode 100644 index 0000000..3473cdf --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/recording-analyze-pending.json @@ -0,0 +1 @@ +{"session_id":"recording-session-1","status":"pending"} diff --git a/tests-ts/contracts/fixtures/responses/recording-transcription.json b/tests-ts/contracts/fixtures/responses/recording-transcription.json new file mode 100644 index 0000000..b02fe76 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/recording-transcription.json @@ -0,0 +1 @@ +{"transcript":"面试官:请介绍项目","segments":[]} diff --git a/tests-ts/contracts/fixtures/responses/reference-answer.json b/tests-ts/contracts/fixtures/responses/reference-answer.json new file mode 100644 index 0000000..3c25176 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/reference-answer.json @@ -0,0 +1 @@ +{"reference_answer":"可以从类型兼容性和结构约束两个方面回答。","cached":false} diff --git a/tests-ts/contracts/fixtures/responses/resume-parsed.json b/tests-ts/contracts/fixtures/responses/resume-parsed.json new file mode 100644 index 0000000..c09b8ae --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/resume-parsed.json @@ -0,0 +1 @@ +{"ok":true,"parsed":{"basic":{"name":"Fixture User"},"education":[],"experience":[],"projects":[],"skills":[]}} diff --git a/tests-ts/contracts/fixtures/responses/resume-status-empty.json b/tests-ts/contracts/fixtures/responses/resume-status-empty.json new file mode 100644 index 0000000..12a516c --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/resume-status-empty.json @@ -0,0 +1 @@ +{"has_resume":false} diff --git a/tests-ts/contracts/fixtures/responses/resume-status-present.json b/tests-ts/contracts/fixtures/responses/resume-status-present.json new file mode 100644 index 0000000..69e81c5 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/resume-status-present.json @@ -0,0 +1 @@ +{"has_resume":true,"filename":"resume.pdf","size":1024} diff --git a/tests-ts/contracts/fixtures/responses/resume-uploaded.json b/tests-ts/contracts/fixtures/responses/resume-uploaded.json new file mode 100644 index 0000000..39bd800 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/resume-uploaded.json @@ -0,0 +1 @@ +{"ok":true,"filename":"resume.pdf","size":1024} diff --git a/tests-ts/contracts/fixtures/responses/retrospective-pending.json b/tests-ts/contracts/fixtures/responses/retrospective-pending.json new file mode 100644 index 0000000..1520c34 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/retrospective-pending.json @@ -0,0 +1 @@ +{"task_id":"retro_typescript_user-a","status":"pending"} diff --git a/tests-ts/contracts/fixtures/responses/service-info.json b/tests-ts/contracts/fixtures/responses/service-info.json new file mode 100644 index 0000000..09ac62a --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/service-info.json @@ -0,0 +1 @@ +{"service":"TechSpar","version":"0.4.0"} diff --git a/tests-ts/contracts/fixtures/responses/settings-probe.json b/tests-ts/contracts/fixtures/responses/settings-probe.json new file mode 100644 index 0000000..31bc455 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/settings-probe.json @@ -0,0 +1 @@ +{"ok":true} diff --git a/tests-ts/contracts/fixtures/responses/settings-update.json b/tests-ts/contracts/fixtures/responses/settings-update.json new file mode 100644 index 0000000..3266af6 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/settings-update.json @@ -0,0 +1 @@ +{"ok":true,"embedding_changed":false} diff --git a/tests-ts/contracts/fixtures/responses/settings-view.json b/tests-ts/contracts/fixtures/responses/settings-view.json new file mode 100644 index 0000000..63ccc4a --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/settings-view.json @@ -0,0 +1 @@ +{"llm":{"api_base":"","api_key":"","model":"","temperature":0.7,"compatibility":"generic","use_platform":false},"embedding":{"backend":""},"services":{"dashscope_api_key":"","tavily_api_key":"","oss_access_key_id":"","oss_access_key_secret":"","oss_bucket":"","oss_endpoint":""},"system":{"allow_registration":false},"training":{"num_questions":10,"divergence":3},"is_admin":false,"configured":{"llm":false,"embedding":false},"platform":{"llm":false,"embedding":false},"source":"user","last_reindex_at":""} diff --git a/tests-ts/contracts/fixtures/responses/target-role.json b/tests-ts/contracts/fixtures/responses/target-role.json new file mode 100644 index 0000000..21f33cd --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/target-role.json @@ -0,0 +1 @@ +{"target_role":"后端工程师"} diff --git a/tests-ts/contracts/fixtures/responses/task-done.json b/tests-ts/contracts/fixtures/responses/task-done.json new file mode 100644 index 0000000..15afac8 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/task-done.json @@ -0,0 +1 @@ +{"status":"done","type":"drill_review"} diff --git a/tests-ts/contracts/fixtures/responses/task-error.json b/tests-ts/contracts/fixtures/responses/task-error.json new file mode 100644 index 0000000..e761afc --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/task-error.json @@ -0,0 +1 @@ +{"status":"error","type":"drill_review","error":"任务失败"} diff --git a/tests-ts/contracts/fixtures/responses/task-pending.json b/tests-ts/contracts/fixtures/responses/task-pending.json new file mode 100644 index 0000000..4c95851 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/task-pending.json @@ -0,0 +1 @@ +{"status":"pending","type":"drill_review"} diff --git a/tests-ts/contracts/fixtures/responses/topic-created.json b/tests-ts/contracts/fixtures/responses/topic-created.json new file mode 100644 index 0000000..d59fa18 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/topic-created.json @@ -0,0 +1 @@ +{"ok":true,"key":"typescript"} diff --git a/tests-ts/contracts/fixtures/responses/topic-history.json b/tests-ts/contracts/fixtures/responses/topic-history.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/topic-history.json @@ -0,0 +1 @@ +[] diff --git a/tests-ts/contracts/fixtures/responses/topics.json b/tests-ts/contracts/fixtures/responses/topics.json new file mode 100644 index 0000000..96642e0 --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/topics.json @@ -0,0 +1 @@ +{"typescript":{"name":"TypeScript","icon":"code","dir":"typescript"}} diff --git a/tests-ts/contracts/fixtures/responses/transcription.json b/tests-ts/contracts/fixtures/responses/transcription.json new file mode 100644 index 0000000..c4c658f --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/transcription.json @@ -0,0 +1 @@ +{"text":"这是测试转写"} diff --git a/tests-ts/contracts/fixtures/responses/voiceprint-enrolled.json b/tests-ts/contracts/fixtures/responses/voiceprint-enrolled.json new file mode 100644 index 0000000..9c537db --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/voiceprint-enrolled.json @@ -0,0 +1 @@ +{"ok":true,"enrolled_at":"2026-01-01T00:00:00.000Z"} diff --git a/tests-ts/contracts/fixtures/responses/voiceprint-status.json b/tests-ts/contracts/fixtures/responses/voiceprint-status.json new file mode 100644 index 0000000..08e8ccd --- /dev/null +++ b/tests-ts/contracts/fixtures/responses/voiceprint-status.json @@ -0,0 +1 @@ +{"configured":false,"enrolled":false,"enrolled_at":null,"speaker_nick":null} diff --git a/tests-ts/contracts/fixtures/sse/interview-chat-stream.txt b/tests-ts/contracts/fixtures/sse/interview-chat-stream.txt new file mode 100644 index 0000000..9257361 --- /dev/null +++ b/tests-ts/contracts/fixtures/sse/interview-chat-stream.txt @@ -0,0 +1,3 @@ +data: {"token":"你好"} + +data: {"done":true,"is_finished":false} diff --git a/tests-ts/contracts/fixtures/sse/rebuild-index-stream.txt b/tests-ts/contracts/fixtures/sse/rebuild-index-stream.txt new file mode 100644 index 0000000..2bc7aff --- /dev/null +++ b/tests-ts/contracts/fixtures/sse/rebuild-index-stream.txt @@ -0,0 +1,3 @@ +data: {"progress":0.5,"message":"正在索引"} + +data: {"done":true} diff --git a/tests-ts/contracts/http-response-boundaries.test.ts b/tests-ts/contracts/http-response-boundaries.test.ts new file mode 100644 index 0000000..8dc454a --- /dev/null +++ b/tests-ts/contracts/http-response-boundaries.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test' +import { ProviderResponseError, type InterviewUseCases, type ProfileUseCases } from '@techspar/core' +import { loadResponseFixture } from './fixture.ts' +import { boundaryApp, jsonHeaders } from './test-app.ts' + +describe('HTTP response boundary inventory', () => { + test('keeps service info response shape', async () => { + const response = await boundaryApp().request('/api/') + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/json') + expect(await response.json()).toMatchObject({ service: 'TechSpar', version: expect.any(String) }) + }) + + test('records the current dynamic topic-drill start response', async () => { + const interview = { + async start() { return loadResponseFixture('interview-start-topic.json') }, + } as unknown as InterviewUseCases + const response = await boundaryApp({ interview }).request('/api/interview/start', { + method: 'POST', headers: jsonHeaders, body: JSON.stringify({ mode: 'topic_drill', topic: 'typescript' }), + }) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/json') + expect(await response.json()).toMatchObject({ + session_id: expect.any(String), mode: 'topic_drill', topic: expect.any(String), questions: expect.any(Array), + }) + }) + + test('records the current profile response shape', async () => { + const profile = { + async get() { return loadResponseFixture('profile.json') }, + } as unknown as ProfileUseCases + const response = await boundaryApp({ profile }).request('/api/profile', { headers: { authorization: jsonHeaders.authorization } }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ weak_points: expect.any(Array), stats: expect.any(Object) }) + }) + + test('keeps task status responses parseable at the HTTP boundary', async () => { + const interview = { + async task() { return loadResponseFixture('task-pending.json') }, + } as unknown as InterviewUseCases + const response = await boundaryApp({ interview }).request('/api/tasks/task-1', { + headers: { authorization: jsonHeaders.authorization }, + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: 'pending', type: 'drill_review' }) + }) + + test('preserves the current interview SSE framing', async () => { + const interview = { + async *chatStream() { + yield { token: '你好' } + yield { done: true, is_finished: false } + }, + } as unknown as InterviewUseCases + const response = await boundaryApp({ interview }).request('/api/interview/chat/stream', { + method: 'POST', headers: jsonHeaders, body: JSON.stringify({ session_id: 'session-1', message: '开始' }), + }) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + const body = await response.text() + expect(body).toContain('data: {"token":"你好"}') + expect(body).toContain('data: {"done":true,"is_finished":false}') + }) + + test('preserves provider errors at the HTTP boundary', async () => { + const interview = { + async start() { throw new ProviderResponseError('模型服务响应无效') }, + } as unknown as InterviewUseCases + const response = await boundaryApp({ interview }).request('/api/interview/start', { + method: 'POST', headers: jsonHeaders, body: JSON.stringify({ mode: 'topic_drill', topic: 'typescript' }), + }) + expect(response.status).toBe(502) + expect(await response.json()).toEqual({ detail: '模型服务响应无效', code: 'provider_response_error' }) + }) + + test('returns the existing validation error format', async () => { + const response = await boundaryApp().request('/api/auth/login', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), + }) + expect(response.status).toBe(422) + expect(response.headers.get('content-type')).toContain('application/json') + expect(await response.json()).toMatchObject({ detail: expect.any(Array) }) + }) + + test('returns JSON 401 for missing authentication', async () => { + // Register the route so the request reaches the authentication boundary. + const response = await boundaryApp({ profile: {} as ProfileUseCases }).request('/api/profile') + expect(response.status).toBe(401) + expect(response.headers.get('content-type')).toContain('application/json') + expect(await response.json()).toEqual({ detail: 'Invalid or expired token' }) + }) +}) diff --git a/tests-ts/contracts/openapi-parity.test.ts b/tests-ts/contracts/openapi-parity.test.ts index 1e1cbf4..37036fa 100644 --- a/tests-ts/contracts/openapi-parity.test.ts +++ b/tests-ts/contracts/openapi-parity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { createApp, type AppDependencies } from '../../apps/api/src/app.ts' +import { inventoryOperations } from './response-inventory.ts' type Operation = { parameters?: unknown[] @@ -78,4 +79,9 @@ describe('FastAPI to Hono OpenAPI parity', () => { 'multipart/form-data', ]) }) + + test('keeps every generated HTTP operation in the response inventory', async () => { + const { current } = await loadSpecs() + expect(inventoryOperations).toEqual(operations(current)) + }) }) diff --git a/tests-ts/contracts/response-inventory.test.ts b/tests-ts/contracts/response-inventory.test.ts new file mode 100644 index 0000000..0664980 --- /dev/null +++ b/tests-ts/contracts/response-inventory.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { inventoryOperations, responseInventory } from './response-inventory.ts' + +describe('API response inventory', () => { + test('has unique HTTP operations', () => { + expect(new Set(inventoryOperations).size).toBe(inventoryOperations.length) + }) + + test('records a fixture for every JSON response family', () => { + const missing = responseInventory + .filter((entry) => entry.transport === 'json' && !entry.fixture) + .map((entry) => entry.operation) + expect(missing).toEqual([]) + }) + + test('points only to checked-in fixture files', async () => { + const missing = [] as string[] + for (const entry of responseInventory) { + if (!entry.fixture) continue + const root = entry.fixture.includes('/') ? 'tests-ts/contracts/fixtures' : 'tests-ts/contracts/fixtures/responses' + const path = `${root}/${entry.fixture}` + if (!await Bun.file(path).exists()) missing.push(path) + } + expect(missing).toEqual([]) + }) + + test('keeps JSON response fixtures parseable', async () => { + const invalid = [] as string[] + for (const entry of responseInventory) { + if (!entry.fixture || entry.fixture.includes('/')) continue + try { await Bun.file(`tests-ts/contracts/fixtures/responses/${entry.fixture}`).json() } + catch { invalid.push(entry.fixture) } + } + expect(invalid).toEqual([]) + }) + + test('marks current dynamic response surfaces for later tightening', () => { + const dynamic = responseInventory.filter((entry) => entry.dynamic).map((entry) => entry.operation) + expect(dynamic).toEqual(expect.arrayContaining([ + 'POST /api/interview/start', + 'GET /api/profile', + 'GET /api/copilot/prep/{prep_id}', + ])) + }) +}) diff --git a/tests-ts/contracts/response-inventory.ts b/tests-ts/contracts/response-inventory.ts new file mode 100644 index 0000000..60338c7 --- /dev/null +++ b/tests-ts/contracts/response-inventory.ts @@ -0,0 +1,99 @@ +export type ResponseTransport = 'json' | 'binary' | 'sse' | 'websocket' + +export type ResponseInventoryEntry = { + operation: string + routeFile: string + status: number + contentType: string + transport: ResponseTransport + fixture?: string + dynamic: boolean + requiredKeys?: readonly string[] + nullableKeys?: readonly string[] + notes?: string +} + +/** + * Current response facts. This is intentionally separate from contracts/src: + * phase one records behavior before phase two introduces strict DTO schemas. + */ +export const responseInventory: readonly ResponseInventoryEntry[] = [ + { operation: 'GET /api/', routeFile: 'apps/api/src/routes/auth.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'service-info.json', dynamic: false, requiredKeys: ['service', 'version'] }, + { operation: 'POST /api/auth/password', routeFile: 'apps/api/src/routes/auth.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'change-password.json', dynamic: false, requiredKeys: ['status'] }, + { operation: 'GET /api/auth/config', routeFile: 'apps/api/src/routes/auth.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'auth-config.json', dynamic: false, requiredKeys: ['allow_registration'] }, + { operation: 'POST /api/auth/login', routeFile: 'apps/api/src/routes/auth.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'auth-response.json', dynamic: false, requiredKeys: ['token', 'user'] }, + { operation: 'POST /api/auth/register', routeFile: 'apps/api/src/routes/auth.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'auth-response.json', dynamic: false, requiredKeys: ['token', 'user'] }, + { operation: 'GET /api/settings', routeFile: 'apps/api/src/routes/settings.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'settings-view.json', dynamic: false }, + { operation: 'PUT /api/settings', routeFile: 'apps/api/src/routes/settings.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'settings-update.json', dynamic: false, requiredKeys: ['ok', 'embedding_changed'] }, + { operation: 'GET /api/usage/quota', routeFile: 'apps/api/src/routes/settings.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'quota-status.json', dynamic: false }, + { operation: 'POST /api/settings/test-llm', routeFile: 'apps/api/src/routes/settings.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'settings-probe.json', dynamic: false }, + { operation: 'POST /api/settings/test-embedding', routeFile: 'apps/api/src/routes/settings.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'settings-probe.json', dynamic: false }, + { operation: 'POST /api/settings/rebuild-index', routeFile: 'apps/api/src/routes/settings.ts', status: 200, contentType: 'text/event-stream', transport: 'sse', fixture: 'sse/rebuild-index-stream.txt', dynamic: true }, + { operation: 'GET /api/topics', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'topics.json', dynamic: false }, + { operation: 'POST /api/topics', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'topic-created.json', dynamic: false }, + { operation: 'DELETE /api/topics/{key}', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'GET /api/knowledge/{topic}/core', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'knowledge-files.json', dynamic: false }, + { operation: 'POST /api/knowledge/{topic}/core', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'knowledge-created.json', dynamic: false }, + { operation: 'PUT /api/knowledge/{topic}/core/{filename}', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'DELETE /api/knowledge/{topic}/core/{filename}', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'POST /api/knowledge/{topic}/upload', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'knowledge-created.json', dynamic: false }, + { operation: 'POST /api/knowledge/{topic}/generate', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'knowledge-generated.json', dynamic: false }, + { operation: 'GET /api/knowledge/{topic}/high_freq', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'knowledge-content.json', dynamic: false }, + { operation: 'PUT /api/knowledge/{topic}/high_freq', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'GET /api/graph/{topic}', routeFile: 'apps/api/src/routes/knowledge.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'question-graph.json', dynamic: true }, + { operation: 'GET /api/resume/status', routeFile: 'apps/api/src/routes/resume.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'resume-status-empty.json', dynamic: false }, + { operation: 'GET /api/resume/file', routeFile: 'apps/api/src/routes/resume.ts', status: 200, contentType: 'application/pdf', transport: 'binary', dynamic: false }, + { operation: 'DELETE /api/resume', routeFile: 'apps/api/src/routes/resume.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'POST /api/resume/parse', routeFile: 'apps/api/src/routes/resume.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'resume-parsed.json', dynamic: true }, + { operation: 'POST /api/resume/upload', routeFile: 'apps/api/src/routes/resume.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'resume-uploaded.json', dynamic: false }, + { operation: 'POST /api/transcribe', routeFile: 'apps/api/src/routes/resume.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'transcription.json', dynamic: false }, + { operation: 'POST /api/job-prep/preview', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-job-preview.json', dynamic: true }, + { operation: 'POST /api/job-prep/start', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-start-jd.json', dynamic: true }, + { operation: 'POST /api/interview/start', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-start-topic.json', dynamic: true }, + { operation: 'POST /api/interview/chat', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-chat.json', dynamic: true }, + { operation: 'POST /api/interview/chat/stream', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'text/event-stream', transport: 'sse', fixture: 'sse/interview-chat-stream.txt', dynamic: true }, + { operation: 'POST /api/interview/end/{session_id}', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-end-pending.json', dynamic: true }, + { operation: 'POST /api/interview/draft/{session_id}', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-draft.json', dynamic: true }, + { operation: 'POST /api/interview/review/{session_id}/generate', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-review-pending.json', dynamic: true }, + { operation: 'GET /api/interview/session/{session_id}/resume', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-resume.json', dynamic: true }, + { operation: 'POST /api/interview/reference-answer', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'reference-answer.json', dynamic: false }, + { operation: 'GET /api/interview/review/{session_id}', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-review.json', dynamic: true }, + { operation: 'GET /api/tasks/{task_id}', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'task-pending.json', dynamic: false }, + { operation: 'GET /api/interview/history', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-history.json', dynamic: true }, + { operation: 'DELETE /api/interview/session/{session_id}', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'GET /api/interview/topics', routeFile: 'apps/api/src/routes/interview.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'interview-topics.json', dynamic: false }, + { operation: 'GET /api/profile', routeFile: 'apps/api/src/routes/profile.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'profile.json', dynamic: true }, + { operation: 'POST /api/profile/infer-target-role', routeFile: 'apps/api/src/routes/profile.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'target-role.json', dynamic: false }, + { operation: 'POST /api/profile/viewed', routeFile: 'apps/api/src/routes/profile.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'profile.json', dynamic: true }, + { operation: 'POST /api/profile/pattern/feedback', routeFile: 'apps/api/src/routes/profile.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'profile-feedback.json', dynamic: true }, + { operation: 'GET /api/profile/due-reviews', routeFile: 'apps/api/src/routes/profile.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'due-reviews.json', dynamic: true }, + { operation: 'GET /api/profile/topic/{topic}/history', routeFile: 'apps/api/src/routes/profile.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'topic-history.json', dynamic: true }, + { operation: 'POST /api/profile/topic/{topic}/retrospective', routeFile: 'apps/api/src/routes/profile.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'retrospective-pending.json', dynamic: false }, + { operation: 'GET /api/personal-agent/documents', routeFile: 'apps/api/src/routes/personal-agent.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'personal-documents.json', dynamic: true }, + { operation: 'POST /api/personal-agent/documents', routeFile: 'apps/api/src/routes/personal-agent.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'personal-document.json', dynamic: true }, + { operation: 'DELETE /api/personal-agent/documents/{document_id}', routeFile: 'apps/api/src/routes/personal-agent.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'GET /api/personal-agent/conversations', routeFile: 'apps/api/src/routes/personal-agent.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'personal-conversations.json', dynamic: true }, + { operation: 'GET /api/personal-agent/conversations/{conversation_id}', routeFile: 'apps/api/src/routes/personal-agent.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'personal-conversation.json', dynamic: true }, + { operation: 'DELETE /api/personal-agent/conversations/{conversation_id}', routeFile: 'apps/api/src/routes/personal-agent.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'POST /api/personal-agent/chat', routeFile: 'apps/api/src/routes/personal-agent.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'personal-agent-chat.json', dynamic: false }, + { operation: 'POST /api/recording/transcribe', routeFile: 'apps/api/src/routes/recording.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'recording-transcription.json', dynamic: false }, + { operation: 'POST /api/recording/analyze', routeFile: 'apps/api/src/routes/recording.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'recording-analyze-pending.json', dynamic: false }, + { operation: 'GET /api/data/export', routeFile: 'apps/api/src/routes/data-migration.ts', status: 200, contentType: 'application/gzip', transport: 'binary', dynamic: false }, + { operation: 'GET /api/data/export/personal', routeFile: 'apps/api/src/routes/data-migration.ts', status: 200, contentType: 'application/gzip', transport: 'binary', dynamic: false }, + { operation: 'POST /api/data/import', routeFile: 'apps/api/src/routes/data-migration.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'data-import.json', dynamic: true }, + { operation: 'POST /api/copilot/prep', routeFile: 'apps/api/src/routes/copilot.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'copilot-prep-created.json', dynamic: false }, + { operation: 'GET /api/copilot/preps', routeFile: 'apps/api/src/routes/copilot.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'copilot-preps.json', dynamic: false }, + { operation: 'GET /api/copilot/prep/{prep_id}', routeFile: 'apps/api/src/routes/copilot.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'copilot-prep-status.json', dynamic: true }, + { operation: 'GET /api/copilot/prep/{prep_id}/tree', routeFile: 'apps/api/src/routes/copilot.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'copilot-tree.json', dynamic: true }, + { operation: 'DELETE /api/copilot/prep/{prep_id}', routeFile: 'apps/api/src/routes/copilot.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'GET /api/voiceprint/status', routeFile: 'apps/api/src/routes/voiceprint.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'voiceprint-status.json', dynamic: false }, + { operation: 'PUT /api/voiceprint/credentials', routeFile: 'apps/api/src/routes/voiceprint.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'POST /api/voiceprint/enroll', routeFile: 'apps/api/src/routes/voiceprint.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'voiceprint-enrolled.json', dynamic: false }, + { operation: 'DELETE /api/voiceprint/enroll', routeFile: 'apps/api/src/routes/voiceprint.ts', status: 200, contentType: 'application/json', transport: 'json', fixture: 'ok.json', dynamic: false }, + { operation: 'WS /ws/copilot/{session_id}', routeFile: 'apps/api/src/routes/copilot.ts', status: 101, contentType: 'application/json', transport: 'websocket', fixture: 'copilot/events.json', dynamic: true, notes: 'WebSocket is not represented as an OpenAPI HTTP operation.' }, +] + +export const inventoryOperations = responseInventory + .filter((entry) => entry.transport !== 'websocket') + .map((entry) => entry.operation) + .sort() diff --git a/tests-ts/contracts/test-app.ts b/tests-ts/contracts/test-app.ts new file mode 100644 index 0000000..27ee7db --- /dev/null +++ b/tests-ts/contracts/test-app.ts @@ -0,0 +1,34 @@ +import type { AppDependencies } from '../../apps/api/src/app.ts' +import { createApp } from '../../apps/api/src/app.ts' + +export const TEST_TOKEN = 'test-token' + +export const jsonHeaders = { + authorization: `Bearer ${TEST_TOKEN}`, + 'content-type': 'application/json', +} + +/** Fail loudly if a boundary test calls an adapter it did not explicitly provide. */ +export const unavailable = new Proxy({}, { + get() { + return () => Promise.reject(new Error('unexpected dependency call')) + }, +}) + +export const testTokens = { + async create() { return TEST_TOKEN }, + async decode(token: string) { return token === TEST_TOKEN ? 'user-a' : undefined }, +} + +export function boundaryApp(overrides: Partial = {}): ReturnType { + return createApp({ + auth: unavailable, + registration: { allowRegistration: false }, + settings: unavailable, + quota: unavailable, + tokens: testTokens, + knowledge: unavailable, + resume: unavailable, + ...overrides, + } as unknown as AppDependencies) +} diff --git a/tests-ts/http-nullable-contracts.test.ts b/tests-ts/http-nullable-contracts.test.ts index 7ce3734..6484d82 100644 --- a/tests-ts/http-nullable-contracts.test.ts +++ b/tests-ts/http-nullable-contracts.test.ts @@ -1,22 +1,6 @@ import { describe, expect, test } from 'bun:test' import { ProviderResponseError, type CopilotPrepUseCases, type InterviewUseCases, type JobPrepInput, type PersonalAgentUseCases, type StartInterviewInput } from '@techspar/core' -import { createApp, type AppDependencies } from '../apps/api/src/app.ts' - -const unavailable = new Proxy({}, { get() { return () => Promise.reject(new Error('unexpected dependency call')) } }) -const headers = { authorization: 'Bearer test-token', 'content-type': 'application/json' } - -function boundaryApp(overrides: Partial) { - return createApp({ - auth: unavailable, - registration: { allowRegistration: false }, - settings: unavailable, - quota: unavailable, - tokens: { async create() { return 'test-token' }, async decode(token: string) { return token === 'test-token' ? 'user-a' : undefined } }, - knowledge: unavailable, - resume: unavailable, - ...overrides, - } as unknown as AppDependencies) -} +import { boundaryApp, jsonHeaders as headers } from './contracts/test-app.ts' describe('legacy nullable HTTP request contracts', () => { test('accepts numeric confidence when ending a batch interview', async () => {