import { afterEach, describe, expect, test } from "bun:test"; import type { Context } from "@wrnexus/core"; import { v } from "@wrnexus/validation"; import { serviceClient } from "../src/client.ts"; import { defineService, procedure } from "../src/contract.ts"; import { RPC_ERROR_CODES, ServiceError, failure, success } from "../src/errors.ts"; import type { RpcTarget } from "../src/transport.ts"; const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; const originalAppName = process.env.WRNEXUS_APP_NAME; afterEach(() => { if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; else process.env.WRNEXUS_APP_NAME = originalAppName; }); function configure(appName = "web") { process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; process.env.WRNEXUS_APP_NAME = appName; } const billing = defineService({ name: "billing", procedures: { createInvoice: procedure .input(v.object({ amountCents: v.number() })) .output<{ invoiceId: string }>() .build(), }, }); describe("serviceClient", () => { test("returns the handler's value unwrapped", async () => { const client = serviceClient(billing, { transport: { call: async () => success({ invoiceId: "inv_1" }) }, }); expect(await client.createInvoice({ amountCents: 1 })).toEqual({ invoiceId: "inv_1" }); }); test("throws a ServiceError carrying code and retryable on failure", async () => { const client = serviceClient(billing, { transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") }, }); let caught: unknown; try { await client.createInvoice({ amountCents: 1 }); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(ServiceError); expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true }); }); test("attaches an identity token when given a context and omits it for an anonymous context", async () => { configure("web"); let seenIdentity: string | undefined = "unset"; const client = serviceClient(billing, { as: { user: { id: "u1" }, locals: {} } as unknown as Context, transport: { call: async (_target, _payload, options) => { seenIdentity = options.identity; return success({ invoiceId: "inv_1" }); }, }, }); await client.createInvoice({ amountCents: 1 }); expect(seenIdentity).toBeTypeOf("string"); let seenAnon: unknown = "unset"; const anonClient = serviceClient(billing, { transport: { call: async (_target, _payload, options) => { seenAnon = options.identity; return success({ invoiceId: "inv_1" }); }, }, }); await anonClient.createInvoice({ amountCents: 1 }); expect(seenAnon).toBeUndefined(); }); test("an undeclared procedure is undefined rather than a function that throws", async () => { let called = false; const client = serviceClient(billing, { transport: { call: async () => { called = true; return success({}); }, }, }); const proxy = client as unknown as Record; expect(proxy.deleteEverything).toBeUndefined(); expect(called).toBe(false); }); test("the proxy has no `then` escape — await/return does not trigger a call", async () => { let called = false; const client = serviceClient(billing, { transport: { call: async () => { called = true; return success({ invoiceId: "inv_1" }); }, }, }); const proxy = client as unknown as Record; expect(proxy.then).toBeUndefined(); expect(proxy.catch).toBeUndefined(); expect(proxy.finally).toBeUndefined(); // `await client` must resolve to the proxy object itself, not reject. const awaited = await client; expect(awaited).toBe(client); expect(called).toBe(false); }); test("the signal arrives at the transport", async () => { let seenSignal: AbortSignal | undefined; const client = serviceClient(billing, { transport: { call: async (_target, _payload, options) => { seenSignal = options.signal; return success({ invoiceId: "inv_1" }); }, }, }); await client.createInvoice({ amountCents: 1 }); expect(seenSignal).toBeInstanceOf(AbortSignal); }); test("the call aborts at timeoutMs when the transport ignores the signal", async () => { const client = serviceClient(billing, { timeoutMs: 20, transport: { call: () => new Promise(() => {}), // never resolves; ignores the signal }, }); let caught: unknown; const start = Date.now(); try { await client.createInvoice({ amountCents: 1 }); } catch (err) { caught = err; } const elapsed = Date.now() - start; expect(caught).toBeInstanceOf(ServiceError); expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport }); expect(elapsed).toBeLessThan(500); }); test("the timer is cleared on both success and failure", async () => { const originalClearTimeout = globalThis.clearTimeout; let clearCount = 0; globalThis.clearTimeout = ((...args: Parameters) => { clearCount++; return originalClearTimeout(...args); }) as typeof clearTimeout; try { const okClient = serviceClient(billing, { transport: { call: async () => success({ invoiceId: "inv_1" }) }, }); await okClient.createInvoice({ amountCents: 1 }); expect(clearCount).toBe(1); const failClient = serviceClient(billing, { transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") }, }); await expect(failClient.createInvoice({ amountCents: 1 })).rejects.toBeInstanceOf( ServiceError, ); expect(clearCount).toBe(2); } finally { globalThis.clearTimeout = originalClearTimeout; } }); test("the app defaults to the service name", async () => { let seenTarget: RpcTarget | undefined; const client = serviceClient(billing, { transport: { call: async (target) => { seenTarget = target; return success({ invoiceId: "inv_1" }); }, }, }); await client.createInvoice({ amountCents: 1 }); expect(seenTarget?.app).toBe("billing"); expect(seenTarget?.service).toBe("billing"); }); test("a wrn-level non-retryable failure (e.g. a 403) surfaces at the client as retryable: false", async () => { // RPC_TRANSPORT recomputes to retryable: true from the code alone — that // is correct for a 5xx, but the transport already determined a 403 is // NOT retryable via the bounded status check. The client must pass that // wrn value through rather than recompute it from the code. const client = serviceClient(billing, { transport: { call: async () => ({ ok: false, code: RPC_ERROR_CODES.transport, message: "Service returned 403", retryable: false, }), }, }); let caught: unknown; try { await client.createInvoice({ amountCents: 1 }); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(ServiceError); expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: false }); }); });