Skip to content

Commit 7beaaa3

Browse files
os-litantclaude
andauthored
fix(client)!: oauth.applications.delete resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15675)
* fix(client)!: bind `oauth.applications.delete` to the zero-byte 200 its route answers `ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with `SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after the row had already been committed away server-side. The method had no success path a caller could observe, and the obvious recovery (retry) failed DIFFERENTLY, with the route's 404 `not_found`. Measured end to end, not inherited: real betterAuth + real oauthProvider over the real ObjectQL adapter, a real signed-up user and session, driven through the real client with only the socket stood in for. POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json · NO content-length header through the client, before -> REJECTED: SyntaxError the row, server-side -> ALREADY GONE (get-client answers 404) through the client, after -> RESOLVED | undefined Emptiness is detected by READING the body. Both shortcuts were measured and both are unusable here: the status is 200, not the 204 five other delete surfaces in this file key off, and the response carries no `content-length` header at all. A non-empty body is still parsed and its failure still thrown, so the ONLY behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted" and "was already gone" are distinguished on the ERROR channel (404 `not_found`, raised by `this.fetch` before any success value exists), so a synthesised `{ deleted: true }` would be a shape the wire never sends. `exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*` entry is now gone; 35 sites remain open. The `toEqualTypeOf<any>()` pin PR #15445 left behind for exactly this moment is replaced by `returnTypePrecisionPins15451`, and the reject/resolve flip — which no compile-time assertion can observe — is pinned in a new runtime suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(changeset): declare the BREAKING binding of `oauth.applications.delete` `**BREAKING**` on two independent axes: the declared return moves off an erased `any` onto `void` (a compile break, though only for reads of a value the promise never produced), and the runtime flips from always-rejecting to resolving, so a caller's `catch` stops firing on success. ⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on this changeset until a maintainer settles it. Both legs were measured rather than guessed: type-surface-only REFUSED at predicate 4. A reference is a bare identifier resolved to the FIRST same-named definition, and index.ts declares TEN members named `delete`; the first (line 2397) is unannotated at both revs, so the gate reports "still UNANNOTATED" about a member this diff never touched. Issue #15627, filed off PR #15445 where the same ambiguity cost the `get` member its place in the marker — here it blocks the only member there is. no-migration-prescription mechanically ACCEPTED, and deliberately not taken. ADR-0087's D7 records that #8277 held this exemption on a detector MISS rather than a positive finding, and names that as the pattern the sixth category exists to stop. Taking it here, with the measurement in hand, would repeat it knowingly. Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum closes it for this class in as many words. So the honest state is a loud red on one gate with the reasoning written down, rather than a green held by a category that does not describe this diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * feat(spec): register the oauth.applications.delete binding in the ADR-0087 ledger The changeset for this branch declared a breaking change and carried no `adr-0087:` disposition marker, so `check-adr-0087-registration` refused it. The maintainer ruling on #15674 (comment 5549251533, 2026-09-05) settled the disposition: this class routes through `registered`, not through any `not-required` category, and the sixth category's text stays as written. - `entries/semantic/18.client-oauth-applications-delete-void.ts` — one `SemanticMigration` for major 18, the open window (`PROTOCOL_VERSION` is 17.0.0). surface: both halves of what a caller of `client.oauth.applications.delete` observes — the declared return, `any` to `void`, and the settle behaviour, reject-on-every-successful-delete to resolve. replacement: no value; the migration is on the settle path, the `catch` that fired on every successful delete now fires only on a real failure. reason: the wire is byte-identical and no `packages/spec` declaration moves, so the ledger is the only channel — and the half that actually ran has NO diagnostic, since a `try`/`catch` around the call compiles identically before and after while its `catch` stops executing. The TS2339 the type move produces names only code that was unreachable. acceptanceCriteria: every `catch` around the call re-read by hand, and the measured populations (this repo: zero production call sites; objectui at the pinned `.objectui-sha`: zero; cloud: NOT MEASURED). - `migrations/registry.ts` — regenerated by `gen:migration-registry` (187 semantic, 161 retired-key, 116 retired-def); never hand-edited. - The changeset's non-claim note becomes `adr-0087: registered client-oauth-applications-delete-void`; the rest of the file is byte-identical and keeps its `**BREAKING**` declaration. Anchors live in a source comment above the type import rather than in the entry's strings: the strings are projected into `spec-changes.json` and `docs/protocol-upgrade-guide.md` when 18 becomes current, and this repo's issue numbers do not resolve for the consumers who read those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(spec): the ledger entry names the flat better-auth error body, not an ADR-0112 envelope `entries/semantic/18.client-oauth-applications-delete-void.ts` listed "same ADR-0112 error envelope" among the things this change leaves untouched on the wire. The operative claim is true — the error bodies do not move — but the label is wrong for this route. `POST {auth}/oauth2/delete-client` is better-auth's, and its 404 answers the vendor's FLAT shape, `{"error_description":"client not found","error":"not_found"}`, not ObjectStack's nested ADR-0112 envelope. The repo states the distinction verbatim at `packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:86`: "ObjectStack's ADR-0112 envelope nests it; better-auth's flat shape does not". Worth a correction rather than a follow-up because the string is consumer-facing: entries prefixed `18.` project into `spec-changes.json` and `docs/protocol-upgrade-guide.md` when major 18 becomes current, and a reader told "same ADR-0112 error envelope" would write a nested read against a flat body. What the entry asserts is unchanged; only the mislabel moves. `migrations/registry.ts` is regenerated by `gen:migration-registry`, never hand-edited, and a re-run at this head is a byte no-op — the entry is indexed, not merely present. This branch squashes, so its commit messages enter `main` verbatim and this one carries the current state of the disposition question. The ADR-0087 disposition IS claimed: the changeset carries `adr-0087: registered client-oauth-applications-delete-void`, and `check-adr-0087-registration` exits 0 at this head, printing "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" against that marker. An earlier message on this branch states the disposition is not claimed and that the gate is expected to red until a maintainer settles it; that expectation is superseded — the maintainer settled it as ruling D on #15674 (2026-09-05), and this class routes through ADR-0087 `registered`. Measured at this head, exit codes captured by redirect-then-capture: `gen:migration-registry` re-run byte no-op (`git hash-object` unchanged), `check:spec-changes` "spec-changes.json is up to date.", `check:upgrade-guide` "protocol-upgrade-guide.md is up to date.", `check:adr-0087-registration` self-test (332 assertions) and main both exit 0, the spec migrations suite 117 passed, `@objectstack/spec` typecheck exit 0, and `check:nul-bytes`, `check:doc-authoring`, `check:spec-parsed-alias` exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2756e07 commit 7beaaa3

