Tests e2e — le monde par défaut & les factories
Ce fichier doit rester synchronisé avec
e2e/support/world.tsete2e/fixtures/. À mettre à jour quand une entité du dataset, un endpoint enregistré ou une factory change.
Le « monde » est le dataset happy-path que chaque test reçoit via la fixture world (objet frais par test, defaultWorld()). registerDefaultWorld(backend, world) enregistre ensuite un handler pour chaque endpoint que l'app peut toucher pendant les parcours cœur — c'est la conséquence directe du fail-loud : un endpoint non enregistré fait échouer tout test qui le touche.
Les entités du monde (World, e2e/support/world.ts)
interface World {
travels: Travel[] // les 3 voyages ci-dessous
catalog: Catalog // hôtel + chambres + assurances + véhicule
customer: E2eCustomer // Claire Dupont
countries: Country[] // CH / FR / IT / ES / DE
seasideCategories: TravelCategory[] // Majorque, Costa Brava, Igea
roomTypeAvailabilities: RoomTypeAvailability[]
occupiedSeats: Seat[] // seat-3, seat-4 (matchent les sièges du pont)
bookings: Booking[] // [] par défaut
}Les trois voyages — chacun existe pour exercer un chemin distinct du produit
| Voyage | Factory | Type | Ce qu'il exerce |
|---|---|---|---|
« Croisière sur le Rhin » (travel-rhin, slug croisiere-sur-le-rhin) | makeTravel() | CATALOG, 7 jours, days.length > 0 | Le chemin wizard avec hébergement : chambres (prices[].roomId ↔ catalog), 2 occurrences (occ-rhin-1 J+30, occ-rhin-2 J+60), lignes/arrêts (Sion, Lausanne), plan de sièges, chambre double 2'980 / familiale 3'600 |
« Marché de Noël de Colmar » (travel-colmar, slug marche-de-noel-de-colmar) | makeOneDayTravel() | ONE_DAY, 1 jour, days: [] | Le chemin sans chambre : transport tarifé par tranche d'âge (adulte 89 / junior 69 / enfant 49), occurrence unique occ-colmar-1 J+21 (skip de l'étape date), pas d'assurance ni pension |
« Séjour balnéaire à Majorque » (travel-majorque, slug sejour-balneaire-a-majorque) | makeSeasideTravel() | SEASIDE, 14 jours | Le listing balnéaire (?seaside=true) ET le wizard Seaside : accommodation singulier (acc-hotel-majorque, « Hôtel Playa Azul ») → chemin chambres + pension obligatoire (requiresMealPlan) ; occurrence occ-majorque-1 J+45 avec seule la chambre double pricée (2'580 — la disposition familiale reste masquée, filtre getPriceForRoom > 0) ; occurrence unique → les deux étapes date sont skippées, le wizard ouvre sur accommodations |
Le customer (makeCustomer())
Claire Dupont (customer-claire, claire.dupont@example.ch), Sion, née il y a 40 ans (yearsAgo(40)), contact d'urgence Marc Dupont, loyaltyPoints: 120. Type local E2eCustomer = Customer augmenté des champs loyalty du wire (absents du package) : loyaltyPoints, pendingLoyaltyPoints?, loyaltyTransactions?, loyaltyTransactionsPretty? (types miroir de src/types/extensions.ts, dates en ISO strings). Seul loyaltyPoints a un défaut — les specs loyalty posent le reste par mutation de world.customer avant seedAuth(). C'est ce customer que seedAuth() persiste — le wizard préremplit le passager 1 depuis lui.
seedAuthest idempotent par clé : l'init script tourne à chaque navigation mais ne seede une clé (auth,customer) que si elle est absente du localStorage. L'état que l'app persiste elle-même en cours de test (ex. l'override optimistependingLoyaltyposé au submit du paiement) survit donc à unpage.goto— comme sur un vrai device.
Consentement analytics (seedAnalyticsConsent, fixture auto)
Chaque test démarre avec une décision de consentement analytics déjà persistée (localStorage.analyticsConsent, défaut 'refused') — sinon le modal opt-in (ADR 0019) s'ouvrirait par-dessus l'écran sous test. Piloté par la fixture-option analyticsConsent ('accepted' | 'refused' | 'none') : test.use({ analyticsConsent: 'none' }) désactive le seed pour tester le prompt lui-même (analytics-consent.spec.ts). Même idempotence par clé que seedAuth. PostHog n'est de toute façon jamais initialisé en e2e (VITE_POSTHOG_PROJECT_TOKEN vidé dans .env.e2e) — une requête posthog.com ferait échouer le teardown fail-loud.
Sièges occupés
occupiedSeats = seat-3 et seat-4, dont les ids matchent les sièges du pont (makeVehicle()) — c'est ce qui rend les sièges « pris » visibles dans TBSeatMap.
La cohérence par ids partagés (e2e/fixtures/ids.ts)
Les fixtures se référencent entre fichiers par id. IDS est la source unique — ne jamais mettre un id en dur dans une factory ou un spec :
occurrence.prices[].roomId ───► catalog rooms (room-double / room-family) ← pricing wizard
occupiedSeats[].id ───► deck.seats[].id (seat-1 … seat-12) ← plan de sièges
roomTypeAvailability ───► roomType (rt-double) ← dispo chambres
occurrence.drives[].stops ───► stop-sion / stop-lausanne ← lieu de départ
catalog.insurances ───► ins-annulation (45 CHF) / ins-none (0 CHF) ← select assuranceCasser un de ces liens ne fait pas d'erreur de type — ça fait un wizard qui n'affiche pas de prix, des sièges tous libres, etc.
Endpoints enregistrés par registerDefaultWorld
Origines : relatif = n'importe quelle origine factice ; sinon anchoré. Tous les JSON sortent en content-type: application/json + CORS permissif (exigence CapacitorHttp web).
| Endpoint | Réponse par défaut | Particularités |
|---|---|---|
GET https://search.e2e.test/travels et GET /api/travels | paginate(hits) | Handler partagé listTravels : honore ?seaside (split catalogue/balnéaire par TravelType), ?search (texte libre sur name/subtitle), ?page/?size |
GET /api/travels/seaside/categories | world.seasideCategories | |
GET /api/travels/random | 3 premiers travels | |
GET /api/travels/:slug | le travel, sinon 404 {message:'not found'} | |
GET /api/travels/:id/catalog | world.catalog | Le même catalog pour tous les voyages |
GET /api/occurrences/:id | l'occurrence (avec travelSummary), sinon {} | Quirk du vrai backend conservé (objet vide = pas trouvé) |
GET /api/occurrences/upcoming | occurrences du Rhin + travelSummary | Enregistré après /:id pour gagner (plus spécifique) |
GET /api/occurrences/room-type-availability/:occId | world.roomTypeAvailabilities | |
GET /api/occurrences/vehicle-occupancy/:occId/:bookingId? | world.occupiedSeats | Segment :bookingId? optionnel (édition de booking) |
GET /api/countries | world.countries | |
POST /api/authentification et /refresh | {email, token: fakeJwt(...), refreshToken} | Login accepte n'importe quel mot de passe par défaut |
POST /api/authentification/forgot / /reset | {} | |
GET /api/customers/email-exists | 404 | Sémantique app : 200 = email pris, sinon libre. Override en {status: 200} pour tester « déjà utilisé » |
POST /api/customers / PUT /api/customers | echo du body fusionné sur world.customer | |
GET /api/customers | world.customer | Honore les params d'allègement comme Horizon : lighter-response=true → loyaltyTransactions: null, ignoreTravels=true → travels: [] |
GET /api/loyalty/winning-points | {winningPoints: ceil(amount × 0.1), mobileWinningPoints: ×2} | Miroir du calculateur Horizon ; appelé (débounce 300 ms) par l'étape Paiement du wizard |
GET /api/bookings | world.bookings ([]) | |
POST /api/bookings | echo du body + general.id = 'booking-new-1' | |
GET /api/bookings/:id/confirmation-invoice | TINY_PDF (raw) | |
GET /api/comments/mine | [] | |
POST /api/mail/add-waiting-list | {} | |
GET /api/gifts/:code | makeGiftCodeResponse({ code }) (bon 200 CHF intact) | Tout code valide par défaut — override en 404/{status} pour un code invalide, remainingValue pour un bon entamé |
POST https://website.e2e.test/api/mobile/booking/initialize | makePaymentInitializeResponse() (redirectUrl → stub Saferpay) | |
GET https://website.e2e.test/api/mobile/booking/payment-callback | makePaymentCallbackResponse() (CAPTURED, succès) | |
GET https://saferpay.e2e.test/pay | SAFERPAY_STUB_HTML (raw) | Page stub avec data-testid="saferpay-stub", rendue dans le popup |
GET horizon.e2e.test/images/* (regex) | PLACEHOLDER_PNG (raw) | PNG valide 320×420 — le plan de sièges dimensionne sa grille sur la taille naturelle de l'image ; un stub 1×1 rend les sièges incliquables |
GET horizon.e2e.test/Uploads/* (regex) | TINY_PDF (raw) |
Catalogue des factories (e2e/fixtures/)
Pattern commun : makeX(overrides: Partial<T> = {}) — défauts spreadés puis ...overrides, le tout en satisfies Partial<T> as T (les défauts restent honnêtes vis-à-vis du package de contrat, le cast couvre les champs backend-only jamais lus). Seuls les champs que l'app mobile lit sont peuplés.
| Fichier | Exports | À savoir |
|---|---|---|
travel.ts | SWITZERLAND, makeStop, makeDrive, makeLine, makeGlobalSupplement, makeOccurrence, makeTravel, makeOneDayTravel, makeSeasideTravel, makeVolSecSeasideTravel | makeOneDayTravel/makeSeasideTravel sont des makeTravel({...}) — n'importe quel override reste possible. makeVolSecSeasideTravel (« Vol sec Majorque », slug vol-sec-majorque) = Seaside sans accommodation (chemin room-less du wizard, transport par tranche, bébé gratuit) — hors monde par défaut : à pusher dans world.travels par le spec qui en a besoin. makeGlobalSupplement : supplément d'occurrence (défaut « Supplément carburant », 5 CHF flat/pers, isFlatRatePerPerson augmenté localement comme dans l'app) — à poser dans occurrence.globalSupplements par le spec. makeOccurrence : les seat maps multi-day lisent occurrence.vehicles (jamais GET /occurrences/:id — véhicules vides, bug backend connu, cf. occurrence-capacity.md) ; Seaside et single-day lisent catalog.vehicles à la place. makeSeasideTravel porte l'accommodation singulière (le champ qui décide seul du chemin chambres + pension du wizard) |
catalog.ts | makeRoom, makeRoomType, makeAccommodation, makeInsurance, makeVehicle, makeCatalog | makeRoomType porte la disposition familiale exacte 2A+1C (room-family) qui drive le happy path wizard. makeVehicle : 1 pont, 12 sièges en 3 rangées de 4, sièges break = séparateurs de rangée. makeCatalog : 2 assurances dont « Aucune assurance » value 0 (ins-none) |
customer.ts | E2eCustomer, LoyaltyTransaction, LoyaltyTransactionPretty, makeCustomer, COUNTRIES | Types loyalty redéclarés depuis src/types/extensions.ts (dates ISO strings, transactionType = ordinal numérique) — à garder en sync |
availability.ts | makeRoomTypeAvailability | Quota par room type |
booking.ts | makeBookingPassenger, makeBooking | Côté lecture (MyTravels, pending trip Home) — le wizard, lui, crée son booking en mémoire |
payment.ts | PaymentInitializeResponse, PaymentCallbackResponse, TransactionStatus, makePaymentInitializeResponse, makePaymentCallbackResponse | ⚠️ Types redéclarés depuis src/types/payment.ts (le projet e2e n'a pas l'alias @/) — à garder en sync manuellement |
gifts.ts | GiftCodeResponse, makeGiftCodeResponse | ⚠️ Contrat redéclaré depuis src/types/extensions.ts (GiftCodeResponse) — à garder en sync. Défauts : id: IDS.gift, 200 CHF, remainingValue: null (bon intact), validUntil à +365 j |
pagination.ts | paginate<T>(records, overrides?) | L'enveloppe {totalRecords, totalFilteredRecords, page, records} que lit stores/travels.ts |
dates.ts | daysFromNow(n), yearsAgo(n), UNZONED_DATETIME, localDay(d) | Vrais Date à midi local, toujours relatifs à la date d'exécution. Midi = le jour calendaire survit au round-trip UTC ; vrais Date = le mock JSON.stringify sérialise en ISO exactement comme le wire. UNZONED_DATETIME (regex du contrat wire sortant yyyy-MM-ddTHH:mm:ss, ADR 0016) et localDay (jour calendaire local d'une fixture) servent aux assertions de payload initialize |
ids.ts | IDS | Voir « cohérence par ids » ci-dessus |
enums.ts | TravelType, TravelBookingState, OccurrenceStatus, MealPlan, BookingStatus | Seul fichier autorisé à deep-importer le dist horizon-types (dist/enum/*.js) — le dist/index.js du package n'est pas chargeable par Node (cf. DETTE_TECHNIQUE.md). Les import type restent libres partout |
Voir aussi
e2e-overview.md— le harnais (fixtures, interception, règles).e2e-writing-tests.md— comment muter ce monde / overrider un endpoint dans un spec.- ADR
0015— pourquoi des factories manuelles (et pascreateDefaultdu package).

