From f2e7bac4bfa481b31762564e7f57a8b58c733098 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 00:37:10 +0300 Subject: [PATCH] test: add contract tests against real grpc-js mock server 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 --- test/contract.test.ts | 348 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 test/contract.test.ts diff --git a/test/contract.test.ts b/test/contract.test.ts new file mode 100644 index 0000000..4ff0677 --- /dev/null +++ b/test/contract.test.ts @@ -0,0 +1,348 @@ +/** + * Contract tests against a real grpc-js server. + * + * These tests use the actual generated stubs and a minimal in-process grpc-js + * server so that proto serialization / deserialization is exercised end-to-end. + * Mock-based tests in client.test.ts cover error-mapping logic in isolation; + * these tests verify that the wire encoding of requests and responses is correct. + */ + +import { Metadata, Server, ServerCredentials, status } from "@grpc/grpc-js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ConfigClient } from "../src/client.js"; +import { NotFoundError } from "../src/errors.js"; +import { ConfigServiceService } from "../src/generated/centralconfig/v1/config_service.js"; +import { ServerServiceService } from "../src/generated/centralconfig/v1/server_service.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function bindServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.bindAsync("127.0.0.1:0", ServerCredentials.createInsecure(), (err, port) => { + if (err) reject(err); + else resolve(port); + }); + }); +} + +function shutdownServer(server: Server): Promise { + return new Promise((resolve) => server.tryShutdown(resolve)); +} + +const unimplErr = { + code: status.UNIMPLEMENTED, + details: "not implemented", + metadata: new Metadata(), +}; + +const unimpl = (_: unknown, cb: (err: unknown, res: null) => void) => cb(unimplErr, null); + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +describe("contract", () => { + let server: Server; + let client: ConfigClient; + + // Per-test mutable handlers — each test sets these to control server behaviour. + let handleGetConfig: (req: unknown, cb: (err: unknown, res: unknown) => void) => void; + let handleGetField: (req: unknown, cb: (err: unknown, res: unknown) => void) => void; + let handleSetField: (req: unknown, cb: (err: unknown, res: unknown) => void) => void; + let handleSetFields: (req: unknown, cb: (err: unknown, res: unknown) => void) => void; + let handleSubscribe: (call: { write: (r: unknown) => void; end: () => void }) => void; + + beforeEach(async () => { + handleGetConfig = unimpl; + handleGetField = unimpl; + handleSetField = unimpl; + handleSetFields = unimpl; + handleSubscribe = (call) => call.end(); + + server = new Server(); + + server.addService(ConfigServiceService, { + getConfig: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) => + handleGetConfig(call.request, cb), + getField: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) => + handleGetField(call.request, cb), + getFields: unimpl, + setField: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) => + handleSetField(call.request, cb), + setFields: (call: { request: unknown }, cb: (e: unknown, r: unknown) => void) => + handleSetFields(call.request, cb), + listVersions: unimpl, + getVersion: unimpl, + rollbackToVersion: unimpl, + subscribe: (call: { write: (r: unknown) => void; end: () => void }) => handleSubscribe(call), + exportConfig: unimpl, + importConfig: unimpl, + }); + + server.addService(ServerServiceService, { + getServerInfo: (_call: unknown, cb: (e: unknown, r: unknown) => void) => + cb(null, { version: "0.8.0", commit: "test", features: {} }), + }); + + const port = await bindServer(server); + client = new ConfigClient(`127.0.0.1:${port}`, { + insecure: true, + subject: "testuser", + retry: false, + }); + }); + + afterEach(async () => { + client.close(); + await shutdownServer(server); + }); + + // ------------------------------------------------------------------------- + // get / getField + // ------------------------------------------------------------------------- + + describe("get() — round-trip through proto serialization", () => { + it("decodes a string value", async () => { + let received: Record | undefined; + handleGetField = (req, cb) => { + received = req as Record; + cb(null, { + value: { + fieldPath: (req as { fieldPath: string }).fieldPath, + value: { stringValue: "hello-world" }, + checksum: "abc", + }, + }); + }; + + const result = await client.get("tenant-1", "payments.fee"); + + expect(result).toBe("hello-world"); + expect(received?.tenantId).toBe("tenant-1"); + expect(received?.fieldPath).toBe("payments.fee"); + }); + + it("decodes an integer value when Number is requested", async () => { + handleGetField = (req, cb) => + cb(null, { + value: { + fieldPath: (req as { fieldPath: string }).fieldPath, + value: { integerValue: 42 }, + checksum: "c1", + }, + }); + + const result = await client.get("tenant-1", "count", Number); + expect(result).toBe(42); + }); + + it("decodes a boolean value when Boolean is requested", async () => { + handleGetField = (req, cb) => + cb(null, { + value: { + fieldPath: (req as { fieldPath: string }).fieldPath, + value: { boolValue: true }, + checksum: "c2", + }, + }); + + const result = await client.get("tenant-1", "feature.on", Boolean); + expect(result).toBe(true); + }); + + it("raises NotFoundError on gRPC NOT_FOUND from real server", async () => { + handleGetField = (_, cb) => + cb({ code: status.NOT_FOUND, details: "no such field", metadata: new Metadata() }, null); + + await expect(client.get("tenant-1", "missing")).rejects.toThrow(NotFoundError); + }); + }); + + // ------------------------------------------------------------------------- + // set / setField + // ------------------------------------------------------------------------- + + describe("set() — request proto reaches server correctly", () => { + const okVersion = { + configVersion: { + id: "v1", + tenantId: "tenant-1", + version: 1, + description: "", + createdBy: "testuser", + createdAt: new Date(), + }, + }; + + it("sends stringValue for a string", async () => { + let received: Record | undefined; + handleSetField = (req, cb) => { + received = req as Record; + cb(null, okVersion); + }; + + await client.set("tenant-1", "payments.fee", "0.5%"); + + expect(received?.tenantId).toBe("tenant-1"); + expect(received?.fieldPath).toBe("payments.fee"); + expect(received?.value).toMatchObject({ stringValue: "0.5%" }); + }); + + it("sends numberValue for a number (via setNumber)", async () => { + let received: Record | undefined; + handleSetField = (req, cb) => { + received = req as Record; + cb(null, okVersion); + }; + + await client.setNumber("tenant-1", "payments.rate", 0.05); + + expect(received?.value).toMatchObject({ numberValue: 0.05 }); + }); + + it("sends boolValue for a boolean (via setBool)", async () => { + let received: Record | undefined; + handleSetField = (req, cb) => { + received = req as Record; + cb(null, okVersion); + }; + + await client.setBool("tenant-1", "feature.enabled", false); + + expect(received?.value).toMatchObject({ boolValue: false }); + }); + + it("sends undefined value for setNull", async () => { + let received: Record | undefined; + handleSetField = (req, cb) => { + received = req as Record; + cb(null, okVersion); + }; + + await client.setNull("tenant-1", "payments.fee"); + + expect(received?.value).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // setMany / setFields + // ------------------------------------------------------------------------- + + describe("setMany() — batch request reaches server correctly", () => { + it("sends multiple typed updates in one RPC", async () => { + let received: Record | undefined; + handleSetFields = (req, cb) => { + received = req as Record; + cb(null, { + configVersion: { + id: "v2", + tenantId: "tenant-1", + version: 2, + description: "", + createdBy: "testuser", + createdAt: new Date(), + }, + }); + }; + + await client.setMany("tenant-1", { a: "hello", b: 42, c: true }); + + const updates = received?.updates as Array<{ fieldPath: string; value: unknown }>; + expect(updates).toHaveLength(3); + expect(updates.find((u) => u.fieldPath === "a")?.value).toMatchObject({ + stringValue: "hello", + }); + expect(updates.find((u) => u.fieldPath === "b")?.value).toMatchObject({ numberValue: 42 }); + expect(updates.find((u) => u.fieldPath === "c")?.value).toMatchObject({ boolValue: true }); + }); + }); + + // ------------------------------------------------------------------------- + // watch — ConfigWatcher + Subscribe stream + // ------------------------------------------------------------------------- + + describe("watch() — ConfigWatcher against real server", () => { + it("loads initial snapshot from GetConfig and receives Subscribe change", async () => { + handleGetConfig = (_req, cb) => + cb(null, { + config: { + tenantId: "tenant-1", + version: 1, + values: [ + { + fieldPath: "payments.fee", + value: { stringValue: "0.05" }, + checksum: "c1", + }, + ], + }, + }); + + // Capture the subscribe call so we can push changes manually. + // We need a promise to wait for the server-side handler to be invoked + // because subscribe() is fire-and-forget from start() and the server + // processes the incoming stream asynchronously. + let subscribeCall: { write: (r: unknown) => void; end: () => void } | undefined; + let notifySubscribeReady: () => void; + const subscribeReady = new Promise((resolve) => { + notifySubscribeReady = resolve; + }); + handleSubscribe = (call) => { + subscribeCall = call; + notifySubscribeReady(); + // Keep stream open — the test controls when to send. + }; + + const watcher = client.watch("tenant-1"); + const fee = watcher.field("payments.fee", Number, { default: 0 }); + await watcher.start(); + + // Initial value loaded from snapshot. + expect(fee.value).toBe(0.05); + + // Wait for the server-side subscribe handler to be called. + await subscribeReady; + + // Push a change through the real Subscribe stream. + const changeArrived = new Promise((resolve) => { + fee.on("change", () => resolve()); + }); + + if (!subscribeCall) throw new Error("subscribe handler not called"); + subscribeCall.write({ + change: { + tenantId: "tenant-1", + version: 2, + fieldPath: "payments.fee", + oldValue: { stringValue: "0.05" }, + newValue: { stringValue: "0.1" }, + changedBy: "test", + changedAt: new Date(), + }, + }); + + await changeArrived; + expect(fee.value).toBe(0.1); + + await watcher.stop(); + }); + + it("field uses default when field is absent from initial snapshot", async () => { + handleGetConfig = (_req, cb) => + cb(null, { + config: { tenantId: "tenant-1", version: 1, values: [] }, + }); + + const watcher = client.watch("tenant-1"); + const flag = watcher.field("feature.enabled", Boolean, { default: false }); + await watcher.start(); + + expect(flag.value).toBe(false); + + await watcher.stop(); + }); + }); +});