Skip to content

Commit 2b7e2b4

Browse files
justin-layervclaude
andcommitted
test: address cr round 1 + simplify findings on contract test
cr round 1 (non-blocking) + /simplify findings: - Extract mockFetch + createClient to src/test-helpers.ts so both client.test.ts and contract.test.ts import a single fixture. tsconfig.json excludes the new file from the build output so it doesn't ship in dist/. - Regen script: replace the `CONTENT=$(git show …)` capture with a direct `git show` stream inside the heredoc. Avoids bash command- substitution's trailing-newline strip and ARG_MAX edges on large specs. - Regen script: run `git fetch --quiet origin` before `rev-parse` so `origin/main` resolves to the live upstream tip rather than a stale local ref. - Multi-page listAll contract case: original test asserted only the first page; the new case mocks two pages and asserts both calls hit GET /v1/qurls (covers the pagination path properly). - `assertSdkCallMatches` takes a `callIndex` param for the multi- page case and guards against a future SDK method calling fetch without an `init` object (clearer error than a bare access). - Trim narrative-only comment explaining a lint workaround that would rot if the rule changed. - Add CONTRIBUTING note: new public SDK methods require a matching contract case (alias methods included, so alias rewires can't silently slip past). Snapshot-staleness detector (cr round 1 suggestion #3) filed as a follow-up issue — scope-appropriate since it's a new CI workflow rather than a change to this test. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent fbf36b3 commit 2b7e2b4

6 files changed

Lines changed: 115 additions & 57 deletions

File tree

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ was last validated against. If the regenerated snapshot breaks the
4444
contract test, that's the signal: qurl-service changed an endpoint the
4545
SDK depends on, and the SDK needs to be updated in lockstep.
4646

47+
**Adding a new public SDK method?** Add a matching case to
48+
`src/contract.test.ts` asserting the exact `(verb, path-template)` the
49+
method calls. Alias methods (e.g. `extend``update`) get their own
50+
case so an alias rewire can't silently slip past.
51+
4752
## Pull Requests
4853

4954
1. Fork the repo and create a branch from `main`

scripts/update-openapi-snapshot.sh

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,25 @@ if [ ! -d "$QURL_SERVICE_DIR/.git" ]; then
2222
exit 2
2323
fi
2424

25+
# Refresh remote refs so `origin/main` (the default) points at the current
26+
# upstream tip, not whatever was last fetched locally. Without this, a
27+
# stale checkout would silently pin the snapshot to an old SHA. Harmless
28+
# when the caller passes an explicit SHA or tag.
29+
git -C "$QURL_SERVICE_DIR" fetch --quiet origin
30+
2531
# Resolve ref → commit SHA so the snapshot header is deterministic even if
2632
# the caller passed a branch name that moves later.
2733
SHA=$(git -C "$QURL_SERVICE_DIR" rev-parse "$REF^{commit}")
2834
SRC_FILE="api/openapi.yaml"
2935

30-
# Fetch the file at the resolved SHA (doesn't require checkout). Piping
31-
# through cat keeps the script composable if someone redirects output.
32-
CONTENT=$(git -C "$QURL_SERVICE_DIR" show "${SHA}:${SRC_FILE}")
33-
36+
# Write to a sibling tempfile and `mv` into place so a Ctrl-C mid-write
37+
# can't leave the committed snapshot truncated. `mv` on the same
38+
# filesystem is atomic; the trap cleans up the tempfile if the script
39+
# aborts before the rename. Streaming `git show` directly into the
40+
# heredoc (rather than capturing into a shell variable) avoids trailing-
41+
# newline truncation and ARG_MAX/memory edges on very large specs.
42+
TMP="${OUT}.tmp.$$"
43+
trap 'rm -f "$TMP"' EXIT
3444
{
3545
echo "# Snapshot of qurl-service/api/openapi.yaml — canonical API contract."
3646
echo "# This file is vendored (not auto-generated) so the contract test runs"
@@ -39,7 +49,8 @@ CONTENT=$(git -C "$QURL_SERVICE_DIR" show "${SHA}:${SRC_FILE}")
3949
echo "# Source: layervai/qurl-service@${SHA}"
4050
echo "# Regenerate with: scripts/update-openapi-snapshot.sh"
4151
echo "#"
42-
printf '%s\n' "$CONTENT"
43-
} > "$OUT"
52+
git -C "$QURL_SERVICE_DIR" show "${SHA}:${SRC_FILE}"
53+
} > "$TMP"
54+
mv "$TMP" "$OUT"
4455

