|
| 1 | +import { describe, it, expect, vi } from 'vitest'; |
| 2 | +import { readFileSync } from 'node:fs'; |
| 3 | +import { ObjectStackClient } from './index'; |
| 4 | + |
| 5 | +/** |
| 6 | + * The `limit` query-parameter emitters of this SDK, pinned as ONE family (#19567). |
| 7 | + * |
| 8 | + * They used to guard three different ways — a truthy test, `!= null`, and |
| 9 | + * `!== undefined` — so one input got a different answer by method: |
| 10 | + * `{ limit: 0 }` was dropped by some (the server then answered `200` with its |
| 11 | + * DEFAULT window, a page the caller did not ask for) and sent by the others |
| 12 | + * (`400` from a door that declares `min(1)`); `null` was dropped by some and |
| 13 | + * sent by the rest. |
| 14 | + * |
| 15 | + * The rule now is the one the declared doors need: the SDK does not judge |
| 16 | + * `limit`. Only an ABSENT value (`undefined`) stays off the wire; `0`, `NaN` |
| 17 | + * and an untyped `null` all leave the client exactly as written, and the door |
| 18 | + * that declares the bound is the one that refuses. Every emitter is driven |
| 19 | + * through the real method against a stubbed `fetch`, and the assertion reads |
| 20 | + * the URL it actually requested. |
| 21 | + * |
| 22 | + * `null` is outside every emitter's declared type (`limit?: number`); it is |
| 23 | + * driven through a cast because an untyped caller can still pass it. Pinning |
| 24 | + * it as SENT is deliberate: dropping it again would turn the `400` those |
| 25 | + * doors answer today into a silent `200` with a default page. |
| 26 | + */ |
| 27 | + |
| 28 | +type Emitter = { |
| 29 | + /** The public path a caller writes. */ |
| 30 | + label: string; |
| 31 | + /** Drives the method with `{ limit }` (or its positional equivalent). */ |
| 32 | + call: (client: ObjectStackClient, limit: number | undefined) => Promise<unknown>; |
| 33 | +}; |
| 34 | + |
| 35 | +const ENV = 'env_limit'; |
| 36 | + |
| 37 | +const EMITTERS: readonly Emitter[] = [ |
| 38 | + { label: 'meta.getHistory', call: (c, limit) => c.meta.getHistory('object', 'task', { limit }) }, |
| 39 | + { label: 'meta.getAudit', call: (c, limit) => c.meta.getAudit('object', 'task', { limit }) }, |
| 40 | + { label: 'environments.listRevisions', call: (c, limit) => c.environments.listRevisions(ENV, { limit }) }, |
| 41 | + { label: 'automation.runs.list', call: (c, limit) => c.automation.runs.list('my_flow', { limit }) }, |
| 42 | + { label: 'automation.listRuns', call: (c, limit) => c.automation.listRuns('my_flow', { limit }) }, |
| 43 | + { label: 'search', call: (c, limit) => c.search('acme', { limit }) }, |
| 44 | + { label: 'notifications.list', call: (c, limit) => c.notifications.list({ limit }) }, |
| 45 | + { label: 'ai.conversations.list', call: (c, limit) => c.ai.conversations.list({ limit }) }, |
| 46 | + { label: 'ai.pendingActions.list', call: (c, limit) => c.ai.pendingActions.list({ limit }) }, |
| 47 | + { label: 'data.listImportJobs', call: (c, limit) => c.data.listImportJobs({ limit }) }, |
| 48 | + { label: 'data.export', call: (c, limit) => c.data.export('task', { limit }) }, |
| 49 | + { label: 'environment().meta.getHistory', call: (c, limit) => c.environment(ENV).meta.getHistory('object', 'task', { limit }) }, |
| 50 | + { label: 'environment().data.listImportJobs', call: (c, limit) => c.environment(ENV).data.listImportJobs({ limit }) }, |
| 51 | + { label: 'environment().automation.listRuns', call: (c, limit) => c.environment(ENV).automation.listRuns('my_flow', { limit }) }, |
| 52 | +]; |
| 53 | + |
| 54 | +/** The URL the method requested, after it has run to completion or failed on the stub body. */ |
| 55 | +async function requestedUrl(emitter: Emitter, limit: number | undefined): Promise<URL> { |
| 56 | + const fetchMock = vi.fn().mockResolvedValue({ |
| 57 | + ok: true, |
| 58 | + status: 200, |
| 59 | + statusText: 'OK', |
| 60 | + json: async () => ({ success: true, data: {} }), |
| 61 | + headers: new Headers(), |
| 62 | + }); |
| 63 | + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }); |
| 64 | + // Only the request matters here; a method that trips over the stub body |
| 65 | + // AFTER fetching has still told us what it sent. |
| 66 | + await emitter.call(client, limit).then(() => undefined, () => undefined); |
| 67 | + expect(fetchMock, `${emitter.label} never reached fetch`).toHaveBeenCalledTimes(1); |
| 68 | + return new URL(String(fetchMock.mock.calls[0][0])); |
| 69 | +} |
| 70 | + |
| 71 | +describe('the limit emitter family sends what the caller wrote', () => { |
| 72 | + it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: an absent limit stays off the wire', async (_label, emitter) => { |
| 73 | + const url = await requestedUrl(emitter, undefined); |
| 74 | + expect(url.searchParams.has('limit')).toBe(false); |
| 75 | + }); |
| 76 | + |
| 77 | + it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: an ordinary limit is sent', async (_label, emitter) => { |
| 78 | + const url = await requestedUrl(emitter, 20); |
| 79 | + expect(url.searchParams.getAll('limit')).toEqual(['20']); |
| 80 | + }); |
| 81 | + |
| 82 | + it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: limit 0 is sent, not swapped for the default window', async (_label, emitter) => { |
| 83 | + const url = await requestedUrl(emitter, 0); |
| 84 | + expect(url.searchParams.getAll('limit')).toEqual(['0']); |
| 85 | + }); |
| 86 | + |
| 87 | + it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: limit NaN is sent, not swapped for the default window', async (_label, emitter) => { |
| 88 | + const url = await requestedUrl(emitter, Number.NaN); |
| 89 | + expect(url.searchParams.getAll('limit')).toEqual(['NaN']); |
| 90 | + }); |
| 91 | + |
| 92 | + it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: an untyped null is sent as written, for the door to refuse', async (_label, emitter) => { |
| 93 | + const url = await requestedUrl(emitter, null as unknown as number); |
| 94 | + expect(url.searchParams.getAll('limit')).toEqual(['null']); |
| 95 | + }); |
| 96 | + |
| 97 | + it('drives every limit emitter in index.ts — a new one has to join this table', () => { |
| 98 | + // The census the table above must cover: every `set('limit', …)` call plus |
| 99 | + // the one emitter that spells the key inside a template literal. A new |
| 100 | + // emitter that is not added to EMITTERS changes this count, so it cannot |
| 101 | + // arrive with a fourth guard spelling unpinned. |
| 102 | + const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8'); |
| 103 | + const setCalls = source.match(/\.set\('limit',/g) ?? []; |
| 104 | + const templateEmitters = source.match(/\?limit=\$\{/g) ?? []; |
| 105 | + expect(setCalls.length + templateEmitters.length).toBe(EMITTERS.length); |
| 106 | + }); |
| 107 | +}); |
0 commit comments