Skip to content

Commit 79e8f4b

Browse files
justin-layervclaude
andcommitted
fix: address review — empty-string timing fields, IPv6 comment, suspicious-batch logs
5 items + 2 coverage gaps from the latest cr round. ### Concerns 1. **Empty-string handling on timing fields.** `update({ extend_by: "" })` and `mintLink({ expires_in: "" })` previously round-tripped to the API as `{"extend_by": ""}`, hitting either the mutual-exclusion check (because `"" !== undefined`) or the server's duration parser. Both write surfaces now reject empty-string timing fields with a structured `ValidationError` — `description: ""` keeps its documented clear-the-field semantic. Three new tests: rejection on each surface plus a positive test locking in `description: ""` round-trips. 2. **Conflicting IPv6 hostname comment.** Tightened the outer comment that said `URL.hostname` strips IPv6 brackets — the inner comment correctly notes Node returns hosts WITH brackets, and the code handles both forms. Outer comment now points at the inner block instead of contradicting it. 4. **`request_id` shadowing debug log.** Added a both-fields-present debug log in `batchCreate` mirroring `mapQurlsField`'s pattern. If the server ever puts `request_id` on both `data` and `meta`, operators see the divergence rather than learning about it via support-ticket archaeology. 5. **Empty BatchCreateOutput debug log.** Added a debug log in `validateBatchCreateResponse` for the `succeeded: 0, failed: 0, results: []` case — shape-valid but suspicious (the SDK definitely sent items). Keeps the response valid; surfaces the anomaly. ### Coverage - **list-side `mapQurlsField` mapping.** Test asserts list items containing wire-format `qurls` map to `access_tokens` consistently. Locks in the defensive `data.map(mapQurlsField)` in `list()`. ### No action - **#3 RFC 3339 client-side parse for `expires_at`.** Reviewer marked optional ("if you ever want a tiny sanity check"). Keeping the duration-parser stance: server is authoritative. - **#6 `requireValidTags` empty-array semantics.** Reviewer noted as intentional and well-supported by existing test. - **#7 `Number.isFinite` for `maxRetries` null-coverage.** Reviewer confirmed the `?? DEFAULT_MAX_RETRIES` chain already handles null. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 3cb5a30 commit 79e8f4b

2 files changed

Lines changed: 155 additions & 3 deletions

File tree

src/client.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,49 @@ describe("QURLClient", () => {
439439
expect(result.has_more).toBe(false);
440440
});
441441

