diff --git a/frontend/src/shared/state/listThunksRejectOnError.test.ts b/frontend/src/shared/state/listThunksRejectOnError.test.ts new file mode 100644 index 00000000..4ca21540 --- /dev/null +++ b/frontend/src/shared/state/listThunksRejectOnError.test.ts @@ -0,0 +1,87 @@ +// Run: node --test (via frontend/scripts/run-tests.mjs) +// +// The chokepoint half of ENG-277. Every list thunk did: +// +// const res = await fetch(url); +// const data = await res.json(); +// return data.things; +// +// On a 401 that parses the ERROR body, `data.things` is undefined, the thunk RESOLVES, and the +// reducer's `for (const t of action.payload)` throws inside immer. Guarding the reducer stops the +// crash; guarding the thunk stops the bad value existing at all, which is the higher rung. +// +// These run the real payload creators against a stubbed fetch, so they test behaviour rather than +// the presence of a line of source. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchTools } from './toolsSlice.ts'; +import { fetchSkills } from './skillsSlice.ts'; +import { fetchModes } from './modesSlice.ts'; +import { fetchOutputs } from './outputsSlice.ts'; +import { fetchWorkflows } from './workflowsSlice.ts'; + +type Thunk = { (arg?: unknown): (d: unknown, g: unknown, e: unknown) => Promise<{ type: string; payload?: unknown }> }; + +const LIST_THUNKS: Array<[string, Thunk]> = [ + ['fetchTools', fetchTools as unknown as Thunk], + ['fetchSkills', fetchSkills as unknown as Thunk], + ['fetchModes', fetchModes as unknown as Thunk], + ['fetchOutputs', fetchOutputs as unknown as Thunk], + ['fetchWorkflows', fetchWorkflows as unknown as Thunk], +]; + +/** Run a thunk's payload creator with fetch stubbed, and report which lifecycle action it ended on. */ +async function runWith(thunk: Thunk, response: unknown): Promise { + const realFetch = globalThis.fetch; + (globalThis as { fetch: unknown }).fetch = async () => response; + try { + // createAsyncThunk ignores getState here except for `condition`; loading:false lets it run. + const getState = () => ({ + tools: { loading: false }, skills: { loading: false }, modes: { loading: false }, + outputs: { loading: false }, workflows: { loading: false, items: {} }, + }); + const action = await thunk(undefined)(() => {}, getState, undefined); + return action?.type ?? 'no-action'; + } finally { + (globalThis as { fetch: unknown }).fetch = realFetch; + } +} + +const unauthorized = { + ok: false, + status: 401, + json: async () => ({ detail: 'Unauthorized' }), +}; + +for (const [name, thunk] of LIST_THUNKS) { + test(`${name} REJECTS on 401 instead of resolving undefined into the reducer`, async () => { + const type = await runWith(thunk, unauthorized); + assert.ok( + type.endsWith('/rejected'), + `${name} ended on "${type}"; a fulfilled action here hands the reducer an undefined payload`, + ); + }); +} + +// The negative half: a healthy response must still reach the reducer, or "it rejects" would pass +// on a thunk that had been broken into rejecting always. +test('a healthy 200 still fulfils, for every list thunk', async () => { + const okBody = { + ok: true, + status: 200, + json: async () => ({ + tools: [], skills: [], modes: [], builtin_defaults: {}, outputs: [], workflows: [], + }), + }; + for (const [name, thunk] of LIST_THUNKS) { + const type = await runWith(thunk, okBody); + assert.ok(type.endsWith('/fulfilled'), `${name} ended on "${type}" for a good response`); + } +}); + +test('a 500 rejects too, so this is about status and not about the 401 shape', async () => { + const type = await runWith(fetchTools as unknown as Thunk, { + ok: false, status: 500, json: async () => ({ detail: 'boom' }), + }); + assert.ok(type.endsWith('/rejected'), `ended on "${type}"`); +}); diff --git a/frontend/src/shared/state/modesSlice.ts b/frontend/src/shared/state/modesSlice.ts index 9a294d64..3679eb2a 100644 --- a/frontend/src/shared/state/modesSlice.ts +++ b/frontend/src/shared/state/modesSlice.ts @@ -29,6 +29,7 @@ export const fetchModes = createAsyncThunk( 'modes/fetch', async () => { const res = await fetch(`${MODES_API}/list`); + if (!res.ok) throw new Error(`Modes list failed: ${res.status}`); const data = await res.json(); return { modes: data.modes as Mode[], builtinDefaults: (data.builtin_defaults ?? {}) as Record }; }, diff --git a/frontend/src/shared/state/outputsSlice.ts b/frontend/src/shared/state/outputsSlice.ts index 7ad98e62..0706490b 100644 --- a/frontend/src/shared/state/outputsSlice.ts +++ b/frontend/src/shared/state/outputsSlice.ts @@ -96,6 +96,7 @@ export const fetchOutputs = createAsyncThunk( 'outputs/fetch', async () => { const res = await fetch(`${OUTPUTS_API}/list`); + if (!res.ok) throw new Error(`Outputs list failed: ${res.status}`); const data = await res.json(); return data.outputs as Output[]; }, diff --git a/frontend/src/shared/state/skillsSlice.ts b/frontend/src/shared/state/skillsSlice.ts index cd6bec0c..22759c76 100644 --- a/frontend/src/shared/state/skillsSlice.ts +++ b/frontend/src/shared/state/skillsSlice.ts @@ -36,6 +36,7 @@ export const fetchSkills = createAsyncThunk( 'skills/fetch', async () => { const res = await fetch(`${SKILLS_API}/list`); + if (!res.ok) throw new Error(`Skills list failed: ${res.status}`); const data = await res.json(); return data.skills as Skill[]; }, diff --git a/frontend/src/shared/state/toolsSlice.ts b/frontend/src/shared/state/toolsSlice.ts index fabea840..a61ac51e 100644 --- a/frontend/src/shared/state/toolsSlice.ts +++ b/frontend/src/shared/state/toolsSlice.ts @@ -41,6 +41,7 @@ export const fetchTools = createAsyncThunk( 'tools/fetch', async () => { const res = await fetch(`${TOOLS_API}/list`); + if (!res.ok) throw new Error(`Tools list failed: ${res.status}`); const data = await res.json(); return data.tools as ToolDefinition[]; }, diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 9dfb0d33..acba632c 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -282,6 +282,7 @@ export const fetchWorkflows = createAsyncThunk( async (dashboardId?: string) => { const url = dashboardId ? `${API}/list?dashboard_id=${encodeURIComponent(dashboardId)}` : `${API}/list`; const res = await fetch(url); + if (!res.ok) throw new Error(`Workflows list failed: ${res.status}`); const data = await res.json(); return data.workflows as Workflow[]; },