From 49d18c1757027620685f64403be398f4402d9583 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 24 Sep 2026 14:13:12 -0400 Subject: [PATCH] fix: adding a found subscription sends its membership id (adopt 400 invalid_body) membershipReconcile.adopt passed { membership_id } as request options, so no body was sent. useApi now refuses any option other than body, params or signal, so a payload in the wrong place fails loudly in the browser instead of as a 400 from the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR --- frontend/src/composables/useApi.js | 17 +++++++++- frontend/src/stores/membershipReconcile.js | 2 +- frontend/test/composables/useApi.spec.js | 37 ++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 frontend/test/composables/useApi.spec.js diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js index 76cb4f7..ffeba82 100644 --- a/frontend/src/composables/useApi.js +++ b/frontend/src/composables/useApi.js @@ -10,7 +10,22 @@ export class ApiError extends Error { } } -async function request(method, url, { body, params, signal } = {}) { +const OPTIONS = new Set(['body', 'params', 'signal']) + +async function request(method, url, opts = {}) { + // Refuse an option this wrapper does not know. `api.post(url, { id: 1 })` + // reads naturally and sends NO body — the payload lands in the options bag + // and is dropped — so the server answers `invalid_body` and the caller + // looks broken server-side. It shipped exactly that way in the Subscriptions + // "Add subscription" button (2026-09-24). Failing here names the mistake. + const unknown = Object.keys(opts).filter((k) => !OPTIONS.has(k)) + if (unknown.length) { + throw new TypeError( + `useApi ${method} ${url}: unknown option(s) ${unknown.join(', ')} — ` + + 'a request payload goes under `body`, a query under `params`' + ) + } + const { body, params, signal } = opts let fullUrl = url if (params) { const search = new URLSearchParams() diff --git a/frontend/src/stores/membershipReconcile.js b/frontend/src/stores/membershipReconcile.js index 66d6a90..8298224 100644 --- a/frontend/src/stores/membershipReconcile.js +++ b/frontend/src/stores/membershipReconcile.js @@ -28,7 +28,7 @@ export const useMembershipReconcileStore = defineStore('membershipReconcile', () async function adopt (membershipId) { try { const res = await api.post('/api/sources/reconciliation/adopt', { - membership_id: membershipId + body: { membership_id: membershipId } }) toast({ text: res.already_tracked diff --git a/frontend/test/composables/useApi.spec.js b/frontend/test/composables/useApi.spec.js new file mode 100644 index 0000000..010c39c --- /dev/null +++ b/frontend/test/composables/useApi.spec.js @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useApi } from '../../src/composables/useApi.js' + +// 2026-09-24: `api.post(url, { membership_id })` shipped in the Subscriptions +// "Add subscription" button. It reads naturally, sends NO body, and the +// server's `invalid_body` made the bug look like the backend's. The wrapper now +// refuses an option it does not know, so the mistake names itself. +describe('useApi', () => { + afterEach(() => { vi.unstubAllGlobals() }) + + function stubFetch () { + const fetch = vi.fn().mockResolvedValue({ + ok: true, status: 200, statusText: 'OK', text: () => Promise.resolve('{}'), + }) + vi.stubGlobal('fetch', fetch) + return fetch + } + + it('refuses a payload passed as options instead of under body', async () => { + const fetch = stubFetch() + await expect(useApi().post('/api/x', { membership_id: 1 })) + .rejects.toThrow(/membership_id.*body/) + expect(fetch).not.toHaveBeenCalled() + }) + + it('sends a body passed under body', async () => { + const fetch = stubFetch() + await useApi().post('/api/x', { body: { membership_id: 1 } }) + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ membership_id: 1 }) + }) + + it('still accepts no options at all', async () => { + stubFetch() + await expect(useApi().get('/api/x')).resolves.toEqual({}) + }) +})