Stateful mock of Gene by Gene's Nucleus API v2 (and its OAuth auth host) for test suites: client-credentials tokens with the credential-blocking failures, the product catalog, shipping quotes, order placement (shipped and quantity-only, plus orders for existing kits), order lines, fulfillments and address edits, kits and their demographics attributes, the three-layer cancel, results with presigned downloads, notification subscriptions, and GxG-signed notifications. Kits move along the lab's status ladder only when a test says so, so genomics suites that serialised on one staging slot with 15 s sleeps and 300 s waits resolve in milliseconds.
The catalog calls this package
@crvouga/mockingbird-service-gene-by-gene; it is published as@crvouga/mockingbird-service-genebygene(binmockingbird-genebygene).
- Operation coverage: SUPPORT.md (30 of the 51 operations; every one our consumer calls).
- Contract:
openapi.yamlis the full Nucleus v2 Swagger document our consumer commits (GXG/transport/spec/gxg-openapi.json, fromdemo-api.genebygene.com), vendored byscripts/vendor-openapi.ts. The script adds operationIds and Mockingbird annotations, the fields the live API returns but Swagger omits (ProductDto.preassembly,OrderLineDto.placerOrderNumber/kitNumbers,CreateOrder_Item.placerOrderNumber,nullcourier objects), schemas for the bodies Swagger leaves out (eventTypes,kitorderlines/kits,results/search,presignedUrl), and the auth host's/connect/token.
Install
npm install -D @crvouga/mockingbird-service-genebygeneESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
npx mockingbird-genebygene serve (default port 8788), createServer from ./server (Node), or
createRuntime with any Fetch server.
Usage
The mock serves the API and the auth host on one port. Point the app at it:
| App env | Value |
|---|---|
GENE_BY_GENE_API_URL |
http://127.0.0.1:8788 (or …/ns/<namespace>) |
GENE_BY_GENE_ACCESS_TOKEN_URL |
http://127.0.0.1:8788/connect/token (or …/ns/<namespace>/connect/token) |
GENE_BY_GENE_API_KEY / _CLIENT_SECRET |
anything, unless --client-id/--client-secret pin a pair |
GENE_BY_GENE_RESULTS_S3_* |
the stack's s3rver, matching --results-s3-endpoint/--results-s3-bucket |
(Our backend's non-prod safety gate must allow loopback first: catalog item G-X1.)
npx mockingbird-genebygene serve --port 8788 \
--webhook-url http://127.0.0.1:3000/webhooks/gene-by-gene --webhook-secret "$GXG_KV_SECRET" \
--results-s3-endpoint http://127.0.0.1:4569 --results-s3-bucket geviti-gxg-results-devimport { createRuntime } from "@crvouga/mockingbird-service-genebygene"
const gxg = createRuntime({ webhooks: { url: "http://127.0.0.1:3000/webhooks/gene-by-gene", secret: "kv-secret" } })
const call = (path: string, init: RequestInit = {}) => gxg.fetch(new Request(`http://gxg.test${path}`, init))
const { access_token } = (await (
await call("/connect/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "client_credentials", client_id: "id", client_secret: "s" }),
})
).json()) as { access_token: string }
// …the app places POST /api/v2/orders with Authorization: Bearer <access_token>; the response's
// orderLines[].kitNumbers carry the new kit (Order.Created + KitNumbersGenerated are sent)…
const admin = (path: string, body: unknown) =>
call(`/__admin${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
await admin("/orders/<orderId>/ship", {}) // tracking numbers + Order.Shipped
await admin("/kits/WB3K9Q2X/transition", { to: "Received" }) // Kit.Received
await admin("/kits/WB3K9Q2X/transition", { to: "Completed", fixture: "pgx" }) // results + Kit.Completed
await admin("/kits/WB7ABCDE/transition", { to: "Error", errorCode: 19 }) // Kit.Error (new collection)Routes (vendor)
| Route | Behaviour |
|---|---|
POST /connect/token |
Form grant_type=client_credentials, client_id, client_secret → {access_token, expires_in: 3600, token_type: "Bearer"}. Blocked clients (PUT /__admin/settings {blockedClients}) get 400 {"error":"invalid_client"}, 401 or 403; a mismatch against pinned clients is 400 invalid_client. |
GET /api/v2/products[?productId|productCode|productType] |
The recorded staging catalog (3 bundles with components), byte for byte. productId also finds component products; unknown → 404. |
POST /api/v2/fulfillments/actions/getShippingOptions |
{shippingAddress, quantity, productId} → {dutiesAndTaxesIncluded, errorMessages, shippingOptions[{courierName, courierServiceCode, courierServiceDisplayName, estimatedShipDate, estimatedPrice, estimatedDeliveryDate, attributes}]}. Address problems (missing fields, addressLine1 > 35 chars, non-US) come back as errorMessages; a product with nothing to ship is 400 "… is not valid for shipping options". |
POST /api/v2/orders |
Shipped form {items:[{productId, placerOrderNumber, shipments:[{quantity, address, courierServiceCode, referenceId}]}], notes} or quantity-only {items:[{productId, placerOrderNumber, quantity}]}. Bundles expand into one order line per component; the kit-material line carries a fulfillment (outbound shipment + return label) per shipment; every line carries the kit numbers (WB + 6). Address > 35 chars → 400 "shipping address(es) not validated"; unknown courier → 400 "… not valid for shipping options". |
POST /api/v2/orders/actions/createOrderForExistingKits |
{items:[{productId, kitNumbers, samples[{kitNumber, attributes}]}]}: a lab order on existing kits (Order.Created with OrderType: 3); unknown kits → 400. |
GET /api/v2/orders[?orderId…&offset&pageSize], /api/v2/orders/{id} |
OrderDto{id, orderDate, orderLines[{id, productId, currentStatus, fulfillments, placerOrderNumber, kitNumbers, …}]}; pages are {offset, pageSize, totalCount, items} (pageSize capped at 500). |
GET /api/v2/orderLines/{id} |
The order-line detail with kitStatusSummary and sibling lines. |
GET /api/v2/kits, /api/v2/kits/{kitNumber}, /api/v2/kitorderlines[?kitNumbers|orderId|attributeTerm…], /api/v2/kitorderlines/kits |
KitDto{kitNumber, gender, currentStatuses[{status, errors, errorMessage, orderLineId, orderId, fulfillment, results, history}], attributes} and the kit-order-line rows (currentStatus, currentErrorMessage, cancelCodeId, kitReceivedDate, kitEffectiveDate). |
GET /api/v2/fulfillments[?orderId], POST …/actions/updateShipmentAddress |
Shipments with isReturnShipment, address, trackingNumber, reference1, courier: null, courierService: null. An address edit is visible on the next GET; a shipped (tracked) shipment refuses. |
PATCH /api/v2/kits/{kitNumber}/attributes |
{kitNumber, attributes:[{name, value}]} (names case-insensitive; firstname, lastname, dateofbirth, gender, race, ethnicity, email, phone). Unknown name → 400; bad dateofbirth (not YYYYMMDD) or gender (not M/F/Unknown) → 422; returns every attribute. |
DELETE /api/v2/fulfillments/{id}, /api/v2/kits/{kit}/orderLines, /api/v2/orderLines/{id} |
The three cancel layers: 204; a shipped or already-canceled fulfillment, or a kit/line already with the lab, is 400 "… not in a cancellable status"; unknown or already-canceled kits/lines are 404. Canceling a kit sends Kit.KitOrderLine.Canceled. |
GET /api/v2/results[?kitNumber], /api/v2/kits/{kit}/results, /api/v2/results/search |
{resultPayload: "s3://<bucket>/<kit>.<json|pdf|csv>", resultType, resultDisplayName, resultDate, orderLineId, orderId, kitNumber, resultId}. |
GET /api/v2/results/results/presignedUrl?resultId&kitNumber&resultType |
{presignedUrl, resultId, kitNumber, resultType, expiresAt}; the URL is GET {mock}/__blob/<key>?X-Amz-… and answers the bytes, or 403 AccessDenied (bad signature, expired on the mock clock, or the presigned_access_denied preset). |
GET /api/v2/attributes, /api/v2/eventTypes |
The attribute definitions and event types. |
GET/POST /api/v2/notificationSubscriptions, GET/PATCH/DELETE …/{id} |
{id, endPoint, events, secret, active, …}. secret is returned only by POST. Accepts application/json-patch+json. Kit.KitOrderLine.Canceled in events is 400 "Valid event type is required."; a second subscription for the same endpoint is 400. |
Every API route needs Authorization: Bearer <token>; a missing, expired, revoked
(POST /__admin/tokens/revoke) or blocked-client token is an empty 401 with
WWW-Authenticate: Bearer error="invalid_token", so our client invalidates and retries once.
Handler errors are ErrorDto{statusCode, message, payload, errorType}; model-binding failures are
ASP.NET problem details with errors.
Webhooks
Notifications go to every active subscription for its events, signed with that subscription's
secret, and to --webhook-url (all events) signed with --webhook-secret:
gxg-signature: sha512=<hex HMAC-SHA512(secret, rawBody)>,gxg-eventtype: <event>,gxg-notificationid: <uuid>.- Events:
GxG.Nucleus.Order.Created(OrderGuid,OrderType,OrderItems— the realOrderGuidshape, which our receiver'sOrderIdextractor misses: kept on purpose),.Order.KitNumbersGenerated,.Order.Shipped,.Kit.Received,.Kit.Completed(one per result-bearing order line: JSON + PDF on the report line, CSV on the raw-data line),.Kit.Error(ErrorCode4 "10 Day Delay", 19 "New collection requested"), and.Kit.KitOrderLine.Canceled. Bodies are PascalCase with the keys of the recorded samples (GXG/docs/webhook-events.json). - Retries: immediately, 10 s, 1 min, 5 min (GxG retries up to three times).
GET /__admin/webhooks,…/events,POST …/flush,…/:id/replayas usual.
Admin (beyond the standard contract)
| Route | Effect |
|---|---|
POST /__admin/kits/:kitNumber/transition |
{to, errorCode?, errorMessage?, fixture?}. to: Not Received, Received (Kit.Received), In Lab, In QC Analysis, QC Analysis Complete, Results Completed / Completed (writes results, then Kit.Completed), Error (Kit.Error, code default 4), Canceled. |
PUT /__admin/results/:kitNumber |
Stage what Completed publishes: {fixture: "normal" | "pgx" | "ancestry"} or a custom {json?, csv?, pdfBase64?}. |
POST /__admin/orders/:id/ship |
{trackingNumber?, returnTrackingNumber?}: tracking numbers, closeout date, lines Shipped/Processing, Order.Shipped. |
POST /__admin/orders/:id/kit-numbers |
Mint kit numbers for an order placed without them (KitNumbersGenerated). |
PUT /__admin/settings |
{tokenTtlSeconds?, clients?, blockedClients?: {"<client_id>": "invalid_client" | "unauthorized" | "forbidden"}, generateKitNumbers?, presignedUrlTtlSeconds?, resultsBucket?}. |
POST /__admin/tokens/revoke |
Every token issued so far answers 401. |
GET /__admin/orders, /__admin/kits |
The namespace's records. |
Result fixtures: normal is our consumer's own comprehensive-report fixture (synthetic "Jane
Doe", trimmed to one item per section); pgx and ancestry are synthetic reports in the same
schema. Every fixture also publishes the raw-data CSV and a one-page PDF.
Fault presets (POST /__admin/faults {"preset": "<name>", "count"?, "latencyMs"?}):
invalid_client, token_unauthorized, token_forbidden, token_revoked, rate_limited,
shipping_empty_500, address_not_validated, slow_orders (5 s on POST /orders),
no_kit_numbers, cancel_conflict (409), presigned_access_denied, server_error,
webhook_duplicate, webhook_reorder, webhook_drop.
Namespaces
x-mockingbird-namespace, a /ns/<name> prefix on both GENE_BY_GENE_API_URL and the token
URL, or by client id: PUT /__admin/credentials {"credentials": {"<client_id>": "<namespace>"}}
(tokens carry the client id they were issued to). Kit numbers and result keys are salted per
namespace, so parallel workers never collide in the shared results bucket.
Deliberately not modelled
- The 21 operations our consumer never calls (HL7
ehr/*, single-attribute edits, CSV/document uploads,resetSecret, bulk kit-order-line cancel, …):supported: falseinopenapi.yaml. - Server-side subscription filters (GxG creates them on request), invoicing, insurance.
- Real carrier behaviour: shipping prices and tracking numbers are fixed-format synthetics.
- A live
corpus pull: the catalog is the consumer's recordedgxg-list-products-dev.json; attribute definitions and event types are synthesised from the consumer's code (no recording exists), because staging credentials are not in Vault. - Nothing moves on its own: every lab step is an admin transition.
API
| Export | Kind | Description |
|---|---|---|
GeneByGeneAPI |
class | The in-process mock: fetch(request), reset(), transition(kit, {to, errorCode?, fixture?}), ship(orderId), generateKitNumbers(orderId), setPendingResults(kit, source), orders(), kits(), order(id), kit(kit), activeSubscriptions(). Options: sqlite, now, namespace, products, settings, onWebhook, resultsS3, publicNamespace. |
createRuntime |
function | The mock with the full service contract. Options: webhooks: {url?, secret?, events?, retryDelaysMs?, fetch?}, resultsS3: {endpoint, bucket, region?, accessKeyId?, secretAccessKey?}, products, settings, clock, seed, adminKey, onLog. |
GENEBYGENE_PRESETS |
object | Every named fault preset. |
GENEBYGENE_NAMESPACE |
string | The service name, "genebygene". |
tokenCredential |
function | The client id a bearer token was issued to (credential → namespace). |
KIT_STATUSES, MAX_ADDRESS_LINE |
values | The kit status ladder; the 35-character address-line limit. |
GXG_EVENTS, KIT_ERROR_MESSAGES |
values | Notification event names; the Kit.Error messages for codes 4 and 19. |
gxgSigner, signGxgWebhookBody |
functions | The GxG webhook signer and sha512=<hex> signature. |
STAGING_PRODUCTS, PRODUCT_CODES, CORPUS_VERSION, ATTRIBUTE_DEFINITIONS, EVENT_TYPES, SHIPPING_OPTIONS |
values | The corpus. |
RESULT_FIXTURES, NORMAL_REPORT, PGX_REPORT, ANCESTRY_REPORT, RAW_DATA_CSV, resultFiles, minimalPdf |
values | Result fixtures and the files a completed kit publishes. |
DEFAULT_SETTINGS, netIso |
values | Default per-namespace settings; .NET-style instant formatting. |
document, operationIds, supportedOperationIds |
values | The vendored OpenAPI contract and its operation ids. |
createServer, serveTarget, DEFAULT_PORT (./server) |
Node | Serve over node:http; the serve CLI target; port 8788. |
Development
bun test # self-parity, acceptance through test/consumer.ts, contract, served-over-HTTP
bun scripts/vendor-openapi.ts # re-vendor openapi.yaml from ~/geviti-monorepo, then bun run generate
bun run parity # live parity against GxG staging; exits 2 without
# MOCKINGBIRD_GENEBYGENE_CLIENT_ID / _CLIENT_SECRET (env or Vault)Part of mockingbird.