Skip to content

Commit 4ad953f

Browse files
zeevdrclaude
andauthored
test: add contract tests against real grpc-js mock server (#90)
Adds test/contract.test.ts with an in-process grpc-js server fixture that exercises get, set, setMany, and watch RPCs through the actual generated proto stubs. This catches signature drift that mock-based tests cannot detect, closing the gap described in issue #58. The fixture binds on port 0, registers per-test mutable handlers for each RPC, and tears down cleanly after every test. The subscribe test waits on an explicit Promise to avoid the race between watcher.start() returning and the server-side streaming handler being invoked. Closes #58 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c798e8 commit 4ad953f

1 file changed

Lines changed: 348 additions & 0 deletions

File tree

test/contract.test.ts

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/**
2+
* Contract tests against a real grpc-js server.
3+
*
4+
* These tests use the actual generated stubs and a minimal in-process grpc-js
5+
* server so that proto serialization / deserialization is exercised end-to-end.
6+
* Mock-based tests in client.test.ts cover error-mapping logic in isolation;
7+
* these tests verify that the wire encoding of requests and responses is correct.
8+
*/
9+
10+
import { Metadata, Server, ServerCredentials, status } from "@grpc/grpc-js";
11+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
12+
import { ConfigClient } from "../src/client.js";
13+
import { NotFoundError } from "../src/errors.js";
14+
import { ConfigServiceService } from "../src/generated/centralconfig/v1/config_service.js";
15+
import { ServerServiceService } from "../src/generated/centralconfig/v1/server_service.js";
16+
17+
// ---------------------------------------------------------------------------
18+
// Helpers
19+
// ---------------------------------------------------------------------------
20+
21+
function bindServer(server: Server): Promise<number> {
22+
return new Promise((resolve, reject) => {
23+
server.bindAsync("127.0.0.1:0", ServerCredentials.createInsecure(), (err, port) => {
24+
if (err) reject(err);
25+
else resolve(port);
26+
});
27+
});
28+
}
29+
30+
function shutdownServer(server: Server): Promise<void> {
31+
return new Promise((resolve) => server.tryShutdown(resolve));
32+
}
33+
34+
const unimplErr = {
35+
code: status.UNIMPLEMENTED,
36+
details: "not implemented",
37+
metadata: new Metadata(),
38+
};
39+
40+
const unimpl = (_: unknown, cb: (err: unknown, res: null) => void) => cb(unimplErr, null);
41+
42+
// ---------------------------------------------------------------------------
43+
// Fixture
44+
// ---------------------------------------------------------------------------
45+
46+
describe("contract", () => {
47+
let server: Server;
48+
let client: ConfigClient;
49+
50+
// Per-test mutable handlers — each test sets these to control server behaviour.
51+
let handleGetConfig: (req: unknown, cb: (err: unknown, res: unknown) => void) => void;
52+
let handleGetField: (req: unknown, cb: (err: unknown, res: unknown) => void) => void;
53+
let handleSetField: (req: unknown, cb: (err: unknown, res: unknown) => void) => void;
54+
let handleSetFields: (req: unknown, cb: (err: unknown, res: unknown) => void) => void;
55+
let handleSubscribe: (call: { write: (r: unknown) => void; end: () => void }) => void;
56+
57+
beforeEach(async () => {
58+
handleGetConfig = unimpl;
59+
handleGetField = unimpl;
60+
handleSetField = unimpl;
61+
handleSetFields = unimpl;
62+
handleSubscribe = (call) => call.end();
63+
64+
server = new Server();
65+
66+
server.addService(ConfigServiceService, {
67+
getConfig: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) =>
68+
handleGetConfig(call.request, cb),
69+
getField: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) =>
70+
handleGetField(call.request, cb),
71+
getFields: unimpl,
72+
setField: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) =>
73+
handleSetField(call.request, cb),
74+
setFields: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) =>
75+
handleSetFields(call.request, cb),
76+
listVersions: unimpl,
77+
getVersion: unimpl,
78+
rollbackToVersion: unimpl,
79+
subscribe: (call: { write: (r: unknown) => void; end: () => void }) => handleSubscribe(call),
80+
exportConfig: unimpl,
81+
importConfig: unimpl,
82+
});
83+
84+
server.addService(ServerServiceService, {
85+
getServerInfo: (_call: unknown, cb: (e: unknown, r: unknown) => void) =>
86+
cb(null, { version: "0.8.0", commit: "test", features: {} }),
87+
});
88+
89+
const port = await bindServer(server);
90+
client = new ConfigClient(`127.0.0.1:${port}`, {
91+
insecure: true,
92+
subject: "testuser",
93+
retry: false,
94+
});
95+
});
96+
97+
afterEach(async () => {
98+
client.close();
99+
await shutdownServer(server);
100+
});
101+
102+
// -------------------------------------------------------------------------
103+
// get / getField
104+
// -------------------------------------------------------------------------
105+
106+
describe("get() — round-trip through proto serialization", () => {
107+
it("decodes a string value", async () => {
108+
let received: Record<string, unknown> | undefined;
109+
handleGetField = (req, cb) => {
110+
received = req as Record<string, unknown>;
111+
cb(null, {
112+
value: {
113+
fieldPath: (req as { fieldPath: string }).fieldPath,
114+
value: { stringValue: "hello-world" },
115+
checksum: "abc",
116+
},
117+
});
118+
};
119+
120+
const result = await client.get("tenant-1", "payments.fee");
121+
122+
expect(result).toBe("hello-world");
123+
expect(received?.tenantId).toBe("tenant-1");
124+
expect(received?.fieldPath).toBe("payments.fee");
125+
});
126+
127+
it("decodes an integer value when Number is requested", async () => {
128+
handleGetField = (req, cb) =>
129+
cb(null, {
130+
value: {
131+
fieldPath: (req as { fieldPath: string }).fieldPath,
132+
value: { integerValue: 42 },
133+
checksum: "c1",
134+
},
135+
});
136+
137+
const result = await client.get("tenant-1", "count", Number);
138+
expect(result).toBe(42);
139+
});
140+
141+
it("decodes a boolean value when Boolean is requested", async () => {
142+
handleGetField = (req, cb) =>
143+
cb(null, {
144+
value: {
145+
fieldPath: (req as { fieldPath: string }).fieldPath,
146+
value: { boolValue: true },
147+
checksum: "c2",
148+
},
149+
});
150+
151+
const result = await client.get("tenant-1", "feature.on", Boolean);
152+
expect(result).toBe(true);
153+
});
154+
155+
it("raises NotFoundError on gRPC NOT_FOUND from real server", async () => {
156+
handleGetField = (_, cb) =>
157+
cb({ code: status.NOT_FOUND, details: "no such field", metadata: new Metadata() }, null);
158+
159+
await expect(client.get("tenant-1", "missing")).rejects.toThrow(NotFoundError);
160+
});
161+
});
162+
163+
// -------------------------------------------------------------------------
164+
// set / setField
165+
// -------------------------------------------------------------------------
166+
167+
describe("set() — request proto reaches server correctly", () => {
168+
const okVersion = {
169+
configVersion: {
170+
id: "v1",
171+
tenantId: "tenant-1",
172+
version: 1,
173+
description: "",
174+
createdBy: "testuser",
175+
createdAt: new Date(),
176+
},
177+
};
178+
179+
it("sends stringValue for a string", async () => {
180+
let received: Record<string, unknown> | undefined;
181+
handleSetField = (req, cb) => {
182+
received = req as Record<string, unknown>;
183+
cb(null, okVersion);
184+
};
185+
186+
await client.set("tenant-1", "payments.fee", "0.5%");
187+
188+
expect(received?.tenantId).toBe("tenant-1");
189+
expect(received?.fieldPath).toBe("payments.fee");
190+
expect(received?.value).toMatchObject({ stringValue: "0.5%" });
191+
});
192+
193+
it("sends numberValue for a number (via setNumber)", async () => {
194+
let received: Record<string, unknown> | undefined;
195+
handleSetField = (req, cb) => {
196+
received = req as Record<string, unknown>;
197+
cb(null, okVersion);
198+
};
199+
200+
await client.setNumber("tenant-1", "payments.rate", 0.05);
201+
202+
expect(received?.value).toMatchObject({ numberValue: 0.05 });
203+
});
204+
205+
it("sends boolValue for a boolean (via setBool)", async () => {
206+
let received: Record<string, unknown> | undefined;
207+
handleSetField = (req, cb) => {
208+
received = req as Record<string, unknown>;
209+
cb(null, okVersion);
210+
};
211+
212+
await client.setBool("tenant-1", "feature.enabled", false);
213+
214+
expect(received?.value).toMatchObject({ boolValue: false });
215+
});
216+
217+
it("sends undefined value for setNull", async () => {
218+
let received: Record<string, unknown> | undefined;
219+
handleSetField = (req, cb) => {
220+
received = req as Record<string, unknown>;
221+
cb(null, okVersion);
222+
};
223+
224+
await client.setNull("tenant-1", "payments.fee");
225+
226+
expect(received?.value).toBeUndefined();
227+
});
228+
});
229+
230+
// -------------------------------------------------------------------------
231+
// setMany / setFields
232+
// -------------------------------------------------------------------------
233+
234+
describe("setMany() — batch request reaches server correctly", () => {
235+
it("sends multiple typed updates in one RPC", async () => {
236+
let received: Record<string, unknown> | undefined;
237+
handleSetFields = (req, cb) => {
238+
received = req as Record<string, unknown>;
239+
cb(null, {
240+
configVersion: {
241+
id: "v2",
242+
tenantId: "tenant-1",
243+
version: 2,
244+
description: "",
245+
createdBy: "testuser",
246+
createdAt: new Date(),
247+
},
248+
});
249+
};
250+
251+
await client.setMany("tenant-1", { a: "hello", b: 42, c: true });
252+
253+
const updates = received?.updates as Array<{ fieldPath: string; value: unknown }>;
254+
expect(updates).toHaveLength(3);
255+
expect(updates.find((u) => u.fieldPath === "a")?.value).toMatchObject({
256+
stringValue: "hello",
257+
});
258+
expect(updates.find((u) => u.fieldPath === "b")?.value).toMatchObject({ numberValue: 42 });
259+
expect(updates.find((u) => u.fieldPath === "c")?.value).toMatchObject({ boolValue: true });
260+
});
261+
});
262+
263+
// -------------------------------------------------------------------------
264+
// watch — ConfigWatcher + Subscribe stream
265+
// -------------------------------------------------------------------------
266+
267+
describe("watch() — ConfigWatcher against real server", () => {
268+
it("loads initial snapshot from GetConfig and receives Subscribe change", async () => {
269+
handleGetConfig = (_req, cb) =>
270+
cb(null, {
271+
config: {
272+
tenantId: "tenant-1",
273+
version: 1,
274+
values: [
275+
{
276+
fieldPath: "payments.fee",
277+
value: { stringValue: "0.05" },
278+
checksum: "c1",
279+
},
280+
],
281+
},
282+
});
283+
284+
// Capture the subscribe call so we can push changes manually.
285+
// We need a promise to wait for the server-side handler to be invoked
286+
// because subscribe() is fire-and-forget from start() and the server
287+
// processes the incoming stream asynchronously.
288+
let subscribeCall: { write: (r: unknown) => void; end: () => void } | undefined;
289+
let notifySubscribeReady: () => void;
290+
const subscribeReady = new Promise<void>((resolve) => {
291+
notifySubscribeReady = resolve;
292+
});
293+
handleSubscribe = (call) => {
294+
subscribeCall = call;
295+
notifySubscribeReady();
296+
// Keep stream open — the test controls when to send.
297+
};
298+
299+
const watcher = client.watch("tenant-1");
300+
const fee = watcher.field("payments.fee", Number, { default: 0 });
301+
await watcher.start();
302+
303+
// Initial value loaded from snapshot.
304+
expect(fee.value).toBe(0.05);
305+
306+
// Wait for the server-side subscribe handler to be called.
307+
await subscribeReady;
308+
309+
// Push a change through the real Subscribe stream.
310+
const changeArrived = new Promise<void>((resolve) => {
311+
fee.on("change", () => resolve());
312+
});
313+
314+
if (!subscribeCall) throw new Error("subscribe handler not called");
315+
subscribeCall.write({
316+
change: {
317+
tenantId: "tenant-1",
318+
version: 2,
319+
fieldPath: "payments.fee",
320+
oldValue: { stringValue: "0.05" },
321+
newValue: { stringValue: "0.1" },
322+
changedBy: "test",
323+
changedAt: new Date(),
324+
},
325+
});
326+
327+
await changeArrived;
328+
expect(fee.value).toBe(0.1);
329+
330+
await watcher.stop();
331+
});
332+
333+
it("field uses default when field is absent from initial snapshot", async () => {
334+
handleGetConfig = (_req, cb) =>
335+
cb(null, {
336+
config: { tenantId: "tenant-1", version: 1, values: [] },
337+
});
338+
339+
const watcher = client.watch("tenant-1");
340+
const flag = watcher.field("feature.enabled", Boolean, { default: false });
341+
await watcher.start();
342+
343+
expect(flag.value).toBe(false);
344+
345+
await watcher.stop();
346+
});
347+
});
348+
});

0 commit comments

Comments
 (0)