mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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.
This commit is contained in:
parent
025f41dddd
commit
b0dfd6b3b4
22 changed files with 543 additions and 99 deletions
|
|
@ -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(
|
||||
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
|
||||
);
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<SubmitInterviewAnswerArg, "questionId">) => {
|
||||
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<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
switch (question.question_type) {
|
||||
case QuestionType.YES_NO:
|
||||
|
|
@ -216,14 +217,14 @@ function YesNoBody({
|
|||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ value: "no" })}
|
||||
onClick={() => void onSubmit({ kind: "no" })}
|
||||
className={CHOICE_BUTTON}
|
||||
>
|
||||
No
|
||||
|
|
@ -231,7 +232,7 @@ function YesNoBody({
|
|||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ value: "yes" })}
|
||||
onClick={() => void onSubmit({ kind: "yes" })}
|
||||
className={PRIMARY_BUTTON}
|
||||
>
|
||||
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
|
||||
|
|
@ -246,14 +247,14 @@ function ConfirmationBody({
|
|||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ value: "yes" })}
|
||||
onClick={() => void onSubmit({ kind: "yes" })}
|
||||
className={PRIMARY_BUTTON}
|
||||
>
|
||||
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
|
||||
|
|
@ -272,7 +273,7 @@ function ChoiceBody({
|
|||
options: ApiQuestionOption[];
|
||||
allowFreeform: boolean;
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -283,7 +284,7 @@ function ChoiceBody({
|
|||
key={option.key}
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ selected_option_key: option.key })}
|
||||
onClick={() => void onSubmit({ kind: "selected", option_key: option.key })}
|
||||
className={CHOICE_BUTTON}
|
||||
>
|
||||
{displayLabel(option.label)}
|
||||
|
|
@ -315,7 +316,7 @@ function MultiSelectBody({
|
|||
}: {
|
||||
options: ApiQuestionOption[];
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -357,7 +358,7 @@ function MultiSelectBody({
|
|||
<button
|
||||
type="button"
|
||||
disabled={submitting || selectedKeys.length === 0}
|
||||
onClick={() => void onSubmit({ selected_option_keys: selectedKeys })}
|
||||
onClick={() => void onSubmit({ kind: "multi_selected", option_keys: selectedKeys })}
|
||||
className={PRIMARY_BUTTON}
|
||||
>
|
||||
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
|
||||
|
|
@ -377,7 +378,7 @@ function FreeformBody({
|
|||
divider = false,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
placeholder: string;
|
||||
submitLabel: string;
|
||||
autoFocus?: boolean;
|
||||
|
|
@ -394,7 +395,7 @@ function FreeformBody({
|
|||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || submitting) return;
|
||||
await onSubmit({ value: trimmed });
|
||||
await onSubmit({ kind: "text", text: trimmed });
|
||||
setValue("");
|
||||
}
|
||||
|
||||
|
|
@ -484,3 +485,12 @@ export function displayLabel(label: string): string {
|
|||
.trim();
|
||||
return stripped || trimmed;
|
||||
}
|
||||
|
||||
function interviewSubmitErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.requestId
|
||||
? `${error.message} Request ID: ${error.requestId}`
|
||||
: error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : "Couldn't submit your answer.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,12 @@ describe("generated Axios adapter", () => {
|
|||
|
||||
test("normalizes generated client failures into ApiError", async () => {
|
||||
const body = {
|
||||
errors: [{ status: "500", title: "Internal", request_id: "body-req" }],
|
||||
errors: [{
|
||||
status: "500",
|
||||
title: "Internal",
|
||||
detail: "Database is unavailable.",
|
||||
request_id: "body-req",
|
||||
}],
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -64,7 +69,7 @@ describe("generated Axios adapter", () => {
|
|||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error).toMatchObject({
|
||||
status: 500,
|
||||
message: "Internal Server Error",
|
||||
message: "Database is unavailable.",
|
||||
requestId: "header-req",
|
||||
body,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -176,12 +176,23 @@ function apiErrorFromAxios(error: unknown): ApiError | null {
|
|||
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(response.data);
|
||||
return new ApiError({
|
||||
status: response.status,
|
||||
message: response.statusText || `HTTP ${response.status}`,
|
||||
message: extractErrorDetail(response.data) ?? (response.statusText || `HTTP ${response.status}`),
|
||||
requestId,
|
||||
body: response.data ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function extractErrorDetail(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const errors = (body as Record<string, unknown>).errors;
|
||||
if (!Array.isArray(errors) || errors.length === 0) return null;
|
||||
|
||||
const first = errors[0];
|
||||
if (!first || typeof first !== "object") return null;
|
||||
const detail = (first as Record<string, unknown>).detail;
|
||||
return typeof detail === "string" && detail.length > 0 ? detail : null;
|
||||
}
|
||||
|
||||
function redirectToLogin(error: ApiError, options: ApiCallOptions) {
|
||||
if (error.status !== 401 || options.redirectOnUnauthorized === false) return;
|
||||
if (typeof window !== "undefined") {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,10 @@ function useLifecycleMutation(
|
|||
);
|
||||
}
|
||||
|
||||
export type SubmitInterviewAnswerArg = SubmitAnswerRequest & { questionId: string };
|
||||
export type SubmitInterviewAnswerArg = {
|
||||
questionId: string;
|
||||
answer: SubmitAnswerRequest;
|
||||
};
|
||||
|
||||
export function useSubmitInterviewAnswer(runId: string | undefined) {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
|
@ -109,8 +112,9 @@ export function useSubmitInterviewAnswer(runId: string | undefined) {
|
|||
runId ? `interview-answer:${runId}` : null,
|
||||
async (_key: string, { arg }: { arg: SubmitInterviewAnswerArg }) => {
|
||||
if (!runId) throw new Error("runId is required");
|
||||
const { questionId, ...body } = arg;
|
||||
await apiData(() => humanInTheLoopApi.submitRunAnswer(runId, questionId, body));
|
||||
await apiData(() =>
|
||||
humanInTheLoopApi.submitRunAnswer(runId, arg.questionId, arg.answer),
|
||||
);
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
|
|||
|
|
@ -5177,25 +5177,90 @@ components:
|
|||
|
||||
SubmitAnswerRequest:
|
||||
description: >
|
||||
Request body for submitting an answer to a pending question.
|
||||
At least one of `value`, `selected_option_key`, or `selected_option_keys` must be provided.
|
||||
Request body for submitting an answer to a pending question. The
|
||||
`kind` discriminator determines which answer shape is submitted.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SubmitAnswerYesRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerNoRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerSelectedRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerMultiSelectedRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerTextRequest"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
"yes": "#/components/schemas/SubmitAnswerYesRequest"
|
||||
"no": "#/components/schemas/SubmitAnswerNoRequest"
|
||||
selected: "#/components/schemas/SubmitAnswerSelectedRequest"
|
||||
multi_selected: "#/components/schemas/SubmitAnswerMultiSelectedRequest"
|
||||
text: "#/components/schemas/SubmitAnswerTextRequest"
|
||||
|
||||
SubmitAnswerYesRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
value:
|
||||
kind:
|
||||
type: string
|
||||
description: Freeform answer text.
|
||||
example: "Yes, proceed with the changes."
|
||||
selected_option_key:
|
||||
enum: ["yes"]
|
||||
description: Affirmative answer for yes/no and confirmation questions.
|
||||
|
||||
SubmitAnswerNoRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
description: Key of the selected option (for single-select multiple-choice questions).
|
||||
enum: ["no"]
|
||||
description: Negative answer for yes/no questions.
|
||||
|
||||
SubmitAnswerSelectedRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- option_key
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [selected]
|
||||
description: Single selected option answer.
|
||||
option_key:
|
||||
type: string
|
||||
description: Key of the selected option.
|
||||
example: option_a
|
||||
selected_option_keys:
|
||||
|
||||
SubmitAnswerMultiSelectedRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- option_keys
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [multi_selected]
|
||||
description: Multiple selected option answer.
|
||||
option_keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Keys of selected options (for multi-select questions).
|
||||
description: Keys of selected options.
|
||||
example: ["option_a", "option_b"]
|
||||
|
||||
SubmitAnswerTextRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- text
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [text]
|
||||
description: Freeform text answer.
|
||||
text:
|
||||
type: string
|
||||
description: Freeform answer text.
|
||||
example: "Yes, proceed with the changes."
|
||||
|
||||
ErrorResponseEntry:
|
||||
description: A single error entry in an error response.
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
use fabro_api::types::SubmitAnswerRequest;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn typed_answer_variants_round_trip_through_json() {
|
||||
let cases = [
|
||||
json!({ "kind": "yes" }),
|
||||
json!({ "kind": "no" }),
|
||||
json!({ "kind": "selected", "option_key": "approve" }),
|
||||
json!({ "kind": "multi_selected", "option_keys": ["approve", "notify"] }),
|
||||
json!({ "kind": "text", "text": "Looks good to me." }),
|
||||
];
|
||||
|
||||
for payload in cases {
|
||||
let request: SubmitAnswerRequest = serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), payload);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_answer_fields_are_not_part_of_the_wire_contract() {
|
||||
let result = serde_json::from_value::<SubmitAnswerRequest>(json!({ "value": "yes" }));
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
|
@ -313,12 +313,30 @@ async fn submit_server_interview_answer(
|
|||
qid: &str,
|
||||
answer: &fabro_interview::Answer,
|
||||
) -> Result<bool> {
|
||||
let (value, selected_option_key, selected_option_keys) = match &answer.value {
|
||||
AnswerValue::Text(text) => (Some(text.clone()), None, Vec::new()),
|
||||
AnswerValue::Selected(key) => (None, Some(key.clone()), Vec::new()),
|
||||
AnswerValue::MultiSelected(keys) => (None, None, keys.clone()),
|
||||
AnswerValue::Yes => (Some("yes".to_string()), None, Vec::new()),
|
||||
AnswerValue::No => (Some("no".to_string()), None, Vec::new()),
|
||||
let body = match &answer.value {
|
||||
AnswerValue::Text(text) => types::SubmitAnswerTextRequest {
|
||||
kind: types::SubmitAnswerTextRequestKind::Text,
|
||||
text: text.clone(),
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::Selected(key) => types::SubmitAnswerSelectedRequest {
|
||||
kind: types::SubmitAnswerSelectedRequestKind::Selected,
|
||||
option_key: key.clone(),
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::MultiSelected(keys) => types::SubmitAnswerMultiSelectedRequest {
|
||||
kind: types::SubmitAnswerMultiSelectedRequestKind::MultiSelected,
|
||||
option_keys: keys.clone(),
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::Yes => types::SubmitAnswerYesRequest {
|
||||
kind: types::SubmitAnswerYesRequestKind::Yes,
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::No => types::SubmitAnswerNoRequest {
|
||||
kind: types::SubmitAnswerNoRequestKind::No,
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::Cancelled
|
||||
| AnswerValue::Interrupted
|
||||
| AnswerValue::Skipped
|
||||
|
|
@ -326,15 +344,7 @@ async fn submit_server_interview_answer(
|
|||
return Ok(false);
|
||||
}
|
||||
};
|
||||
client
|
||||
.submit_run_answer(
|
||||
run_id,
|
||||
qid,
|
||||
value,
|
||||
selected_option_key,
|
||||
selected_option_keys,
|
||||
)
|
||||
.await?;
|
||||
client.submit_run_answer(run_id, qid, body).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -977,7 +977,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
.post(format!(
|
||||
"{base_url}/api/v1/runs/{run_id}/questions/{question_id}/answer"
|
||||
))
|
||||
.json(&serde_json::json!({ "selected_option_key": "A" }))
|
||||
.json(&serde_json::json!({ "kind": "selected", "option_key": "A" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("answer submission should succeed");
|
||||
|
|
|
|||
|
|
@ -781,7 +781,7 @@ fn detached_run_answers_pending_question_without_interview_scratch_files() {
|
|||
.post(format!(
|
||||
"{base_url}/api/v1/runs/{run_id}/questions/{question_id}/answer"
|
||||
))
|
||||
.json(&serde_json::json!({ "selected_option_key": "A" }))
|
||||
.json(&serde_json::json!({ "kind": "selected", "option_key": "A" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("answer submission should succeed");
|
||||
|
|
|
|||
|
|
@ -1159,20 +1159,14 @@ impl Client {
|
|||
&self,
|
||||
run_id: &RunId,
|
||||
qid: &str,
|
||||
value: Option<String>,
|
||||
selected_option_key: Option<String>,
|
||||
selected_option_keys: Vec<String>,
|
||||
body: types::SubmitAnswerRequest,
|
||||
) -> Result<()> {
|
||||
self.send_api(|client| async move {
|
||||
client
|
||||
.submit_run_answer()
|
||||
.id(run_id.to_string())
|
||||
.qid(qid)
|
||||
.body(types::SubmitAnswerRequest {
|
||||
value: value.clone(),
|
||||
selected_option_key: selected_option_key.clone(),
|
||||
selected_option_keys: selected_option_keys.clone(),
|
||||
})
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2590,9 +2590,10 @@ fn validate_answer_for_question(
|
|||
) -> Result<(), Response> {
|
||||
match (&question.question_type, &answer.value) {
|
||||
(
|
||||
QuestionType::YesNo | QuestionType::Confirmation,
|
||||
QuestionType::YesNo,
|
||||
fabro_interview::AnswerValue::Yes | fabro_interview::AnswerValue::No,
|
||||
)
|
||||
| (QuestionType::Confirmation, fabro_interview::AnswerValue::Yes)
|
||||
| (
|
||||
_,
|
||||
fabro_interview::AnswerValue::Interrupted
|
||||
|
|
@ -2692,31 +2693,31 @@ fn answer_from_request(
|
|||
req: SubmitAnswerRequest,
|
||||
question: &InterviewQuestionRecord,
|
||||
) -> Result<Answer, Response> {
|
||||
if let Some(key) = req.selected_option_key {
|
||||
let option = question
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.key == key)
|
||||
.cloned();
|
||||
match option {
|
||||
Some(option) => Ok(Answer::selected(key, option)),
|
||||
None => Err(ApiError::bad_request("Invalid option key.").into_response()),
|
||||
}
|
||||
} else if !req.selected_option_keys.is_empty() {
|
||||
for key in &req.selected_option_keys {
|
||||
let valid = question.options.iter().any(|option| option.key == *key);
|
||||
if !valid {
|
||||
return Err(ApiError::bad_request("Invalid option key.").into_response());
|
||||
match req {
|
||||
SubmitAnswerRequest::YesRequest(_) => Ok(Answer::yes()),
|
||||
SubmitAnswerRequest::NoRequest(_) => Ok(Answer::no()),
|
||||
SubmitAnswerRequest::SelectedRequest(req) => {
|
||||
let key = req.option_key;
|
||||
let option = question
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.key == key)
|
||||
.cloned();
|
||||
match option {
|
||||
Some(option) => Ok(Answer::selected(key, option)),
|
||||
None => Err(ApiError::bad_request("Invalid option key.").into_response()),
|
||||
}
|
||||
}
|
||||
Ok(Answer::multi_selected(req.selected_option_keys))
|
||||
} else if let Some(value) = req.value {
|
||||
Ok(Answer::text(value))
|
||||
} else {
|
||||
Err(ApiError::bad_request(
|
||||
"One of value, selected_option_key, or selected_option_keys is required.",
|
||||
)
|
||||
.into_response())
|
||||
SubmitAnswerRequest::MultiSelectedRequest(req) => {
|
||||
for key in &req.option_keys {
|
||||
let valid = question.options.iter().any(|option| option.key == *key);
|
||||
if !valid {
|
||||
return Err(ApiError::bad_request("Invalid option key.").into_response());
|
||||
}
|
||||
}
|
||||
Ok(Answer::multi_selected(req.option_keys))
|
||||
}
|
||||
SubmitAnswerRequest::TextRequest(req) => Ok(Answer::text(req.text)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3983,7 +3983,7 @@ async fn submit_answer_not_found_run() {
|
|||
.uri(api(&format!("/runs/{missing_run_id}/questions/q1/answer")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
|
||||
serde_json::to_string(&serde_json::json!({"kind": "yes"})).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -4026,6 +4026,109 @@ async fn submit_pending_interview_answer_rejects_invalid_answer_shape() {
|
|||
assert_status!(response, StatusCode::BAD_REQUEST).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_from_typed_yes_request_maps_to_yes_answer() {
|
||||
let question = InterviewQuestionRecord {
|
||||
id: "q-1".to_string(),
|
||||
text: "Continue?".to_string(),
|
||||
stage: "gate".to_string(),
|
||||
question_type: QuestionType::YesNo,
|
||||
options: vec![],
|
||||
allow_freeform: false,
|
||||
timeout_seconds: None,
|
||||
context_display: None,
|
||||
};
|
||||
let req: SubmitAnswerRequest = serde_json::from_value(json!({ "kind": "yes" })).unwrap();
|
||||
|
||||
let answer = answer_from_request(req, &question).unwrap();
|
||||
|
||||
assert_eq!(answer.value, AnswerValue::Yes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_from_typed_no_request_maps_to_no_answer() {
|
||||
let question = InterviewQuestionRecord {
|
||||
id: "q-1".to_string(),
|
||||
text: "Continue?".to_string(),
|
||||
stage: "gate".to_string(),
|
||||
question_type: QuestionType::YesNo,
|
||||
options: vec![],
|
||||
allow_freeform: false,
|
||||
timeout_seconds: None,
|
||||
context_display: None,
|
||||
};
|
||||
let req: SubmitAnswerRequest = serde_json::from_value(json!({ "kind": "no" })).unwrap();
|
||||
|
||||
let answer = answer_from_request(req, &question).unwrap();
|
||||
|
||||
assert_eq!(answer.value, AnswerValue::No);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_from_typed_selected_request_validates_and_attaches_option() {
|
||||
let question = InterviewQuestionRecord {
|
||||
id: "q-1".to_string(),
|
||||
text: "Choose one.".to_string(),
|
||||
stage: "gate".to_string(),
|
||||
question_type: QuestionType::MultipleChoice,
|
||||
options: vec![fabro_types::run_event::InterviewOption {
|
||||
key: "approve".to_string(),
|
||||
label: "Approve".to_string(),
|
||||
}],
|
||||
allow_freeform: false,
|
||||
timeout_seconds: None,
|
||||
context_display: None,
|
||||
};
|
||||
let req: SubmitAnswerRequest =
|
||||
serde_json::from_value(json!({ "kind": "selected", "option_key": "approve" })).unwrap();
|
||||
|
||||
let answer = answer_from_request(req, &question).unwrap();
|
||||
|
||||
assert_eq!(answer.value, AnswerValue::Selected("approve".to_string()));
|
||||
assert_eq!(
|
||||
answer
|
||||
.selected_option
|
||||
.as_ref()
|
||||
.map(|option| option.label.as_str()),
|
||||
Some("Approve")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_from_typed_multi_selected_request_validates_option_keys() {
|
||||
let question = InterviewQuestionRecord {
|
||||
id: "q-1".to_string(),
|
||||
text: "Choose many.".to_string(),
|
||||
stage: "gate".to_string(),
|
||||
question_type: QuestionType::MultiSelect,
|
||||
options: vec![
|
||||
fabro_types::run_event::InterviewOption {
|
||||
key: "approve".to_string(),
|
||||
label: "Approve".to_string(),
|
||||
},
|
||||
fabro_types::run_event::InterviewOption {
|
||||
key: "notify".to_string(),
|
||||
label: "Notify".to_string(),
|
||||
},
|
||||
],
|
||||
allow_freeform: false,
|
||||
timeout_seconds: None,
|
||||
context_display: None,
|
||||
};
|
||||
let req: SubmitAnswerRequest = serde_json::from_value(json!({
|
||||
"kind": "multi_selected",
|
||||
"option_keys": ["approve", "notify"],
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let answer = answer_from_request(req, &question).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
answer.value,
|
||||
AnswerValue::MultiSelected(vec!["approve".to_string(), "notify".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_events_not_found() {
|
||||
let app = test_app_with();
|
||||
|
|
@ -8106,7 +8209,7 @@ async fn submit_answer_to_queued_run_returns_conflict() {
|
|||
.uri(api(&format!("/runs/{run_id}/questions/q1/answer")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
|
||||
serde_json::to_string(&serde_json::json!({"kind": "yes"})).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,11 @@ async fn full_http_lifecycle_approve_and_complete() {
|
|||
)))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({"selected_option_key": "A"})).unwrap(),
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"kind": "selected",
|
||||
"option_key": "A",
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
|
|
|
|||
|
|
@ -312,7 +312,12 @@ models/stage-projection.ts
|
|||
models/stage-state.ts
|
||||
models/start-run-request.ts
|
||||
models/steer-run-request.ts
|
||||
models/submit-answer-multi-selected-request.ts
|
||||
models/submit-answer-no-request.ts
|
||||
models/submit-answer-request.ts
|
||||
models/submit-answer-selected-request.ts
|
||||
models/submit-answer-text-request.ts
|
||||
models/submit-answer-yes-request.ts
|
||||
models/success-reason.ts
|
||||
models/system-actor-kind.ts
|
||||
models/system-features.ts
|
||||
|
|
|
|||
|
|
@ -289,7 +289,12 @@ export * from './stage-projection';
|
|||
export * from './stage-state';
|
||||
export * from './start-run-request';
|
||||
export * from './steer-run-request';
|
||||
export * from './submit-answer-multi-selected-request';
|
||||
export * from './submit-answer-no-request';
|
||||
export * from './submit-answer-request';
|
||||
export * from './submit-answer-selected-request';
|
||||
export * from './submit-answer-text-request';
|
||||
export * from './submit-answer-yes-request';
|
||||
export * from './success-reason';
|
||||
export * from './system-actor-kind';
|
||||
export * from './system-features';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export interface SubmitAnswerMultiSelectedRequest {
|
||||
/**
|
||||
* Multiple selected option answer.
|
||||
*/
|
||||
'kind': SubmitAnswerMultiSelectedRequestKindEnum;
|
||||
/**
|
||||
* Keys of selected options.
|
||||
*/
|
||||
'option_keys': Array<string>;
|
||||
}
|
||||
|
||||
export const SubmitAnswerMultiSelectedRequestKindEnum = {
|
||||
MULTI_SELECTED: 'multi_selected'
|
||||
} as const;
|
||||
|
||||
export type SubmitAnswerMultiSelectedRequestKindEnum = typeof SubmitAnswerMultiSelectedRequestKindEnum[keyof typeof SubmitAnswerMultiSelectedRequestKindEnum];
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export interface SubmitAnswerNoRequest {
|
||||
/**
|
||||
* Negative answer for yes/no questions.
|
||||
*/
|
||||
'kind': SubmitAnswerNoRequestKindEnum;
|
||||
}
|
||||
|
||||
export const SubmitAnswerNoRequestKindEnum = {
|
||||
NO: 'no'
|
||||
} as const;
|
||||
|
||||
export type SubmitAnswerNoRequestKindEnum = typeof SubmitAnswerNoRequestKindEnum[keyof typeof SubmitAnswerNoRequestKindEnum];
|
||||
|
||||
|
||||
|
|
@ -13,22 +13,26 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SubmitAnswerMultiSelectedRequest } from './submit-answer-multi-selected-request';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SubmitAnswerNoRequest } from './submit-answer-no-request';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SubmitAnswerSelectedRequest } from './submit-answer-selected-request';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SubmitAnswerTextRequest } from './submit-answer-text-request';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SubmitAnswerYesRequest } from './submit-answer-yes-request';
|
||||
|
||||
/**
|
||||
* Request body for submitting an answer to a pending question. At least one of `value`, `selected_option_key`, or `selected_option_keys` must be provided.
|
||||
* @type SubmitAnswerRequest
|
||||
* Request body for submitting an answer to a pending question. The `kind` discriminator determines which answer shape is submitted.
|
||||
*/
|
||||
export interface SubmitAnswerRequest {
|
||||
/**
|
||||
* Freeform answer text.
|
||||
*/
|
||||
'value'?: string;
|
||||
/**
|
||||
* Key of the selected option (for single-select multiple-choice questions).
|
||||
*/
|
||||
'selected_option_key'?: string;
|
||||
/**
|
||||
* Keys of selected options (for multi-select questions).
|
||||
*/
|
||||
'selected_option_keys'?: Array<string>;
|
||||
}
|
||||
export type SubmitAnswerRequest = { kind: 'multi_selected' } & SubmitAnswerMultiSelectedRequest | { kind: 'no' } & SubmitAnswerNoRequest | { kind: 'selected' } & SubmitAnswerSelectedRequest | { kind: 'text' } & SubmitAnswerTextRequest | { kind: 'yes' } & SubmitAnswerYesRequest;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export interface SubmitAnswerSelectedRequest {
|
||||
/**
|
||||
* Single selected option answer.
|
||||
*/
|
||||
'kind': SubmitAnswerSelectedRequestKindEnum;
|
||||
/**
|
||||
* Key of the selected option.
|
||||
*/
|
||||
'option_key': string;
|
||||
}
|
||||
|
||||
export const SubmitAnswerSelectedRequestKindEnum = {
|
||||
SELECTED: 'selected'
|
||||
} as const;
|
||||
|
||||
export type SubmitAnswerSelectedRequestKindEnum = typeof SubmitAnswerSelectedRequestKindEnum[keyof typeof SubmitAnswerSelectedRequestKindEnum];
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export interface SubmitAnswerTextRequest {
|
||||
/**
|
||||
* Freeform text answer.
|
||||
*/
|
||||
'kind': SubmitAnswerTextRequestKindEnum;
|
||||
/**
|
||||
* Freeform answer text.
|
||||
*/
|
||||
'text': string;
|
||||
}
|
||||
|
||||
export const SubmitAnswerTextRequestKindEnum = {
|
||||
TEXT: 'text'
|
||||
} as const;
|
||||
|
||||
export type SubmitAnswerTextRequestKindEnum = typeof SubmitAnswerTextRequestKindEnum[keyof typeof SubmitAnswerTextRequestKindEnum];
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export interface SubmitAnswerYesRequest {
|
||||
/**
|
||||
* Affirmative answer for yes/no and confirmation questions.
|
||||
*/
|
||||
'kind': SubmitAnswerYesRequestKindEnum;
|
||||
}
|
||||
|
||||
export const SubmitAnswerYesRequestKindEnum = {
|
||||
YES: 'yes'
|
||||
} as const;
|
||||
|
||||
export type SubmitAnswerYesRequestKindEnum = typeof SubmitAnswerYesRequestKindEnum[keyof typeof SubmitAnswerYesRequestKindEnum];
|
||||
|
||||
|
||||
Loading…
Add table
Reference in a new issue