7 files changed

Lines changed: 504 additions & 31 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client)!: `oauth.applications.delete` resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15451)
6+
7+
**BREAKING** on two independent axes, and it makes a published method usable for the first time. Before this change `client.oauth.applications.delete(id)` **rejected on every successful delete** — there was no success path a caller could observe. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`); the version number is not the migration signal here, this entry is.
8+
9+
<!-- adr-0087: registered client-oauth-applications-delete-void -->
10+
11+
The fifth and last method of the `oauth.*` family, and the one #14312 / PR #15445 deliberately could not close: its ruling fenced that card to *narrowing published return types*, and no declared return type could be true while the `res.json()` call stood.
12+
13+
## The defect, measured end to end
14+
15+
Real `betterAuth` + real `@better-auth/oauth-provider` over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the **real** `ObjectStackClient` with only the socket stood in for:
16+
17+
```
18+
POST /api/v1/auth/oauth2/delete-client -> 200 · 0 bytes
19+
content-type: application/json
20+
content-length: (absent)
21+
through the client, BEFORE -> REJECTED: SyntaxError | Unexpected end of JSON input
22+
the row, server-side -> ALREADY GONE (get-client answers 404 not_found)
23+
through the client, AFTER -> RESOLVED | undefined
24+
```
25+
26+
The handler returns nothing and the vendor declares the endpoint `void`. `res.json()` had nothing to parse, so the method rejected — *after* the delete had committed. A caller who did the obvious thing saw a failure, retried, and the retry failed **differently**, because the row no longer existed.
27+
28+
## What changes for a caller
29+
30+
| | before | now |
31+
|:--|:--|:--|
32+
| a successful delete | rejects `SyntaxError` | resolves |
33+
| the resolved value | `any` (unreachable — the promise never resolved) | `void` |
34+
| deleting a client that is not there | rejects `not_found` | rejects `not_found` — unchanged |
35+
| a malformed non-empty body | rejects `SyntaxError` | rejects `SyntaxError` — unchanged |
36+
37+
⚠️ **The `catch` you wrote around this call stops firing on success.** Code shaped like
38+
39+
```ts
40+
try { await client.oauth.applications.delete(id); }
41+
catch { /* the delete probably worked anyway */ }
42+
```
43+
44+
still compiles and still runs, but its catch block was executing on **every** successful delete and now executes only on a real failure. Any workaround that lived in there is now inert and can be deleted. And because the promise never used to resolve, a read off its resolved value — `(await …delete(id)).deleted` — was dead code that has never executed; it now stops compiling (TS2339), which is the compiler delivering the change at the call site.
45+
46+
## Why `void`, and not `{ deleted: boolean }`
47+
48+
"Deleted" and "was already gone" **are** distinguished by the route, but on the error channel: a missing client answers 404 `{ error: 'not_found' }`, which the client already raises as a throw. The 200 answer carries zero bytes and therefore zero information, so a synthesised `{ deleted: true }` would be a shape the wire never sends and strictly less informative than the 404 a caller already receives.
49+
50+
## Why the emptiness is detected by reading the body
51+
52+
Both shortcuts were measured against the real route and both are unusable: the status is **200**, not the `204` five other delete surfaces in this client key off, and the response carries **no `content-length` header at all** — so a header test would never fire and would leave the defect in place while looking like a fix. The body itself is the only thing that answers.
53+
54+
A non-empty body is still parsed and its failure still thrown, so **the only behaviour this change moves is the zero-byte case**: a malformed response stays loud, and the day this route grows a payload, surfacing it is a deliberate widening of the return type rather than a silent change of shape.
55+
56+
`packages/client/exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes **with** the binding. Its last `oauth.*` entry is now gone; 35 sites remain open.

