Skip to content

@guestcraft/channex-simulator

MIT, stateful Channex test oracle written independently from provider documentation (ADR 0006). Runtime handlers import neither client validators nor the offline OpenAPI snapshot. It models the operations GuestCraft uses; it is not a complete Channex server or evidence of production certification.

createChannexSimulator({ environment?, now?, newId?, apiKeys?, rateLimitPerMinute?, stripePaymentMethod? }) returns an empty account of one Channex installation with its environment, its real baseUrl and an injectable fetch. Inject time and identifiers for repeatable scenarios. environment is "production" (default, https://app.channex.io/api/v1, accepted key simulator-key) or "staging" (https://staging.channex.io/api/v1, accepted key simulator-staging-key). fetch serves only URLs under baseUrl; any other URL rejects with a TypeError, as an unreachable host does, and is neither recorded nor applied. channexNetwork(...simulators) returns one fetch that routes each request to the installation whose baseUrl serves it and rejects every other URL the same way; it never reaches the real network and refuses two installations with the same base URL. @guestcraft/channex-simulator/server exposes startSimulatorServer(simulator, { port?, host? }?) for tests in separate processes, one server per installation. It defaults to a free port on 127.0.0.1 and returns baseUrl and close(); close the server when the scenario ends.

Seed properties with seedProperty, including room counts and adult occupancy, rate plans, settings, inheritance flags and channel availability rules. A room type’s ratePlans is a count of per-room plans or a list of plans with sellMode, rateMode, options and childrenFee/infantFee: by default a per-room plan has one option for the room type’s occupancy and a per-person plan one per adult count, the highest the main one (Channex’s “Per Person Manual” example). hotelPolicy: { infantMaxAge, childrenMaxAge } gives the property a hotel policy (none by default: hotel_policy_id is null); extranet.setHotelPolicy(propertyId, ages | null) edits it, or clears the property’s hotel_policy_id, as an operator would. Booking rooms may carry children and ages. A booking may carry paymentCollect, paymentType and a guarantee seed (a test card number, type, holder, expiry, virtual flag, token and virtual-card meta); the simulator stores and answers it the way Channex answers outside its PCI endpoints (BookingRevisions.BookingGuarantee: “Over any other endpoint the card number and CVV arrive masked”): the number as its first six and last four digits around asterisks (the documented example 411111******1111), the CVV as *** (“Always *** for non-secure connections”). withGuarantee: true is a default Visa card. createBooking, modifyBooking and cancelBooking create revisions; booking-room IDs survive modification. seedThread, guestMessage, seedRequest and installMessages arrange synthetic Messages scenarios. setStripeTokenizationApp(propertyId, installed) installs or removes the Stripe Tokenization App on a property as the operator does in Channex’s interface; Channex’s contract documents no route for it, so the simulator has none. Read state through availability, restrictions, revisions, reports and requests. extranet.setAvailability(roomTypeId, date, n) and extranet.setRestrictions(ratePlanId, date, values) change Channex’s state as another user of the account would in Channex’s own application: no request of the test’s key, and a processed task of that kind naming the operator (the OpenAPI’s user_name and user_email).

Channex runs two separate installations: “Production https://app.channex.io/api/v1; staging https://staging.channex.io/api/v1 (open to all)”, and “Open registration has been closed since 2023 (staging remains open)” (analysis, chapter 3). The docs.channex.io “API Reference” (read 2026-11-01) names https://staging.channex.io as the sandbox server, where “You can sign up yourself and create an API key in the user profile area of the admin”. Each simulator instance is therefore one installation with its own accounts, keys, properties, bookings and webhooks: state seeded in one is invisible to the other, and a key of one gets the other’s “401 Unauthorized” (“No valid API Key provided”). Behavioral differences between the two installations are not modeled.

Area Supported behavior
Authentication user-api-key; wrong keys, including another installation’s, receive a provider-shaped 401
Discovery Properties, room types, plans and channel rules; JSON:API relationships and pagination. A property’s hotel_policy_id and embedded policy, and GET /hotel_policies/{id} with infant_max_age/children_max_age (at most 10/21, nullable; 404 for an unknown policy)
Webhooks Account/property listing, registration, update and deletion; unknown updates return 404
Revisions Pending feed, filtered history, individual/current booking reads and acknowledgements
ARI Ordered per-change validation and warnings, date handling, stored absolute values and tasks; per-occupancy rates stored per occupancy for per-person plans; availability and restriction reads with derived availability, stop_sell and stop_sell_manual, 400 with reasons for a missing argument and 422 for an invalid one; values never written are left out
Tasks Single task and the property-filtered, paged, ordered list of processed tasks (“List processed tasks”; finished_at is required); every accepted write is processed at once
Pacing Ten ARI requests per minute per property and endpoint; rejected requests mutate nothing
OTA inventory New bookings decrement known inventory; modifications/cancellations honor separate automatic-update flags; unknown inventory stays unknown
Messages Installations, property feed, threads, sends, attachments and no-reply state
Stripe Tokenization App Only the documented POST /bookings/{booking_id}/stripe_payment_method (src/openapi.snapshot.json: 200 StripeTokenizationApp.StripePaymentMethodDocument, 401, 403, 404, 422): 403 with the documented Common.Errors.Forbidden body unless setStripeTokenizationApp installed the app on the booking’s property (“without it the request answers 403 Forbidden”), 404 for an unknown booking, otherwise { success: true, data: { token } } from the latest revision’s card. The token is the stripePaymentMethod(booking) option’s answer, a payment method the app creates in the property’s Stripe account out of band (wire it to a Stripe simulator); by default a generated pm_… no Stripe account knows. A booking without a card answers 422 and creates nothing, and every call creates a new payment method: both are simulator choices, Channex documents neither. No /applications/… route knows this app
Booking Reporting POST /bookings/{id}/no_show, /invalid_card and /cancel_due_invalid_card on known bookings: Booking.com (BookingCom, Booking.com) takes all three, Expedia (Expedia, A-Expedia) the no-show only, any other OTA gets 422 method_not_supported; 404 for an unknown booking; the no-show body is checked against Bookings.NoShowReportRequest (400 unparsable, 422 invalid arguments). Reporting windows are not modeled. reports() lists every answered report with its booking, kind, parsed body and status
Faults Status, network failure, lost response after apply, hang until abort, malformed response

injectFault(fault, { method?, path? }) consumes a fault on the next matching request. Unfiltered room/plan/rule/webhook collections and current-booking lists are account-wide; revision history models property filtering. Webhook registrations are stored but the simulator never delivers callbacks or signs deliveries, even with is_signed enabled; tests post deliveries themselves. Webhook filter[is_active] is not modeled. Undocumented defaults are conservative simulator choices. Per-occupancy rates: a change is refused as a warning, nothing of it applied, for an occupancy that is not one of the plan’s options, an occupancy given twice, a rate not above 0, rate together with rates, rates on a per-room plan, and rate on a per-person plan with several options (Channex documents none of these cases). A per-person plan reads back the main option’s rate as its one rate: Channex documents one rate per plan and date and no per-occupancy read, so which occupancy it is remains an unverified assumption. ARI reads leave out dates and values never written, so a derived stop_sell counts only a written closure, zero availability or zero rate, and do not apply channel availability rules (availability_offset, max_availability). Every accepted write is processed at once, so no running task is ever listed. Tasks and channel behavior are simplified; passing a simulator test does not prove downstream OTA delivery.

Request records retain method, path, query, status and parsed JSON bodies. Redaction replaces only string values under JSON keys named headers (used by webhook registrations), including nested strings; other values retain their types. Message text, booking and ARI bodies remain as sent. HTTP authentication headers are not recorded. Use synthetic fixtures even when recording requests.

src/openapi.snapshot.json contains dated API facts for 26 operations, including request bodies, ARI reads, the task list, webhook DELETE, the booking card’s Stripe payment method and the three Booking Reporting operations. Source URL, capture date and SHA-256 are embedded. src/contract.unit.test.ts checks raw responses, relationships, pagination and errors; @guestcraft/channex-simulator/testing exports assertChannexRequests for captured client calls, contractOperations for coverage of the operation set, compileDocumented for strict schema compilation and contractSnapshot for the underlying contract facts.

Contract validation rejects undocumented object fields so invented attributes cannot hide client defects. It accepts offset-less ISO timestamps because Channex explicitly exemplifies them. See ADR 0006 for snapshot refresh, licensing and runtime independence policy.

Messages assumption pending live certification: blank or whitespace-only message text is refused with 422, including when attachment_id is present. The pinned MessageThreads.MessageCreate schema specifies text precedence but has no minLength; it does not establish this validation rule. Special-offer bounds come from LiveFeed.LiveFeedInquirySpecialOffer.total_price (integer, int32).

Terminal window
pnpm --filter @guestcraft/channex-simulator test
pnpm --filter @guestcraft/channex-simulator typecheck

Unit/contract tests are offline and need no account. Built-app Channex browser scripts and their setup are documented in the web README. Nightly public-spec drift checking is separate from the offline contract.