Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } fr
import { ConfigClient } from "../src/client.js";
import {
ChecksumMismatchError,
DeadlineExceededError,
DecreeError,
IncompatibleServerError,
NotFoundError,
Expand Down Expand Up @@ -1004,4 +1005,160 @@ describe("ConfigClient", () => {
c.close();
});
});

describe("retry behavior", () => {
let retryClient: ConfigClient;

beforeEach(() => {
retryClient = new ConfigClient("localhost:9090", {
subject: "testuser",
retry: {}, // enabled, no custom retryableCodes → hits line 514
});
});

afterEach(() => {
retryClient.close();
});

it("withRetryAndMap merges defaultCodes into config when retryableCodes is unset (line 514)", async () => {
configStub.getField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { value: { fieldPath: "f", value: { stringValue: "v" }, checksum: "c" } });
},
);
const result = await retryClient.get("tenant-1", "f");
expect(result).toBe("v");
});
});

describe("idempotency key retry", () => {
let retryClient: ConfigClient;

beforeEach(() => {
retryClient = new ConfigClient("localhost:9090", {
subject: "testuser",
retry: { maxAttempts: 2, initialBackoff: 1 },
});
});

afterEach(() => {
retryClient.close();
});

it("set() with idempotencyKey retries DEADLINE_EXCEEDED", async () => {
vi.useFakeTimers();

configStub.setField
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(makeServiceError(status.DEADLINE_EXCEEDED, "timed out"));
},
)
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

const promise = retryClient.set("tenant-1", "payments.fee", "0.5%", {
idempotencyKey: "idem-key-1",
});
await vi.runAllTimersAsync();
await promise;

expect(configStub.setField).toHaveBeenCalledTimes(2);

vi.useRealTimers();
});

it("set() without idempotencyKey does NOT retry DEADLINE_EXCEEDED", async () => {
configStub.setField.mockImplementation(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(makeServiceError(status.DEADLINE_EXCEEDED, "timed out"));
},
);

await expect(retryClient.set("tenant-1", "payments.fee", "0.5%")).rejects.toThrow(
DeadlineExceededError,
);

expect(configStub.setField).toHaveBeenCalledTimes(1);
});

it("setMany() with idempotencyKey retries DEADLINE_EXCEEDED", async () => {
vi.useFakeTimers();

configStub.setFields
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(makeServiceError(status.DEADLINE_EXCEEDED, "timed out"));
},
)
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 2 } });
},
);

const promise = retryClient.setMany("tenant-1", { a: "1" }, { idempotencyKey: "idem-key-2" });
await vi.runAllTimersAsync();
await promise;

expect(configStub.setFields).toHaveBeenCalledTimes(2);

vi.useRealTimers();
});

it("setNull() with idempotencyKey retries DEADLINE_EXCEEDED", async () => {
vi.useFakeTimers();

configStub.setField
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(makeServiceError(status.DEADLINE_EXCEEDED, "timed out"));
},
)
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 3 } });
},
);

const promise = retryClient.setNull("tenant-1", "payments.fee", {
idempotencyKey: "idem-key-3",
});
await vi.runAllTimersAsync();
await promise;

expect(configStub.setField).toHaveBeenCalledTimes(2);

vi.useRealTimers();
});

it("setNumber() with idempotencyKey retries DEADLINE_EXCEEDED", async () => {
vi.useFakeTimers();

configStub.setField
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(makeServiceError(status.DEADLINE_EXCEEDED, "timed out"));
},
)
.mockImplementationOnce(
(_req: unknown, _meta: unknown, _opts: unknown, cb: (...args: unknown[]) => void) => {
cb(null, { configVersion: { version: 1 } });
},
);

const promise = retryClient.setNumber("tenant-1", "payments.fee", 42, {
idempotencyKey: "idem-key-4",
});
await vi.runAllTimersAsync();
await promise;

expect(configStub.setField).toHaveBeenCalledTimes(2);

vi.useRealTimers();
});
});
});
19 changes: 19 additions & 0 deletions test/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ describe("satisfies", () => {
expect(satisfies([1], ">=1.0.0")).toBe(true);
expect(satisfies([1, 0, 0], ">=1")).toBe(true);
});

it("compare ?? 0 guard: sparse array makes a[i] undefined, triggering nullish coalescing", () => {
// compare() uses `a[i] ?? 0` and `b[i] ?? 0` as defensive guards.
// These are only reachable if the array has holes (undefined slots).
// Spreading a sparse array into [...version, ...fill] preserves undefined holes,
// so a[i] can be undefined inside compare().
const sparseVersion = [1] as number[];
sparseVersion.length = 3; // [1, <empty>, <empty>] — sparse array with holes
// satisfies pads via [...sparseVersion, ...fill]; spread of sparse keeps undefined slots.
// compare() then hits a[1] ?? 0 → 0, triggering the right-hand side of ??.
expect(satisfies(sparseVersion, ">=1.0.0")).toBe(true); // [1,0,0] >= [1,0,0]
expect(satisfies(sparseVersion, ">=1.0.1")).toBe(false); // [1,0,0] < [1,0,1]
});

// NOTE: The `default: return true` branch at line 59 of compat.ts is unreachable
// via normal string input. The regex /^(>=|<=|>|<|==|!=)(.+)$/ only matches
// those 6 operators, so `op` is always one of them when the switch is reached.
// There is no safe non-destructive way to reach it without monkeypatching
// String.prototype.match, which would be too fragile for a unit test suite.
});

describe("checkVersionCompatible", () => {
Expand Down
9 changes: 9 additions & 0 deletions test/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ describe("convertValue", () => {
expect(() => convertValue("yes", Boolean)).toThrow(TypeMismatchError);
});
});

it("throws TypeMismatchError for unsupported converter type (runtime guard)", () => {
// Line 74 is a runtime guard — TypeScript types prevent reaching it normally.
// Bypass via cast to exercise the branch.
expect(() => convertValue("value", Date as unknown as Converter)).toThrow(TypeMismatchError);
expect(() => convertValue("value", Date as unknown as Converter)).toThrow(
"unsupported converter type",
);
});
});

describe("typedValueToString", () => {
Expand Down
Loading