packages/client/exported-any-returns.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"ObjectStackClient.organizations.teams.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
2323
"ObjectStackClient.organizations.teams.addMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
2424
"ObjectStackClient.organizations.teams.removeMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
25-
"ObjectStackClient.oauth.applications.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
2625
"ObjectStackClient.auth.updateUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
2726
"ObjectStackClient.auth.changePassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
2827
"ObjectStackClient.auth.setInitialPassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",

packages/client/src/index.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3224,29 +3224,60 @@ export class ObjectStackClient {
32243224
* Tokens and consents referencing the client cascade-delete via the
32253225
* better-auth schema's `onDelete: cascade` foreign keys.
32263226
*
3227-
* ⚠️ NOT YET BOUND, and deliberately so — this is the one method of the
3228-
* `oauth.*` family that #14312 left at `Promise<any>`, with its
3229-
* `exported-any-returns.json` entry still open.
3227+
* ## Why this method does not call `res.json()` (#15451)
32303228
*
3231-
* Measured against a real server: the route answers **HTTP 200 with a
3232-
* ZERO-BYTE body** (its handler returns nothing; the provider declares
3233-
* it `void`) under a `content-type: application/json` header. So the
3234-
* `res.json()` below rejects with `SyntaxError: Unexpected end of JSON
3235-
* input` on every successful delete — the delete itself has already
3236-
* committed server-side by then.
3229+
* Measured against a real server — real `betterAuth` + real
3230+
* `oauthProvider` over the real ObjectQL adapter, driven through this
3231+
* very client with only the socket stood in for:
32373232
*
3238-
* No declared return type can be honest while that call stands: any
3239-
* annotation here would promise a value this method never resolves.
3240-
* Binding it therefore needs a behaviour change, which is a decision
3241-
* beyond the type-narrowing this family was scoped to — see #14312.
3233+
* POST /oauth2/delete-client -> 200 · 0 bytes
3234+
* content-type: application/json
3235+
* content-length: (absent)
3236+
*
3237+
* The handler returns nothing and the vendor declares the endpoint
3238+
* `void` (`StrictEndpoint<'/oauth2/delete-client', …, void>`). A
3239+
* `res.json()` on that body therefore rejected with `SyntaxError:
3240+
* Unexpected end of JSON input` on EVERY successful delete, while the
3241+
* row was already gone server-side — so the method had no success path
3242+
* a caller could observe, and the obvious recovery (retry) failed
3243+
* DIFFERENTLY, with the route's 404 `not_found`.
3244+
*
3245+
* ⛔ Emptiness is detected by READING the body, not from the status and
3246+
* not from `content-length`. Both were measured and both are unusable
3247+
* here: the status is `200`, not the `204` the `{ deleted: true }`
3248+
* shortcut elsewhere in this file keys off, and the response carries NO
3249+
* `content-length` header at all. Only the body itself answers.
3250+
*
3251+
* ⛔ The parse below is NOT decoration and must not be deleted as dead
3252+
* code on the grounds that nothing reads its value. It is what keeps
3253+
* this method LOUD on a malformed non-empty body: a body that is
3254+
* present but unparseable still rejects exactly as it did before, so
3255+
* the ONLY behaviour this method changed is the zero-byte case — the
3256+
* defect itself. Pinned by `oauth-applications-delete.test.ts`.
3257+
*
3258+
* ## Why `void`, and not `{ deleted: boolean }`
3259+
*
3260+
* "Deleted" and "was already gone" are distinguished by the route, but
3261+
* on the ERROR channel, not in the success value: a client that is not
3262+
* there answers 404 `{ error: 'not_found' }`, which `this.fetch` has
3263+
* already turned into a throw before this line runs. The 200 answer
3264+
* carries zero bytes and therefore zero information, so a synthesised
3265+
* `{ deleted: true }` would be a value the wire cannot support and
3266+
* strictly less informative than the 404 the caller already gets.
32423267
*/
3243-
delete: async (clientId: string) => {
3268+
delete: async (clientId: string): Promise<void> => {
32443269
const route = this.getRoute('auth');
32453270
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/delete-client`, {
32463271
method: 'POST',
32473272
body: JSON.stringify({ client_id: clientId }),
32483273
});
3249-
return res.json();
3274+
const body = await res.text();
3275+
if (body === '') return;
3276+
// Present but unread: validated so a malformed body still speaks, and
3277+
// discarded because the declared contract is `void`. The day this
3278+
// route starts answering a payload, widening the return type is a
3279+
// deliberate, reviewable edit here — never a silent change of shape.
3280+
JSON.parse(body);
32503281
},
32513282
},
32523283

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#15451] `oauth.applications.delete` must RESOLVE on the zero-byte 200 its
5+
* route actually answers — and must still reject, loudly, on anything else.
6+
*
7+
* ## Why this file exists at all, when its sibling is type-level
8+
*
9+
* `return-type-precision.test.ts` says in its own header that a runtime test
10+
* cannot observe a return-type narrowing: the value is identical either way.
11+
* The reverse is true here and is the whole point. This card did not narrow a
12+
* declaration — it changed what the method DOES. Before it, the method called
13+
* `res.json()` on a body of zero bytes and REJECTED with `SyntaxError:
14+
* Unexpected end of JSON input` on every successful delete; after it, the
15+
* same call resolves. No compile-time assertion can see a reject/resolve
16+
* flip, so the two files pin the two halves and neither is redundant.
17+
*
18+
* ## The wire fact these fixtures encode, measured not assumed
19+
*
20+
* Real `betterAuth` + real `@better-auth/oauth-provider` over the real
21+
* ObjectQL adapter, driven through the real `ObjectStackClient` with only the
22+
* socket stood in for:
23+
*
24+
* POST /api/v1/auth/oauth2/delete-client
25+
* -> 200 · 0 bytes · content-type: application/json · NO content-length
26+
*
27+
* Both shortcuts a reader will reach for were measured and both are unusable,
28+
* which is why the fix reads the body instead:
29+
*
30+
* - `res.status === 204` — the spelling five other delete surfaces in
31+
* `index.ts` use. The status here is **200**, so it never fires.
32+
* - `content-length === '0'` — the header is **absent**, not zero, so a
33+
* header test never fires either and would leave the defect in place
34+
* while looking like a fix.
35+
*
36+
* ## ⛔ The malformed-body case is load-bearing, not leftover
37+
*
38+
* The implementation still runs `JSON.parse` on a NON-EMPTY body and throws
39+
* the result away. That reads like dead code and is not: it is what keeps a
40+
* malformed response loud, so the ONLY behaviour the card changed is the
41+
* zero-byte case — the defect itself. Delete the parse "because nothing reads
42+
* it" and `expect(...).rejects` below goes red, by design.
43+
*/
44+
45+
import { describe, it, expect, vi } from 'vitest';
46+
import { ObjectStackClient } from './index';
47+
48+
const BASE = 'http://localhost:3000';
49+
const DELETE_URL = `${BASE}/api/v1/auth/oauth2/delete-client`;
50+
51+
/**
52+
* A client whose transport answers with a REAL `Response`. Deliberately not a
53+
* hand-rolled double with a stubbed `json()`: the defect lived in how a real
54+
* `Response` behaves when its body is empty, and a double that answers
55+
* `json: async () => undefined` cannot reproduce it — it would have been
56+
* green against the broken client too.
57+
*/
58+
function clientAnswering(body: BodyInit | null, init?: ResponseInit) {
59+
const fetchMock = vi.fn(async () => new Response(body, init));
60+
const client = new ObjectStackClient({ baseUrl: BASE, fetch: fetchMock as never });
61+
return { client, fetchMock };
62+
}
63+
64+
/** The exact answer the route was measured to send on a successful delete. */
65+
const ZERO_BYTE_200: [BodyInit | null, ResponseInit] = [
66+
null,
67+
{ status: 200, headers: { 'content-type': 'application/json' } },
68+
];
69+
70+
describe('#15451 oauth.applications.delete — the zero-byte 200', () => {
71+
it('RESOLVES on the 200 / zero-byte answer the route actually sends', async () => {
72+
const { client } = clientAnswering(...ZERO_BYTE_200);
73+
// ⚠️ RED BEFORE: this rejected with `SyntaxError: Unexpected end of JSON
74+
// input`, on the successful path, every single time.
75+
await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined();
76+
});
77+
78+
it('resolves on an empty-STRING body too — the same zero bytes, spelled differently', async () => {
79+
const { client } = clientAnswering('', { status: 200 });
80+
await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined();
81+
});
82+
83+
it('sends the same request bytes as before — only the RESPONSE handling moved', async () => {
84+
const { client, fetchMock } = clientAnswering(...ZERO_BYTE_200);
85+
await client.oauth.applications.delete('c_1');
86+
expect(fetchMock).toHaveBeenCalledTimes(1);
87+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
88+
expect(url).toBe(DELETE_URL);
89+
expect(init.method).toBe('POST');
90+
expect(init.body).toBe(JSON.stringify({ client_id: 'c_1' }));
91+
});
92+
93+
it('⛔ still REJECTS on a malformed non-empty body — the parse is not decoration', async () => {
94+
const { client } = clientAnswering('{ not json', { status: 200 });
95+
// Green in BOTH states, and recorded as such: it is here to go RED if
96+
// someone removes the `JSON.parse` as unused, which would trade this
97+
// card's loud bug for a quiet one.
98+
await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError);
99+
});
100+
101+
it('rejects on a whitespace-only body — the boundary is EXACTLY zero bytes', async () => {
102+
const { client } = clientAnswering('\n', { status: 200 });
103+
// Stated rather than left to drift: the tolerated case is the empty body
104+
// the route sends, not "anything that looks blank". A body that is
105+
// present but not JSON is a malformed response and says so.
106+
await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError);
107+
});
108+
109+
it('resolves and DISCARDS a well-formed body, should the route ever grow one', async () => {
110+
const { client } = clientAnswering(JSON.stringify({ deleted: true }), {
111+
status: 200,
112+
headers: { 'content-type': 'application/json' },
113+
});
114+
// The declared contract is `void`. A payload arriving here is validated
115+
// and dropped; surfacing it is a deliberate widening of the return type,
116+
// never a silent change of shape under an unchanged declaration.
117+
await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined();
118+
});
119+
120+
it('"already gone" still arrives as a THROW, which is what makes `void` honest', async () => {
121+
// The route distinguishes deleted from already-gone on the ERROR channel:
122+
// a missing client answers 404 `{ error: 'not_found' }`. `this.fetch`
123+
// raises that before any success value exists, so the success answer has
124+
// no information left to carry and `{ deleted: true }` would be invented.
125+
const { client } = clientAnswering(
126+
JSON.stringify({ error_description: 'client not found', error: 'not_found' }),
127+
{ status: 404, headers: { 'content-type': 'application/json' } },
128+
);
129+
await expect(client.oauth.applications.delete('gone')).rejects.toThrow(/not_found/);
130+
});
131+
});

0 commit comments

Comments
 (0)