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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -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
|
let fullUrl = url
|
||||||
if (params) {
|
if (params) {
|
||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const useMembershipReconcileStore = defineStore('membershipReconcile', ()
|
|||||||
async function adopt (membershipId) {
|
async function adopt (membershipId) {
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/sources/reconciliation/adopt', {
|
const res = await api.post('/api/sources/reconciliation/adopt', {
|
||||||
membership_id: membershipId
|
body: { membership_id: membershipId }
|
||||||
})
|
})
|
||||||
toast({
|
toast({
|
||||||
text: res.already_tracked
|
text: res.already_tracked
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user