442+
it("list items with qurls field map to access_tokens (defensive)", async () => {
443+
// The list response is documented as a flat list of QURL resources
444+
// with no nested tokens, but client.list() defensively threads each
445+
// item through mapQurlsField in case the API ever surfaces nested
446+
// tokens on list items (e.g. embedded summaries). Locks in that
447+
// the rename happens consistently on the list path too.
448+
const fetch = mockFetch({
449+
status: 200,
450+
body: {
451+
data: [
452+
{
453+
resource_id: "r_with_tokens",
454+
target_url: "https://example.com",
455+
status: "active",
456+
created_at: "2026-03-10T10:00:00Z",
457+
qurls: [
458+
{
459+
qurl_id: "at_xyz",
460+
status: "active",
461+
one_time_use: false,
462+
max_sessions: 5,
463+
session_duration: 3600,
464+
use_count: 0,
465+
created_at: "2026-03-10T10:00:00Z",
466+
expires_at: "2026-04-10T10:00:00Z",
467+
},
468+
],
469+
},
470+
],
471+
meta: { has_more: false },
472+
},
473+
});
474+
475+
const client = createClient(fetch);
476+
const result = await client.list();
477+
478+
expect(result.qurls).toHaveLength(1);
479+
expect(result.qurls[0].access_tokens).toHaveLength(1);
480+
expect(result.qurls[0].access_tokens![0].qurl_id).toBe("at_xyz");
481+
// Wire-format key stripped from each item.
482+
expect((result.qurls[0] as unknown as { qurls?: unknown }).qurls).toBeUndefined();
483+
});
484+
442485
it("list rejects limit: 0 below the spec's minimum", async () => {
443486
// Per OpenAPI (`GET /v1/qurls` → `limit: minimum: 1, maximum: 100`),
444487
// `limit` must be in [1, 100]. Client-side validation catches this
@@ -1168,6 +1211,69 @@ describe("QURLClient", () => {
11681211
expect(fetch).not.toHaveBeenCalled();
11691212
});
11701213

1214+
it("update rejects empty-string timing fields (extend_by / expires_at)", async () => {
1215+
// Empty string is always a caller mistake on timing fields —
1216+
// typically uninitialized form state. `description: ""` has
1217+
// documented clear-the-field semantics (see UpdateInput JSDoc)
1218+
// and is preserved; the timing fields don't, so they get a
1219+
// structured ValidationError instead of round-tripping garbage.
1220+
const fetch = mockFetch({ status: 200, body: { data: {} } });
1221+
const client = createClient(fetch);
1222+
1223+
for (const key of ["extend_by", "expires_at"] as const) {
1224+
const error = await client
1225+
.update("r_abc", { [key]: "" } as unknown as Parameters<typeof client.update>[1])
1226+
.catch((e: unknown) => e as ValidationError);
1227+
expect(error).toBeInstanceOf(ValidationError);
1228+
const detail = (error as ValidationError).detail;
1229+
expect(detail).toContain(key);
1230+
expect(detail).toContain("must not be an empty string");
1231+
}
1232+
expect(fetch).not.toHaveBeenCalled();
1233+
});
1234+
1235+
it("update preserves description: '' (documented clear-the-field semantic)", async () => {
1236+
// Mirror image of the timing-field rejection above: `description: ""`
1237+
// is a documented way to clear the resource's description and must
1238+
// round-trip to the API as-is. Locks in that the empty-string
1239+
// rejection is timing-field-only.
1240+
const fetch = mockFetch({
1241+
status: 200,
1242+
body: {
1243+
data: {
1244+
resource_id: "r_abc",
1245+
target_url: "https://example.com",
1246+
status: "active",
1247+
created_at: "2026-03-10T10:00:00Z",
1248+
},
1249+
},
1250+
});
1251+
const client = createClient(fetch);
1252+
await client.update("r_abc", { description: "" });
1253+
1254+
expect(fetch).toHaveBeenCalledTimes(1);
1255+
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string);
1256+
expect(body.description).toBe("");
1257+
});
1258+
1259+
it("mintLink rejects empty-string timing fields (expires_in / expires_at)", async () => {
1260+
// Symmetric with update() — empty string on timing fields is
1261+
// always a caller mistake, never a meaningful value.
1262+
const fetch = mockFetch({ status: 200, body: { data: {} } });
1263+
const client = createClient(fetch);
1264+
1265+
for (const key of ["expires_in", "expires_at"] as const) {
1266+
const error = await client
1267+
.mintLink("r_abc", { [key]: "" } as unknown as Parameters<typeof client.mintLink>[1])
1268+
.catch((e: unknown) => e as ValidationError);
1269+
expect(error).toBeInstanceOf(ValidationError);
1270+
const detail = (error as ValidationError).detail;
1271+
expect(detail).toContain(key);
1272+
expect(detail).toContain("must not be an empty string");
1273+
}
1274+
expect(fetch).not.toHaveBeenCalled();
1275+
});
1276+
11711277
it("update treats `{ extend_by: undefined }` as empty (no stealth empty-input)", async () => {
11721278
// A caller who threads an `undefined` through their own types
11731279
// should still hit the empty-input guard — the some()-based check

src/client.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,9 +448,9 @@ export class QURLClient {
448448
`baseUrl: must be a valid URL (got ${JSON.stringify(rawBaseUrl).slice(0, 60)})`,
449449
);
450450
}
451-
// `URL.hostname` strips IPv6 brackets, so the canonical loopback
452-
// forms compared here are bracket-free. Reject any non-http/https
453-
// scheme outright (ftp://, file://, javascript:, ...).
451+
// Reject any non-http/https scheme outright (ftp://, file://,
452+
// javascript:, ...). The loopback exemption logic below handles
453+
// IPv6 bracket form on `URL.hostname` — see the inner comment.
454454
if (parsedBaseUrl.protocol !== "http:" && parsedBaseUrl.protocol !== "https:") {
455455
throw clientValidationError(
456456
`baseUrl: must use http:// or https:// scheme (got ${JSON.stringify(rawBaseUrl).slice(0, 60)})`,
@@ -635,6 +635,18 @@ export class QURLClient {
635635
}
636636
}
637637

638+
// Suspicious-but-shape-valid: empty results AND zero counts on a
639+
// batch the SDK definitely sent items for. Doesn't fail the guard
640+
// (the contract permits it) — surface via debug so operators see
641+
// it if it ever shows up in the wild.
642+
if (result.results.length === 0) {
643+
this.log("batchCreate: response has empty results and zero counts (suspicious)", {
644+
succeeded: result.succeeded,
645+
failed: result.failed,
646+
request_id: requestId,
647+
});
648+
}
649+
638650
return result;
639651
}
640652

@@ -769,6 +781,18 @@ export class QURLClient {
769781
// field. The data-side variant would be a wire-format duplication
770782
// we don't want to silently honor.
771783
const requestId = envelope.meta?.request_id;
784+
// Both-fields-present log: mirrors mapQurlsField's pattern. If
785+
// the server ever puts request_id on both `data` and `meta`, the
786+
// spread silently lets `meta` win — fine, but operators should
787+
// see the divergence rather than discover it via support-ticket
788+
// archaeology.
789+
const dataRequestId = (result as { request_id?: unknown }).request_id;
790+
if (dataRequestId !== undefined && requestId !== undefined && dataRequestId !== requestId) {
791+
this.log("batchCreate: response carries request_id on BOTH data and meta; keeping meta", {
792+
meta_request_id: requestId,
793+
data_request_id: String(dataRequestId).slice(0, 80),
794+
});
795+
}
772796
return requestId !== undefined ? { ...result, request_id: requestId } : result;
773797
}
774798

@@ -969,6 +993,18 @@ export class QURLClient {
969993
}
970994
}
971995

996+
// Empty-string check on timing fields. `description: ""` has
997+
// documented clear semantics (see UpdateInput JSDoc) and stays
998+
// as-is; the timing fields don't — `extend_by: ""` would either
999+
// trip the mutual-exclusion check below or hit the server as a
1000+
// malformed duration. Reject up front with a structured error
1001+
// matching the rest of the validators.
1002+
for (const key of ["extend_by", "expires_at"] as const) {
1003+
if (normalized[key] === "") {
1004+
throw clientValidationError(`${key}: must not be an empty string`);
1005+
}
1006+
}
1007+
9721008
if (normalized.extend_by !== undefined && normalized.expires_at !== undefined) {
9731009
throw clientValidationError(
9741010
"update: `extend_by` and `expires_at` are mutually exclusive — provide at most one",
@@ -1017,6 +1053,16 @@ export class QURLClient {
10171053
}
10181054
normalized = stripped as MintInput;
10191055
}
1056+
// Empty-string check on timing fields. Symmetric with update();
1057+
// the timing fields have no "clear" semantic, so an empty string
1058+
// is always a caller mistake (e.g. uninitialized form state).
1059+
if (normalized !== undefined) {
1060+
for (const key of ["expires_in", "expires_at"] as const) {
1061+
if (normalized[key] === "") {
1062+
throw clientValidationError(`${key}: must not be an empty string`);
1063+
}
1064+
}
1065+
}
10201066
if (normalized?.expires_in !== undefined && normalized.expires_at !== undefined) {
10211067
throw clientValidationError(
10221068
"mintLink: `expires_in` and `expires_at` are mutually exclusive — provide at most one",

0 commit comments

Comments
 (0)