@biffo/cli 0.274.0 → 0.275.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { ApiError, createRequest } from './api-core'
3
+
4
+ /**
5
+ * Guards for the distributed admin-API request core (biffo-template#1492),
6
+ * and in particular for the `onError` mapper added so the marketing plugin
7
+ * could adopt it without losing its endpoint-specific messages.
8
+ */
9
+
10
+ function jsonResponse(status: number, body: unknown): Response {
11
+ return new Response(JSON.stringify(body), {
12
+ status,
13
+ headers: { 'Content-Type': 'application/json' },
14
+ })
15
+ }
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals()
19
+ })
20
+
21
+ function stubFetch(res: Response) {
22
+ const fetchMock = vi.fn().mockResolvedValue(res)
23
+ vi.stubGlobal('fetch', fetchMock)
24
+ return fetchMock
25
+ }
26
+
27
+ describe('createRequest without a mapper (the default path)', () => {
28
+ it('throws ApiError carrying the status and the response body', async () => {
29
+ stubFetch(new Response('you may not do that', { status: 403 }))
30
+ const request = createRequest(() => 'tok', '/base')
31
+
32
+ await expect(request('GET', '/thing')).rejects.toBeInstanceOf(ApiError)
33
+ })
34
+
35
+ it('falls back to statusText when the body is empty', async () => {
36
+ stubFetch(new Response('', { status: 500, statusText: 'Internal Server Error' }))
37
+ const request = createRequest(() => 'tok', '/base')
38
+
39
+ await expect(request('GET', '/thing')).rejects.toThrow('Internal Server Error')
40
+ })
41
+ })
42
+
43
+ describe('createRequest with an onError mapper', () => {
44
+ /**
45
+ * THE POINT OF THE HOOK. The mapper must be able to read the JSON body,
46
+ * which means `request()` must not have consumed the stream before calling
47
+ * it. A mapper handed an already-read Response sees an empty body and falls
48
+ * back to a generic message — the silent degradation this exists to stop.
49
+ */
50
+ it('hands the mapper a response whose body is still readable', async () => {
51
+ stubFetch(jsonResponse(403, { detail: 'you are not an admin of this brand' }))
52
+ const request = createRequest(
53
+ () => 'tok',
54
+ '/base',
55
+ async (res, context) => {
56
+ const body = (await res.json()) as { detail?: string }
57
+ throw new Error(`${body.detail} (${res.status}) while trying to ${context}`)
58
+ },
59
+ )
60
+
61
+ await expect(request('POST', '/links', {}, '/base', 'mint links')).rejects.toThrow(
62
+ 'you are not an admin of this brand (403) while trying to mint links',
63
+ )
64
+ })
65
+
66
+ it('passes the per-call context through, and undefined when omitted', async () => {
67
+ const seen: (string | undefined)[] = []
68
+ stubFetch(jsonResponse(500, {}))
69
+ const request = createRequest(
70
+ () => 'tok',
71
+ '/base',
72
+ (_res, context) => {
73
+ seen.push(context)
74
+ throw new Error('mapped')
75
+ },
76
+ )
77
+
78
+ await expect(request('GET', '/a', undefined, '/base', 'load campaigns')).rejects.toThrow(
79
+ 'mapped',
80
+ )
81
+ stubFetch(jsonResponse(500, {}))
82
+ await expect(request('GET', '/b')).rejects.toThrow('mapped')
83
+
84
+ expect(seen).toEqual(['load campaigns', undefined])
85
+ })
86
+
87
+ it('never reaches the default ApiError once the mapper has thrown', async () => {
88
+ stubFetch(jsonResponse(403, { detail: 'nope' }))
89
+ const request = createRequest(
90
+ () => 'tok',
91
+ '/base',
92
+ () => {
93
+ throw new Error('mapped')
94
+ },
95
+ )
96
+
97
+ // If the default path still ran, this would be an ApiError instead.
98
+ await expect(request('GET', '/thing')).rejects.not.toBeInstanceOf(ApiError)
99
+ })
100
+
101
+ /**
102
+ * A mapper is typed `=> never`, but types are not enforcement at runtime and
103
+ * this is the dangerous failure: a mapper that returns normally would let
104
+ * `request()` fall through to the default throw. That is recoverable. What
105
+ * must never happen is a failed request RESOLVING — so this pins that even a
106
+ * misbehaving mapper cannot turn a 403 into a successful call.
107
+ */
108
+ it('still throws if a mapper wrongly returns instead of throwing', async () => {
109
+ stubFetch(jsonResponse(403, { detail: 'nope' }))
110
+ const request = createRequest(() => 'tok', '/base', (() => undefined) as never)
111
+
112
+ await expect(request('GET', '/thing')).rejects.toBeInstanceOf(ApiError)
113
+ })
114
+
115
+ it('does not run the mapper on a successful response', async () => {
116
+ stubFetch(jsonResponse(200, { ok: true }))
117
+ const onError = vi.fn(() => {
118
+ throw new Error('should not run')
119
+ })
120
+ const request = createRequest(() => 'tok', '/base', onError as never)
121
+
122
+ await expect(request('GET', '/thing')).resolves.toEqual({ ok: true })
123
+ expect(onError).not.toHaveBeenCalled()
124
+ })
125
+ })
@@ -26,18 +26,48 @@ export class ApiError extends Error {
26
26
 
27
27
  export type GetIdToken = () => string | null | Promise<string | null>
28
28
 
29
+ /**
30
+ * Maps a failed response into the error a plugin wants to surface, INSTEAD of
31
+ * the default `ApiError(status, body)`.
32
+ *
33
+ * It receives the `Response` with its body **unread**, so it can `.json()` the
34
+ * payload — Core returns `{"detail": "..."}` for a permission failure, and a
35
+ * plugin usually wants that sentence rather than a status code. It also
36
+ * receives the per-call `context` string, because the useful wording is
37
+ * endpoint-specific: "you need the admin role to **mint links**" is actionable
38
+ * where "403" is not.
39
+ *
40
+ * Added for biffo-template#1492. The marketing plugin could not adopt this core
41
+ * without it: `createRequest` read the body into a string and threw immediately,
42
+ * leaving no seam to inspect it first, so migrating would have collapsed a dozen
43
+ * hand-written, endpoint-specific messages into one generic status string. That
44
+ * is a behaviour regression, and the plugin correctly refused rather than
45
+ * dropping them — the gap was in this module, not in the plugin.
46
+ *
47
+ * MUST NOT RETURN NORMALLY: it either throws, or returns a rejected promise.
48
+ * The `never` return type says so; a mapper that falls through would make
49
+ * `request()` resolve `undefined` for a failed call, which is the silent-success
50
+ * shape this estate spends most of its time eliminating. `assertThrew` in
51
+ * `api-core.test.ts` holds that line.
52
+ */
53
+ export type ErrorMapper = (
54
+ response: Response,
55
+ context: string | undefined,
56
+ ) => Promise<never> | never
57
+
29
58
  /**
30
59
  * Build a `request<T>(method, path, body?, base?)` function bound to a token
31
60
  * source and a default base. A plugin's own api.ts calls this once per
32
61
  * `createApi()` and defines its endpoints on top of the result — see the
33
62
  * starter `api.ts` in this same directory for the worked shape.
34
63
  */
35
- export function createRequest(getIdToken: GetIdToken, defaultBase: string) {
64
+ export function createRequest(getIdToken: GetIdToken, defaultBase: string, onError?: ErrorMapper) {
36
65
  return async function request<T>(
37
66
  method: string,
38
67
  path: string,
39
68
  body?: unknown,
40
69
  base: string = defaultBase,
70
+ context?: string,
41
71
  ): Promise<T> {
42
72
  const token = await getIdToken()
43
73
  const res = await fetch(`${base}${path}`, {
@@ -49,6 +79,12 @@ export function createRequest(getIdToken: GetIdToken, defaultBase: string) {
49
79
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
50
80
  })
51
81
  if (!res.ok) {
82
+ // The mapper runs FIRST, and gets the response with its body still
83
+ // unread. Order is load-bearing: `res.text()` below consumes the stream,
84
+ // and a mapper handed an already-consumed Response cannot call `.json()`
85
+ // — it would silently see an empty body and fall back to a generic
86
+ // message, which is the exact failure this hook exists to prevent.
87
+ if (onError) await onError(res, context)
52
88
  // Read the body for the reason: Core returns a JSON detail for a
53
89
  // permission failure, and "403" alone tells an admin nothing about
54
90
  // which rule bit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.274.0",
3
+ "version": "0.275.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",