4556
echo "wrote $OUT (source: qurl-service@${SHA:0:12})"

src/client.test.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,7 @@ import {
1111
TimeoutError,
1212
ValidationError,
1313
} from "./errors.js";
14-
15-
function mockFetch(response: {
16-
status: number;
17-
body?: unknown;
18-
headers?: Record<string, string>;
19-
}): typeof globalThis.fetch {
20-
return vi.fn().mockResolvedValue({
21-
ok: response.status >= 200 && response.status < 300,
22-
status: response.status,
23-
statusText: response.status === 200 ? "OK" : "Error",
24-
headers: new Headers(response.headers ?? {}),
25-
json: () => Promise.resolve(response.body),
26-
text: () => Promise.resolve(JSON.stringify(response.body)),
27-
} satisfies Partial<Response> as Response);
28-
}
29-
30-
function createClient(fetchFn: typeof globalThis.fetch): QURLClient {
31-
return new QURLClient({
32-
apiKey: "lv_live_test",
33-
baseUrl: "https://api.test.layerv.ai",
34-
fetch: fetchFn,
35-
maxRetries: 0,
36-
});
37-
}
14+
import { mockFetch, createClient } from "./test-helpers.js";
3815

3916
describe("QURLClient", () => {
4017
it("creates a QURL", async () => {

src/contract.test.ts

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
33
import { fileURLToPath } from "node:url";
44
import { dirname, join } from "node:path";
55
import { parse as parseYaml } from "yaml";
6-
import { QURLClient } from "./client.js";
6+
import { mockFetch, createClient } from "./test-helpers.js";
77

88
// Contract test: every SDK method must call the exact (verb, path) pair
99
// that qurl-service's OpenAPI spec declares. Catches two failure modes:
@@ -56,12 +56,26 @@ function assertSdkCallMatches(
5656
fetchFn: typeof globalThis.fetch,
5757
expectedVerb: Verb,
5858
expectedTemplate: string,
59+
callIndex: number = 0,
5960
): void {
6061
const calls = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls;
61-
if (calls.length === 0) {
62-
throw new Error(`SDK made no fetch call; expected ${expectedVerb} ${expectedTemplate}`);
62+
if (calls.length <= callIndex) {
63+
throw new Error(
64+
`SDK made ${calls.length} fetch call(s); expected at least ${callIndex + 1} ` +
65+
`(looking for ${expectedVerb} ${expectedTemplate})`,
66+
);
67+
}
68+
const [rawUrl, init] = calls[callIndex];
69+
// Guard against a future SDK method calling fetch(url) with no options —
70+
// today every path in client.ts passes an `init` object, but the check is
71+
// cheap insurance against a regression with a clearer error than the
72+
// undefined-access TypeError a bare access would throw.
73+
if (!init) {
74+
throw new Error(
75+
`SDK called fetch without an init object; cannot verify method. ` +
76+
`Expected ${expectedVerb} ${expectedTemplate}.`,
77+
);
6378
}
64-
const [rawUrl, init] = calls[0];
6579
const actualVerb = (init as RequestInit).method as Verb;
6680
const actualPath = new URL(rawUrl as string).pathname;
6781

@@ -84,25 +98,15 @@ function assertSdkCallMatches(
8498
}
8599
}
86100

87-
function mockOk(body: unknown = { data: {}, meta: {} }): typeof globalThis.fetch {
88-
return vi.fn().mockResolvedValue({
89-
ok: true,
90-
status: 200,
91-
statusText: "OK",
92-
headers: new Headers(),
93-
json: () => Promise.resolve(body),
94-
text: () => Promise.resolve(JSON.stringify(body)),
95-
} satisfies Partial<Response> as Response);
96-
}
101+
// `mockOk` + `client` wrap the shared helpers with contract-test defaults:
102+
// `status: 200` for `mockOk`, and the identifier `client` kept short since
103+
// every test in this file uses it once. Keeps the test bodies readable
104+
// without forking the underlying fixture — if a fixture ever changes
105+
// (e.g., the default baseUrl or apiKey), both test files move together.
106+
const mockOk = (body: unknown = { data: {}, meta: {} }): typeof globalThis.fetch =>
107+
mockFetch({ status: 200, body });
97108

98-
function client(fetchFn: typeof globalThis.fetch): QURLClient {
99-
return new QURLClient({
100-
apiKey: "lv_live_test",
101-
baseUrl: "https://api.test.layerv.ai",
102-
fetch: fetchFn,
103-
maxRetries: 0,
104-
});
105-
}
109+
const client = createClient;
106110

107111
describe("OpenAPI contract", () => {
108112
it("snapshot parses and has paths", () => {
@@ -144,16 +148,45 @@ describe("OpenAPI contract", () => {
144148

145149
it("listAll → GET /v1/qurls (via wrapped list)", async () => {
146150
const fetch = mockOk({ data: [], meta: { has_more: false } });
147-
// Drive the generator to completion so `list` actually gets called.
148-
// Using iterator-protocol `.next()` instead of `for…of` avoids
149-
// declaring an unused loop binding that trips eslint.
151+
// Drive the generator to completion so the underlying list() fires.
150152
const iter = client(fetch).listAll();
151153
while (!(await iter.next()).done) {
152-
/* no-op — iteration itself is the contract being exercised. */
154+
/* no-op */
153155
}
154156
assertSdkCallMatches(fetch, "GET", "/v1/qurls");
155157
});
156158

159+
it("listAll multi-page → every page hits GET /v1/qurls", async () => {
160+
// Two-page mock so the test covers the pagination path — a single-page
161+
// exit would not exercise that every subsequent fetch also targets the
162+
// same endpoint. Custom fetch instead of mockOk so call N can return
163+
// a different body than call 0.
164+
const fetch = vi
165+
.fn()
166+
.mockImplementationOnce(async () => ({
167+
ok: true,
168+
status: 200,
169+
statusText: "OK",
170+
headers: new Headers(),
171+
json: async () => ({ data: [], meta: { has_more: true, next_cursor: "c2" } }),
172+
text: async () => "",
173+
}))
174+
.mockImplementationOnce(async () => ({
175+
ok: true,
176+
status: 200,
177+
statusText: "OK",
178+
headers: new Headers(),
179+
json: async () => ({ data: [], meta: { has_more: false } }),
180+
text: async () => "",
181+
})) as unknown as typeof globalThis.fetch;
182+
const iter = client(fetch).listAll();
183+
while (!(await iter.next()).done) {
184+
/* no-op */
185+
}
186+
assertSdkCallMatches(fetch, "GET", "/v1/qurls", 0);
187+
assertSdkCallMatches(fetch, "GET", "/v1/qurls", 1);
188+
});
189+
157190
it("update → PATCH /v1/qurls/{id}", async () => {
158191
const fetch = mockOk({ data: { resource_id: "r_x" } });
159192
await client(fetch).update("r_x", { expires_in: "24h" });

src/test-helpers.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { vi } from "vitest";
2+
import { QURLClient } from "./client.js";
3+
4+
// Shared test fixtures used by client.test.ts and contract.test.ts.
5+
// Keep this file test-only — tsconfig excludes *.test.ts but NOT
6+
// test-helpers.ts, so it would otherwise land in dist/. Filename +
7+
// tsconfig exclude in lockstep: if you rename this, update the
8+
// exclude pattern.
9+
10+
export function mockFetch(response: {
11+
status: number;
12+
body?: unknown;
13+
headers?: Record<string, string>;
14+
}): typeof globalThis.fetch {
15+
return vi.fn().mockResolvedValue({
16+
ok: response.status >= 200 && response.status < 300,
17+
status: response.status,
18+
statusText: response.status === 200 ? "OK" : "Error",
19+
headers: new Headers(response.headers ?? {}),
20+
json: () => Promise.resolve(response.body),
21+
text: () => Promise.resolve(JSON.stringify(response.body)),
22+
} satisfies Partial<Response> as Response);
23+
}
24+
25+
export function createClient(fetchFn: typeof globalThis.fetch): QURLClient {
26+
return new QURLClient({
27+
apiKey: "lv_live_test",
28+
baseUrl: "https://api.test.layerv.ai",
29+
fetch: fetchFn,
30+
maxRetries: 0,
31+
});
32+
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@
1616
"forceConsistentCasingInFileNames": true
1717
},
1818
"include": ["src/**/*.ts"],
19-
"exclude": ["node_modules", "dist", "**/*.test.ts"]
19+
"exclude": ["node_modules", "dist", "**/*.test.ts", "src/test-helpers.ts"]
2020
}

0 commit comments

Comments
 (0)