From b0dfd6b3b431e08d911b3ac8f2c3a0b6c5856658 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 8 May 2026 09:18:35 -0700 Subject: [PATCH] fix(api): type interview answer submissions Replace the loose interview answer request payload with a discriminated OpenAPI union so generated clients enforce the wire contract. Surface structured HTTP error details in the web client and update browser, CLI, and server answer submission paths to use the typed variants. --- .../app/components/interview-dock.test.tsx | 36 ++++++ .../app/components/interview-dock.tsx | 44 ++++--- apps/fabro-web/app/lib/api-client.test.ts | 9 +- apps/fabro-web/app/lib/api-client.ts | 13 ++- apps/fabro-web/app/lib/mutations.ts | 10 +- docs/public/api-reference/fabro-api.yaml | 83 ++++++++++++-- .../tests/submit_answer_request_round_trip.rs | 25 ++++ .../fabro-cli/src/commands/run/attach.rs | 40 ++++--- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 2 +- lib/crates/fabro-client/src/client.rs | 10 +- lib/crates/fabro-server/src/server.rs | 49 ++++---- lib/crates/fabro-server/src/server/tests.rs | 107 +++++++++++++++++- .../tests/it/scenario/lifecycle.rs | 6 +- .../src/.openapi-generator/FILES | 5 + .../fabro-api-client/src/models/index.ts | 5 + .../submit-answer-multi-selected-request.ts | 34 ++++++ .../src/models/submit-answer-no-request.ts | 30 +++++ .../src/models/submit-answer-request.ts | 34 +++--- .../models/submit-answer-selected-request.ts | 34 ++++++ .../src/models/submit-answer-text-request.ts | 34 ++++++ .../src/models/submit-answer-yes-request.ts | 30 +++++ 22 files changed, 543 insertions(+), 99 deletions(-) create mode 100644 lib/crates/fabro-api/tests/submit_answer_request_round_trip.rs create mode 100644 lib/packages/fabro-api-client/src/models/submit-answer-multi-selected-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/submit-answer-no-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/submit-answer-selected-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/submit-answer-text-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/submit-answer-yes-request.ts diff --git a/apps/fabro-web/app/components/interview-dock.test.tsx b/apps/fabro-web/app/components/interview-dock.test.tsx index 9ac272ee8..34a58269f 100644 --- a/apps/fabro-web/app/components/interview-dock.test.tsx +++ b/apps/fabro-web/app/components/interview-dock.test.tsx @@ -7,6 +7,7 @@ import { } from "@qltysh/fabro-api-client"; import { InterviewDock, displayLabel } from "./interview-dock"; +import { generatedAxios } from "../lib/api-client"; function render(node: React.ReactNode): TestRenderer.ReactTestRenderer { let tree: TestRenderer.ReactTestRenderer | undefined; @@ -81,6 +82,41 @@ describe("InterviewDock", () => { expect(buttons.No).toBeDefined(); }); + test("yes/no question submits typed yes and no answers", async () => { + const submitted: unknown[] = []; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + submitted.push(JSON.parse(String(config.data))); + return { + data: undefined, + status: 204, + statusText: "No Content", + headers: {}, + config, + }; + }; + + try { + const tree = render( + , + ); + const buttons = buttonsByText(tree); + + await act(async () => { + buttons.Yes.props.onClick(); + await Promise.resolve(); + }); + await act(async () => { + buttons.No.props.onClick(); + await Promise.resolve(); + }); + + expect(submitted).toEqual([{ kind: "yes" }, { kind: "no" }]); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + test("multiple choice question renders option buttons with stripped accelerator prefixes", () => { const question = makeQuestion({ question_type: QuestionType.MULTIPLE_CHOICE, diff --git a/apps/fabro-web/app/components/interview-dock.tsx b/apps/fabro-web/app/components/interview-dock.tsx index c45c67492..35eb2fcf5 100644 --- a/apps/fabro-web/app/components/interview-dock.tsx +++ b/apps/fabro-web/app/components/interview-dock.tsx @@ -22,6 +22,7 @@ import { useSubmitInterviewAnswer, type SubmitInterviewAnswerArg, } from "../lib/mutations"; +import { ApiError } from "../lib/api-client"; import { ErrorMessage } from "./ui"; const PRIMARY_BUTTON = @@ -33,6 +34,8 @@ const CHOICE_BUTTON = const CHOICE_BUTTON_SELECTED = "inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"; +type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"]; + export interface InterviewDockProps { runId: string; questions: ApiQuestion[]; @@ -52,15 +55,13 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) { }, [question?.id, submitMutation.reset]); const submit = useCallback( - async (arg: Omit) => { + async (answer: SubmitInterviewAnswer) => { if (!question) return; setError(null); try { - await submitMutation.trigger({ ...arg, questionId: question.id }); + await submitMutation.trigger({ questionId: question.id, answer }); } catch (caught) { - setError( - caught instanceof Error ? caught.message : "Couldn't submit your answer.", - ); + setError(interviewSubmitErrorMessage(caught)); } }, [question, submitMutation], @@ -172,7 +173,7 @@ function QuestionBody({ }: { question: ApiQuestion; submitting: boolean; - onSubmit: (arg: Omit) => Promise; + onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { switch (question.question_type) { case QuestionType.YES_NO: @@ -216,14 +217,14 @@ function YesNoBody({ onSubmit, }: { submitting: boolean; - onSubmit: (arg: Omit) => Promise; + onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { return (