@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.
Entry points and setup
Section titled “Entry points and setup”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).
Environments
Section titled “Environments”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.
Modeled behavior
Section titled “Modeled behavior”| 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.
Independent offline contract
Section titled “Independent offline contract”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).
pnpm --filter @guestcraft/channex-simulator testpnpm --filter @guestcraft/channex-simulator typecheckUnit/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.