mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -65,7 +65,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
@@ -228,7 +228,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
YardFacilitiesSeeder,
|
||||
// YardFacilitiesSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
@@ -255,7 +255,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
@@ -304,7 +304,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
||||
// Dire Dawa). Idempotent; creates no yards.
|
||||
await this.yardFacilitiesSeeder.run();
|
||||
// await this.yardFacilitiesSeeder.run();
|
||||
|
||||
// Dropdown settings are not seeded on boot; run them with
|
||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||
@@ -335,9 +335,11 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
||||
consumer.apply(LoginAudienceMiddleware).forRoutes(
|
||||
{ path: "auth/login", method: RequestMethod.POST },
|
||||
{ path: "auth/mfa-verify", method: RequestMethod.POST },
|
||||
);
|
||||
consumer
|
||||
.apply(LoginAudienceMiddleware)
|
||||
.forRoutes(
|
||||
{ path: "auth/login", method: RequestMethod.POST },
|
||||
{ path: "auth/mfa-verify", method: RequestMethod.POST },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,13 @@ type SeedOrganization = {
|
||||
export class EdrOrgSeeder {
|
||||
private readonly logger = new Logger(EdrOrgSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(private readonly dataSource: DataSource) { }
|
||||
|
||||
async run() {
|
||||
if (!this.shouldSeed()) {
|
||||
this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`);
|
||||
this.logger.log(
|
||||
`Skipping EDR org seed because ${SEED_FLAG} is not enabled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,20 +97,22 @@ export class EdrOrgSeeder {
|
||||
manager: EntityManager,
|
||||
organizationId: string,
|
||||
) {
|
||||
const organizationConfigurationRepository =
|
||||
manager.getRepository(OrganizationConfiguration);
|
||||
|
||||
await organizationConfigurationRepository.upsert({
|
||||
organizationId,
|
||||
canCreateBranchByItself: true,
|
||||
canStartReceivingRecord: true,
|
||||
}, {
|
||||
conflictPaths: { organizationId: true },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Ensured organization configuration for '${EDR_ORG_KEY}'`,
|
||||
const organizationConfigurationRepository = manager.getRepository(
|
||||
OrganizationConfiguration,
|
||||
);
|
||||
|
||||
await organizationConfigurationRepository.upsert(
|
||||
{
|
||||
organizationId,
|
||||
canCreateBranchByItself: true,
|
||||
canStartReceivingRecord: true,
|
||||
},
|
||||
{
|
||||
conflictPaths: { organizationId: true },
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`Ensured organization configuration for '${EDR_ORG_KEY}'`);
|
||||
}
|
||||
|
||||
private async ensureDefaultUnit(
|
||||
@@ -152,7 +156,9 @@ export class EdrOrgSeeder {
|
||||
key: EDR_FREIGHT_APPLICATION.key,
|
||||
name: { ...EDR_FREIGHT_APPLICATION.name },
|
||||
});
|
||||
this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
|
||||
this.logger.log(
|
||||
`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`,
|
||||
);
|
||||
return { id: insertResult.identifiers[0]?.id as string };
|
||||
}
|
||||
|
||||
@@ -181,7 +187,8 @@ export class EdrOrgSeeder {
|
||||
applicationId,
|
||||
})),
|
||||
)
|
||||
.orUpdate(["name", "application_id"], ["key"])
|
||||
.orIgnore()
|
||||
// .orUpdate(["name", "application_id"], ["key"])
|
||||
.execute();
|
||||
|
||||
this.logger.log(
|
||||
|
||||
165
docker-compose.it.yaml
Normal file
165
docker-compose.it.yaml
Normal file
@@ -0,0 +1,165 @@
|
||||
# EDR Freight — API integration stack (headless).
|
||||
#
|
||||
# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio,
|
||||
# mocks, freight-api), except the payment microservice is REAL here instead of
|
||||
# `payment-mock-e2e`, and only the bank gateways are stubbed:
|
||||
#
|
||||
# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it
|
||||
# ^ │
|
||||
# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer)
|
||||
#
|
||||
# Its own compose project (`name:` below overrides the base) and its own host
|
||||
# ports, so it can run side by side with the Cypress e2e stack.
|
||||
#
|
||||
# node integration/scripts/it.mjs up|test|down|logs
|
||||
#
|
||||
# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is
|
||||
# an OVERLAY and needs the base file first:
|
||||
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ...
|
||||
name: edr-freight-it
|
||||
|
||||
services:
|
||||
# Outbox transport. The payment API publishes payment.succeeded/failed here
|
||||
# and freight consumes it — the production path. Copied from the passenger
|
||||
# harness (e2e/docker-compose.yml).
|
||||
rabbitmq-it:
|
||||
image: rabbitmq:3-management
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: edr
|
||||
RABBITMQ_DEFAULT_PASS: edr_secret
|
||||
RABBITMQ_DEFAULT_VHOST: payment
|
||||
ports:
|
||||
- "${IT_RABBIT_PORT:-5772}:5672"
|
||||
- "${IT_RABBIT_UI_PORT:-15772}:15672"
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
# Stand-in for every bank/wallet gateway the payment API talks to, plus a
|
||||
# control plane the tests drive (force a provider to fail/hang, fire a
|
||||
# correctly-signed webhook, read back what was called). See
|
||||
# integration/gateway-mock/server.js.
|
||||
gateway-mock-it:
|
||||
image: node:20-alpine
|
||||
volumes:
|
||||
- ./integration/gateway-mock:/app:ro
|
||||
working_dir: /app
|
||||
environment:
|
||||
PORT: "4600"
|
||||
# Same secrets the payment API gets — so webhooks the mock signs pass the
|
||||
# API's REAL signature verification instead of bypassing it.
|
||||
CBE_SECRET_KEY: it-cbe-secret
|
||||
CBE_MERCHANT_ID: it-cbe-merchant
|
||||
PAYMENT_API_URL: http://payment-api-it:3003
|
||||
command: ["node", "server.js"]
|
||||
ports:
|
||||
- "${IT_GATEWAY_PORT:-4600}:4600"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
payment-api-it:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-payment-api/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
depends_on:
|
||||
postgres-freight-e2e:
|
||||
condition: service_healthy
|
||||
rabbitmq-it:
|
||||
condition: service_healthy
|
||||
gateway-mock-it:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: "3003"
|
||||
NODE_ENV: test
|
||||
# Payment tables live in their own schema of the same throwaway DB;
|
||||
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
|
||||
DB_HOST: postgres-freight-e2e
|
||||
DB_PORT: "5432"
|
||||
DB_USER: edr_e2e
|
||||
DB_PASSWORD: edr_e2e
|
||||
DB_NAME: edr_freight_e2e
|
||||
DB_SCHEMA: edr_payment
|
||||
# Same token freight already uses in the base stack.
|
||||
SERVICE_AUTH_TOKEN: e2e-service-token
|
||||
PUBLISHER_TRANSPORT: rabbitmq
|
||||
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
||||
# HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http).
|
||||
PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid
|
||||
# Fast relay + sweep so retry/reconciliation are observable inside a test
|
||||
# rather than a minute later.
|
||||
OUTBOX_RELAY_INTERVAL_MS: "1000"
|
||||
RECONCILE_STALE_AFTER_MS: "5000"
|
||||
# Every gateway points at the one mock. Paths are per-provider prefixes.
|
||||
CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr
|
||||
CBE_MERCHANT_ID: it-cbe-merchant
|
||||
CBE_SECRET_KEY: it-cbe-secret
|
||||
CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr
|
||||
CBE_RETURN_URL: http://localhost/return
|
||||
TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr
|
||||
TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web
|
||||
TELEBIRR_FABRIC_APP_ID: it-fabric
|
||||
TELEBIRR_APP_SECRET: it-secret
|
||||
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
|
||||
TELEBIRR_MERCHANT_CODE: "999999"
|
||||
TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr
|
||||
# Telebirr PSS-signs every request object — a throwaway key generated per
|
||||
# launch by it.mjs (nothing key-shaped lives in git).
|
||||
TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY}
|
||||
EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr
|
||||
DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney
|
||||
CARD_BASE_URL: http://gateway-mock-it:4600/card
|
||||
WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi
|
||||
CAC_BASE_URL: http://gateway-mock-it:4600/cac
|
||||
CAC_USERNAME: it-cac
|
||||
CAC_PASSWORD: it-cac
|
||||
CAC_APP_KEY: it-cac-key
|
||||
CAC_API_KEY: it-cac-api
|
||||
CAC_COMPANY_SERVICES_ID: "1"
|
||||
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
|
||||
# the freight API, so this direction runs real code on both sides.
|
||||
CBE_BILL_ENABLED: "true"
|
||||
CBE_BILL_CLIENT_ID: it-cbe-bill
|
||||
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
|
||||
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
|
||||
FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api
|
||||
ports:
|
||||
- "${IT_PAYMENT_PORT:-3113}:3003"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 40s
|
||||
|
||||
# Base-stack service, re-pointed at the real payment API.
|
||||
freight-api-e2e:
|
||||
depends_on:
|
||||
payment-api-it:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PAYMENT_API_URL: http://payment-api-it:3003
|
||||
# Freight's payment module skips RabbitMQModule entirely when this is
|
||||
# unset (payment.module.ts) — without it, outbox events never arrive.
|
||||
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
||||
# RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email
|
||||
# clients, which must remain off. The payment consumer is wired by
|
||||
# PAYMENT_RABBITMQ_URL alone.
|
||||
156
integration/README.md
Normal file
156
integration/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Freight API integration suite
|
||||
|
||||
Headless, API-level tests for the freight API running against the **real**
|
||||
`edr-payment-api`. Only the bank/wallet gateways are stubbed.
|
||||
|
||||
```
|
||||
pnpm it:up # build + start the stack (first run ~5 min)
|
||||
pnpm it:test # vitest run (auto-ups the stack if needed)
|
||||
pnpm it:test -- --reporter=basic src/payment-happy.it.ts
|
||||
pnpm it:logs payment-api-it
|
||||
pnpm it:down # -v, wipes the throwaway DB
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml` — same
|
||||
freight API, Postgres (tmpfs), MinIO, Fayda/eTrade mocks; plus RabbitMQ, the
|
||||
real payment API, and one gateway mock. It is a separate compose project
|
||||
(`edr-freight-it`) on offset ports, so the Cypress e2e stack can run alongside.
|
||||
|
||||
```
|
||||
freight-api-e2e :3111 ──HTTP──> payment-api-it :3113 ──HTTP──> gateway-mock-it :4600
|
||||
^ │
|
||||
└────────── RabbitMQ :5772 ─────┘ (outbox → payment.events → consumer)
|
||||
```
|
||||
|
||||
Never `docker compose -f docker-compose.it.yaml` on its own — it needs the base
|
||||
file first. Use `it.mjs`.
|
||||
|
||||
## Gateway mock
|
||||
|
||||
`gateway-mock/server.js` — one zero-dep `node:http` process serving every
|
||||
provider under a path prefix, plus a control plane the tests drive:
|
||||
|
||||
| call | effect |
|
||||
| --- | --- |
|
||||
| `POST /__control/provider/:name` `{mode, times}` | `ok` / `fail` / `timeout` / `pending` / `paid` |
|
||||
| `POST /__control/webhook` `{merchantOrderId, status, eventId, signature}` | fires a **correctly signed** callback at the payment API |
|
||||
| `POST /__control/settle` `{merchantOrderId}` | pays at the bank with no callback (reconciliation path) |
|
||||
| `GET /__control/calls` | every inbound provider call |
|
||||
| `POST /__control/reset` | clear modes, orders and calls |
|
||||
|
||||
Signatures are real: the mock shares `CBE_SECRET_KEY` with the API, so
|
||||
`verifyWebhookSignature` runs for real and `signature: "bad"` is a genuine
|
||||
negative test. The suite drives **CBE Birr** end to end (plain HMAC, no key
|
||||
material); other providers answer a generic stub until a scenario needs them.
|
||||
|
||||
Editing `server.js` needs a container restart (`docker compose … restart
|
||||
gateway-mock-it`) — the code is a read-only mount, not baked into an image.
|
||||
|
||||
## Files
|
||||
|
||||
| file | covers |
|
||||
| --- | --- |
|
||||
| `src/payment-happy.it.ts` | initiate → webhook → outbox → broker → invoice PAID → booking advances |
|
||||
| `src/payment-failure.it.ts` | provider down, decline, forged signature, reconciliation sweep, `unverifiable`, late capture |
|
||||
| `src/concurrency.it.ts` | duplicate callbacks, two intents on one invoice, wagon budget, pay-window gate |
|
||||
| `src/authz.it.ts` | cross-tenant isolation, login audience, service-token gates |
|
||||
| `src/cbe-bill.it.ts` | inbound CBE Unified Bill: token → query (hops into freight) → payment |
|
||||
| `src/bulk-import-full-train.it.ts` | six wheat bookings fill 54 wagons, then gate pass → T1 → dispatch → corridor → arrival → customs tail |
|
||||
| `src/bulk-import-waiting-expiry.it.ts` | exact-fill trio selected, waiting three expire with the day |
|
||||
| `src/bulk-import-split-promote.it.ts` | partial offer, split on settlement, expiry promotion, exact-remainder rebooking |
|
||||
| `src/bulk-import-window-reopen.it.ts` | nobody pays → cycle 2 opens on the same train |
|
||||
| `src/bulk-import-matrix.it.ts` | no-window day, sub-corridor, ride-along, whole-train giant |
|
||||
| `src/bulk-export-full-train.it.ts` | FCFS accept = reservation, deadlines clamped to window close, export tail |
|
||||
| `src/bulk-export-fcfs-space.it.ts` | reservations hold capacity, whole-or-nothing giant |
|
||||
| `src/bulk-export-pay-or-lose.it.ts` | expiry frees space; window close expires the unpaid, no reopen |
|
||||
| `src/bulk-export-matrix.it.ts` | mid-route boarding, leg occupancy, ride-along, sibling train windows |
|
||||
| `src/bulk-b1-priority-expiry-refill.it.ts` | rule-engine priority band, expiry refill |
|
||||
| `src/bulk-b2-per-item-floor.it.ts` | PER_ITEM wagon floor vs tonnage math |
|
||||
| `src/bulk-b3-per-item-giant.it.ts` | PER_ITEM giant + line quantities (pins two defects) |
|
||||
| `src/g1-s1-expiry-promotes-waitlist.it.ts` | 53-wagon built train: expiry frees exactly the waiting list's space |
|
||||
| `src/g1-s2-exact-fill.it.ts` | exact 53/53 fill, every one of 68 containers mapped to a slot |
|
||||
| `src/g1-s3-underfill-day-open.it.ts` | 28/53 is NOT FULL — the day still offers 25 wagons |
|
||||
| `src/g1-s4-split-closes-gap.it.ts` | container split closes the last 3-wagon gap, 14-box remainder |
|
||||
| `src/g1-s5-cascading-expiry.it.ts` | one settle promotes twice; expiries terminal, invoices closed |
|
||||
| `src/g1-s6-s8-offers-government-tiers.it.ts` | ignored offer, government preemption, USD/customs/plain tiers |
|
||||
| `src/flows.ts` | freight business steps, ported from `e2e/freight/cypress/e2e/flows/import-utils.ts` |
|
||||
|
||||
## Findings pinned by these tests
|
||||
|
||||
Where the platform's live behaviour differs from the scenario, the test asserts
|
||||
what it actually does and says so in a comment, so a fix fails loudly:
|
||||
|
||||
- **Refill never supersedes an open partial offer** (`bulk-b1`, `g1-s5`). After
|
||||
the giant expires and frees 28 wagons, the offered booking is re-selected but
|
||||
keeps its stale 16-wagon offer; `applySplit` applies it, so the customer ships
|
||||
16 of 20 with room to spare. `g1-s5` pins the container half: an expiry frees
|
||||
8 more wagons and the 2-wagon offer beside them is never resized.
|
||||
- **PER_ITEM bookings never get a partial offer** (`bulk-b3`). `sizeOffer` sizes
|
||||
bulk offers by weight off `cargoTotalWeightVgm`, which for PER_ITEM holds the
|
||||
ITEM COUNT — a 240-auto booking needing 60 wagons looks like 4, gets no offer,
|
||||
allocates nothing, and expires with the day.
|
||||
- **The contract booking path drops bulk `hazardousQuantity` / `reeferQuantity`**
|
||||
(`bulk-b3`). Only `POST /api/bookings` maps and clamps them.
|
||||
- **Paid intercity ride-alongs are unpinned back to the pool** (both matrices) —
|
||||
staff must place them again. The Cypress twin never sees this because its
|
||||
staff mark-paid shortcut leaves the reservation pinned.
|
||||
- **Bulk priority is recomputed at doc-review** (`bulk-b1`), so writing
|
||||
`priority_score` directly is a no-op; ranking has to come from a WAGON
|
||||
priority config.
|
||||
- **Freight sends a dev-shortcut amount** (1 minor unit, 10 for CAC) for every
|
||||
non-`CBE_BILL` provider, with no short-payment guard.
|
||||
- **Government preemption cannot reach a FULL train** (`g1-s6-s8`). `isFillable`
|
||||
rejects a schedule whose `booking_window_status` is FULL before any budget or
|
||||
victim is considered, and `refreshWindowStatus` re-derives that flag from live
|
||||
capacity — so a genuinely full train is skipped and no commercial booking is
|
||||
ever displaced. S7 therefore commits 52 of 53 slots.
|
||||
- **The CUSTOMS priority band is dead in the shipped fixture.** It applies only
|
||||
when the booking's SERVICE TYPE has `includes_customs`, and the corridor seed
|
||||
ships one service type that does not — so the two CUSTOMS bands never score.
|
||||
`seed-customs-service-type.sql` adds `RAIL_CUSTOMS` so the tier can be tested.
|
||||
- **A customs SERVICE TYPE without a customs CONTRACT cannot finalize
|
||||
clearance.** `finalizeClearance` looks up `clearance_output_<op>_<freight>`,
|
||||
which the seeder deliberately leaves commented out, so `getByCode` 404s. Only
|
||||
the phased path (`customs_clearing_enabled` on the contract) avoids it — which
|
||||
is why S8's customs tenant is Path B.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **One unpaid hold per company.** `assertNoUnpaidHold` blocks a company with a
|
||||
`SELECTED_FOR_BATCH` booking from creating another. Every file starts with
|
||||
`releaseUnpaidHolds()`, which also hard-deletes retired
|
||||
`train_schedule_bookings` rows (`booking_id` is UNIQUE and the constraint
|
||||
ignores `deleted_at`, so a soft-unlinked booking can never be re-batched).
|
||||
- **Arrange is slow.** Contract → booking → clearance → ops accept → batch is
|
||||
30–60s of real API work per booking, so files share one schedule day.
|
||||
- Files run sequentially (one DB); concurrency is exercised inside a test with
|
||||
`Promise.all`.
|
||||
- **A retired fixture keeps its shipment day at its peril.**
|
||||
`rescueStrandedPaidForDay` sweeps every unlinked booking whose
|
||||
`payment_status` is PAID and whose `scheduled_date` falls on the day being
|
||||
filled, and re-places it on the fresh train. A previous run's paid bookings
|
||||
therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, until
|
||||
`releaseUnpaidHolds` / `resetCorridorDay` started nulling `scheduled_date`.
|
||||
- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and
|
||||
REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for
|
||||
DONE there waits forever — use `pollCycleConcluded`.
|
||||
- **Wagon stock is finite and shared.** Paid bookings keep their wagons, so
|
||||
`releaseUnpaidHolds()` also frees every earlier `CTR-IT-%` allocation —
|
||||
without it the fifth or sixth file on a warm stack silently gets a short
|
||||
consist.
|
||||
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
|
||||
because a company may hold only one unpaid reservation at a time; staff book
|
||||
and pay on their behalf, which is also the real Path B flow.
|
||||
- **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a
|
||||
cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here)
|
||||
every fill pass. A built train's coupled consist wins outright, so
|
||||
`seed-g1-train.sql`'s 53 wagons ARE the capacity — the number every G1
|
||||
scenario's arithmetic is written in. `createBuiltTrainSchedule` asserts it.
|
||||
- **Customs bookings walk the phased chain** (`clearBookingPhasedCustoms`):
|
||||
transit assignee → declaration draft → accept → declaration → duty →
|
||||
transit permit → pre-clearance → delivery order. `clearance/finalize` refuses
|
||||
them outright.
|
||||
- Freight sends a dev-shortcut amount (1 minor unit, 10 for CAC) for every
|
||||
non-`CBE_BILL` provider. The tests assert that as-is.
|
||||
299
integration/gateway-mock/server.js
Normal file
299
integration/gateway-mock/server.js
Normal file
@@ -0,0 +1,299 @@
|
||||
// Stand-in for every bank/wallet gateway the payment API calls, plus a control
|
||||
// plane the integration tests drive.
|
||||
//
|
||||
// WHY ONE PROCESS
|
||||
//
|
||||
// Each provider's base URL is env-configurable (packages/payment-providers/…),
|
||||
// so pointing them all at one server with a per-provider path prefix stubs the
|
||||
// whole outbound surface without touching a line of app code. The payment API
|
||||
// itself, its state machine, its webhook pipeline and its signature checks all
|
||||
// run for real.
|
||||
//
|
||||
// Signatures are REAL: this server holds the same CBE_SECRET_KEY the API does
|
||||
// and signs the callbacks it fires, so the API's verifyWebhookSignature runs in
|
||||
// anger instead of being bypassed. That also makes the negative test possible —
|
||||
// ask for a bad signature and the API must refuse to move any money.
|
||||
//
|
||||
// No dependencies (node:http + node:crypto), same shape as e2e/freight/*-mock.
|
||||
const http = require("node:http");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const PORT = Number(process.env.PORT || 4600);
|
||||
const PAYMENT_API_URL = process.env.PAYMENT_API_URL || "http://payment-api-it:3003";
|
||||
const CBE_SECRET = process.env.CBE_SECRET_KEY || "it-cbe-secret";
|
||||
const CBE_MERCHANT = process.env.CBE_MERCHANT_ID || "it-cbe-merchant";
|
||||
const CAC_OTP = "123456";
|
||||
|
||||
/**
|
||||
* Per-provider behaviour, set by POST /__control/provider/:name.
|
||||
* ok — succeed (default)
|
||||
* fail — answer 502, so the provider call throws inside the API
|
||||
* timeout — never answer (the API's own 10s axios timeout fires)
|
||||
* pending — succeed on initiate, but report "not paid yet" on every query
|
||||
* paid — report SUCCESS on query without any webhook (reconciliation path)
|
||||
* `remaining` counts down when set, then the provider reverts to ok.
|
||||
*/
|
||||
const modes = new Map();
|
||||
/** Every inbound call, for "the provider was queried exactly once" assertions. */
|
||||
let calls = [];
|
||||
/** merchantOrderId → what the mock believes the payment did. */
|
||||
const orders = new Map();
|
||||
|
||||
function modeFor(provider) {
|
||||
const entry = modes.get(provider);
|
||||
if (!entry) return "ok";
|
||||
if (entry.remaining != null) {
|
||||
if (entry.remaining <= 0) {
|
||||
modes.delete(provider);
|
||||
return "ok";
|
||||
}
|
||||
entry.remaining -= 1;
|
||||
}
|
||||
return entry.mode;
|
||||
}
|
||||
|
||||
/** CBE Birr signs `k=v` pairs over sorted keys with HMAC-SHA256 (hex). */
|
||||
function cbeSign(data) {
|
||||
const signString = Object.keys(data)
|
||||
.sort()
|
||||
.map((k) => `${k}=${data[k]}`)
|
||||
.join("&");
|
||||
return crypto.createHmac("sha256", CBE_SECRET).update(signString).digest("hex");
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers = {}) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a provider callback at the payment API, signed the way the real gateway
|
||||
* would. `signature: "bad"` deliberately produces a well-formed but wrong
|
||||
* signature (same length — the API compares with timingSafeEqual, which throws
|
||||
* on a length mismatch and would mask what we are testing).
|
||||
*/
|
||||
async function fireWebhook(opts) {
|
||||
const {
|
||||
provider = "CBE_BIRR",
|
||||
merchantOrderId,
|
||||
status = "SUCCESS",
|
||||
transactionId,
|
||||
eventId,
|
||||
signature,
|
||||
} = opts;
|
||||
if (provider !== "CBE_BIRR") {
|
||||
throw new Error(`webhook not implemented for provider ${provider}`);
|
||||
}
|
||||
const order = orders.get(merchantOrderId) ?? {};
|
||||
const payload = {
|
||||
merchantId: CBE_MERCHANT,
|
||||
merchantOrderId,
|
||||
// orderId doubles as the dedupe key upstream: externalEventId is
|
||||
// `${orderId}_${status}` (cbe-birr-webhook.service.ts), so a caller-supplied
|
||||
// eventId is how a test replays the SAME event.
|
||||
orderId: eventId ?? order.orderId ?? `CBEORD-${merchantOrderId}`,
|
||||
status,
|
||||
transactionId: transactionId ?? order.transactionId ?? `CBETXN-${merchantOrderId}`,
|
||||
amount: order.amount ?? "1.00",
|
||||
currency: order.currency ?? "ETB",
|
||||
paidAt: new Date().toISOString(),
|
||||
};
|
||||
payload.signature =
|
||||
signature === "bad" ? crypto.randomBytes(32).toString("hex") : cbeSign(payload);
|
||||
return postJson(`${PAYMENT_API_URL}/webhooks/cbe-birr`, payload);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// provider routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @returns {[number, unknown] | "hang"} */
|
||||
function handleProvider(provider, path, body, url) {
|
||||
const mode = modeFor(provider);
|
||||
if (mode === "timeout") return "hang";
|
||||
if (mode === "fail") return [502, { error: `${provider} unavailable (forced)` }];
|
||||
|
||||
switch (`${provider}/${path}`) {
|
||||
// --- CBE Birr (the suite's primary provider: plain HMAC, no key material)
|
||||
case "cbe-birr/api/v1/payment/initiate": {
|
||||
const orderId = `CBEORD-${body.merchantOrderId}`;
|
||||
orders.set(body.merchantOrderId, {
|
||||
orderId,
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
transactionId: `CBETXN-${body.merchantOrderId}`,
|
||||
paid: false,
|
||||
});
|
||||
return [
|
||||
200,
|
||||
{
|
||||
success: true,
|
||||
orderId,
|
||||
paymentUrl: `http://gateway-mock-it:${PORT}/cbe-birr/pay/${orderId}`,
|
||||
expiresIn: 900,
|
||||
},
|
||||
];
|
||||
}
|
||||
case "cbe-birr/api/v1/payment/query": {
|
||||
const order = orders.get(body.merchantOrderId);
|
||||
if (!order) return [200, { success: false, status: "NOT_FOUND" }];
|
||||
const paid = mode === "paid" || order.paid;
|
||||
return [
|
||||
200,
|
||||
{
|
||||
success: true,
|
||||
orderId: order.orderId,
|
||||
status: paid ? "SUCCESS" : mode === "pending" ? "PENDING" : "PROCESSING",
|
||||
transactionId: order.transactionId,
|
||||
amount: order.amount,
|
||||
paidAt: paid ? new Date().toISOString() : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// --- Telebirr (fabric token + createOrder + queryOrder)
|
||||
case "telebirr/payment/v1/token":
|
||||
return [200, { token: "it-fabric-token" }];
|
||||
case "telebirr/payment/v1/inapp/createOrder": {
|
||||
const merchOrderId = body?.biz_content?.merch_order_id;
|
||||
const prepayId = `PREPAY-${merchOrderId ?? Date.now()}`;
|
||||
orders.set(merchOrderId, { orderId: prepayId, paid: false });
|
||||
return [
|
||||
200,
|
||||
{
|
||||
result: "SUCCESS",
|
||||
code: "0",
|
||||
biz_content: { prepay_id: prepayId, merch_order_id: merchOrderId },
|
||||
},
|
||||
];
|
||||
}
|
||||
case "telebirr/payment/v1/merchant/queryOrder": {
|
||||
const order = orders.get(body?.biz_content?.merch_order_id);
|
||||
const paid = mode === "paid" || order?.paid;
|
||||
return [
|
||||
200,
|
||||
{
|
||||
result: "SUCCESS",
|
||||
code: "0",
|
||||
biz_content: {
|
||||
order_status: paid ? "Completed" : "Paying",
|
||||
trans_id: order?.orderId,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// --- CAC Bank (OTP debit)
|
||||
case "cac/paymentapi/auth/signin":
|
||||
return [200, { token: "it-cac-token", expiresIn: 86400 }];
|
||||
case "cac/paymentapi/PaymentInitiateRequest": {
|
||||
const id = `${Date.now()}00000`;
|
||||
orders.set(String(id), { orderId: String(id), paid: false });
|
||||
return [200, { status: true, message: "OTP sent", data: { id, otpRequired: true } }];
|
||||
}
|
||||
|
||||
default:
|
||||
// Unimplemented gateway paths answer a generic OK rather than 404: the
|
||||
// suite only drives CBE Birr / CAC end to end, and a 404 here would look
|
||||
// like a bug in the API rather than an unused stub. Add real shapes when
|
||||
// a scenario needs them.
|
||||
calls.push({ provider, path, unimplemented: true });
|
||||
return [200, { success: true, stub: true, path: `${provider}/${path}`, url }];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", async () => {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
let body = {};
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
body = { raw };
|
||||
}
|
||||
const send = (status, payload) => {
|
||||
const json = JSON.stringify(payload ?? {});
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(json),
|
||||
});
|
||||
res.end(json);
|
||||
};
|
||||
|
||||
const url = new URL(req.url, "http://mock");
|
||||
const path = url.pathname.replace(/^\/+/, "");
|
||||
|
||||
// --- control plane -----------------------------------------------------
|
||||
if (path.startsWith("__control")) {
|
||||
const [, action, arg] = path.split("/");
|
||||
if (action === "health") return send(200, { ok: true });
|
||||
if (action === "reset") {
|
||||
modes.clear();
|
||||
orders.clear();
|
||||
calls = [];
|
||||
return send(200, { ok: true });
|
||||
}
|
||||
if (action === "calls") {
|
||||
return send(200, { calls });
|
||||
}
|
||||
if (action === "provider" && req.method === "POST") {
|
||||
modes.set(arg, { mode: body.mode ?? "ok", remaining: body.times ?? null });
|
||||
console.log(`[gateway-mock] ${arg} → ${body.mode} (times=${body.times ?? "∞"})`);
|
||||
return send(200, { ok: true, provider: arg, mode: body.mode });
|
||||
}
|
||||
if (action === "settle" && req.method === "POST") {
|
||||
// Mark the order paid at the gateway WITHOUT notifying — the payment
|
||||
// API can then only learn about it by polling (reconciliation path).
|
||||
const order = orders.get(body.merchantOrderId);
|
||||
if (!order) return send(404, { error: "unknown merchantOrderId" });
|
||||
order.paid = true;
|
||||
return send(200, { ok: true });
|
||||
}
|
||||
if (action === "webhook" && req.method === "POST") {
|
||||
try {
|
||||
const result = await fireWebhook(body);
|
||||
console.log(
|
||||
`[gateway-mock] webhook ${body.merchantOrderId} ${body.status ?? "SUCCESS"} → ${result.status}`,
|
||||
);
|
||||
return send(200, { ok: true, delivered: result.status, body: result.body });
|
||||
} catch (err) {
|
||||
return send(500, { error: String(err) });
|
||||
}
|
||||
}
|
||||
return send(404, { error: `unknown control action ${action}` });
|
||||
}
|
||||
|
||||
// --- gateway routes ----------------------------------------------------
|
||||
const provider = path.split("/")[0];
|
||||
const rest = path.slice(provider.length + 1);
|
||||
calls.push({ provider, path: rest, method: req.method, body, at: Date.now() });
|
||||
|
||||
// CAC confirm carries the OTP; wrong code must fail the way the bank does.
|
||||
if (rest.startsWith("paymentapi/") && rest.includes("Confirm")) {
|
||||
const ok = String(body.otp ?? body.OTP ?? "") === CAC_OTP;
|
||||
return send(200, ok
|
||||
? { status: true, data: { id: body.id, status: "SUCCESS" } }
|
||||
: { status: false, message: "Invalid OTP" });
|
||||
}
|
||||
|
||||
const result = handleProvider(provider, rest, body, req.url);
|
||||
if (result === "hang") {
|
||||
console.log(`[gateway-mock] ${provider}/${rest} → hanging (forced timeout)`);
|
||||
return; // never answer; the caller's own timeout fires
|
||||
}
|
||||
send(result[0], result[1]);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log(`gateway-mock listening on ${PORT}`));
|
||||
21
integration/package.json
Normal file
21
integration/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@edr/freight-integration",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "API-level integration tests for the freight API against the real payment microservice",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"pg": "^8.13.0",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
173
integration/scripts/it.mjs
Normal file
173
integration/scripts/it.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freight integration-suite launcher.
|
||||
*
|
||||
* node integration/scripts/it.mjs <up|test|down|logs> [vitest args...]
|
||||
*
|
||||
* Overlays docker-compose.it.yaml on docker-compose.e2e.yaml: same freight
|
||||
* stack, but the payment microservice is real and only the bank gateways are
|
||||
* stubbed. Web/Cypress containers are never started — this suite is HTTP only.
|
||||
*
|
||||
* Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks
|
||||
* can be up at once; they are separate compose projects.
|
||||
*
|
||||
* No dependencies — plain Node spawning `docker compose` and `pnpm`.
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = resolve(itDir, "..");
|
||||
const composeBase = [
|
||||
"compose",
|
||||
"-f",
|
||||
join(repoRoot, "docker-compose.e2e.yaml"),
|
||||
"-f",
|
||||
join(repoRoot, "docker-compose.it.yaml"),
|
||||
];
|
||||
|
||||
/** Deliberately offset from the Cypress stack's defaults (3101/5533/9310…). */
|
||||
const PORTS = {
|
||||
E2E_API_PORT: 3111,
|
||||
E2E_DB_PORT: 5543,
|
||||
E2E_MINIO_PORT: 9320,
|
||||
E2E_MINIO_CONSOLE_PORT: 9321,
|
||||
// Unused here (no web containers) but referenced by the base file's build args.
|
||||
E2E_PORTAL_PORT: 5393,
|
||||
E2E_BACKOFFICE_PORT: 5394,
|
||||
IT_PAYMENT_PORT: 3113,
|
||||
IT_GATEWAY_PORT: 4600,
|
||||
IT_RABBIT_PORT: 5772,
|
||||
IT_RABBIT_UI_PORT: 15772,
|
||||
};
|
||||
|
||||
/** Everything the suite needs up — web + cypress are deliberately absent. */
|
||||
const SERVICES = [
|
||||
"postgres-freight-e2e",
|
||||
"minio-e2e",
|
||||
"minio-init-e2e",
|
||||
"freight-migration-e2e",
|
||||
"fayda-mock-e2e",
|
||||
"etrade-mock-e2e",
|
||||
// Still a base-stack dependency of freight-api-e2e (reconcile-before-expire
|
||||
// has its own client); cheap to run alongside the real payment API.
|
||||
"payment-mock-e2e",
|
||||
"gateway-mock-it",
|
||||
"rabbitmq-it",
|
||||
"payment-api-it",
|
||||
"freight-api-e2e",
|
||||
];
|
||||
|
||||
const RUNNING = SERVICES.filter(
|
||||
(s) => !["minio-init-e2e", "freight-migration-e2e"].includes(s),
|
||||
);
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`\nit: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function preflight() {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||
} catch {
|
||||
fail("docker is not running (or not installed) — start Docker and retry.");
|
||||
}
|
||||
if (!existsSync(join(repoRoot, ".npmrc"))) {
|
||||
fail(".npmrc missing at repo root — image builds need GitHub Packages auth for @tria-plc.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Throwaway RSA PEM — Telebirr PSS-signs every request object; the mock never
|
||||
* verifies it, but the provider refuses to build a request without a real key. */
|
||||
function fakeTelebirrPrivateKey() {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
}
|
||||
|
||||
/** Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 (see e2e.mjs — same reason). */
|
||||
function fakeFaydaPrivateKeyBase64() {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = privateKey.export({ format: "jwk" });
|
||||
Object.assign(jwk, { kty: "RSA", use: "sig", alg: "RS256", kid: "it-fayda-mock" });
|
||||
return Buffer.from(JSON.stringify(jwk)).toString("base64");
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])),
|
||||
IT_API_URL: `http://localhost:${PORTS.E2E_API_PORT}`,
|
||||
IT_PAYMENT_URL: `http://localhost:${PORTS.IT_PAYMENT_PORT}`,
|
||||
IT_GATEWAY_URL: `http://localhost:${PORTS.IT_GATEWAY_PORT}`,
|
||||
IT_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${PORTS.E2E_DB_PORT}/edr_freight_e2e`,
|
||||
FAYDA_PRIVATE_KEY_BASE64:
|
||||
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
|
||||
IT_TELEBIRR_PRIVATE_KEY:
|
||||
process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey(),
|
||||
};
|
||||
|
||||
function compose(args) {
|
||||
const { status } = spawnSync("docker", [...composeBase, ...args], { stdio: "inherit", env });
|
||||
return status ?? 1;
|
||||
}
|
||||
|
||||
function stackRunning() {
|
||||
try {
|
||||
const out = execFileSync("docker", [...composeBase, "ps", "--services", "--status", "running"], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const running = new Set(out.split("\n").filter(Boolean));
|
||||
return RUNNING.every((s) => running.has(s));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function up() {
|
||||
preflight();
|
||||
console.log(
|
||||
`it: starting stack — freight :${PORTS.E2E_API_PORT} payment :${PORTS.IT_PAYMENT_PORT} ` +
|
||||
`gateway :${PORTS.IT_GATEWAY_PORT} db :${PORTS.E2E_DB_PORT}`,
|
||||
);
|
||||
if (compose(["up", "-d", "--build", "--wait", ...SERVICES]) !== 0) {
|
||||
fail(
|
||||
"stack failed to become healthy. Inspect with:\n" +
|
||||
" node integration/scripts/it.mjs logs payment-api-it",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [cmd, ...rawExtra] = process.argv.slice(2);
|
||||
// `pnpm it:test -- src/foo.it.ts` hands us a literal "--" first. Forwarding it
|
||||
// makes vitest treat everything after it as CLI options and ignore the file
|
||||
// filter — the "one file" run silently becomes the whole suite.
|
||||
const extra = rawExtra[0] === "--" ? rawExtra.slice(1) : rawExtra;
|
||||
|
||||
switch (cmd) {
|
||||
case "up":
|
||||
up();
|
||||
break;
|
||||
case "test": {
|
||||
if (!stackRunning()) up();
|
||||
const { status } = spawnSync(
|
||||
"pnpm",
|
||||
["--filter", "@edr/freight-integration", "run", "test", ...extra],
|
||||
{ cwd: repoRoot, stdio: "inherit", env },
|
||||
);
|
||||
process.exit(status ?? 1);
|
||||
}
|
||||
case "logs":
|
||||
process.exit(compose(["logs", "--tail", "200", ...extra]));
|
||||
break;
|
||||
case "down":
|
||||
process.exit(compose(["down", "-v", "--remove-orphans"]));
|
||||
break;
|
||||
default:
|
||||
fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`);
|
||||
}
|
||||
34
integration/sql/seed-company-b.sql
Normal file
34
integration/sql/seed-company-b.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- Second tenant for the integration suite: user2@gmail.com gets its own ACTIVE
|
||||
-- company + approved importer profile, mirroring seed-company.sql (which only
|
||||
-- sets up user@gmail.com). Two real tenants are what make the cross-tenant
|
||||
-- isolation and the "two companies race for the same train" scenarios honest.
|
||||
-- Idempotent; TIN is the key.
|
||||
|
||||
INSERT INTO freight.companies
|
||||
(id, name, type, status, tin, fan_number, country, address, phone, email,
|
||||
nationality, kind, attributes)
|
||||
SELECT gen_random_uuid(), 'IT Freight Partners PLC', 'customer', 'active',
|
||||
'0102030406', '1234567890123457', 'Ethiopia', 'Adama, Ethiopia',
|
||||
'+251911000011', 'ops@it-partners.test', 'ethiopian', 'commercial',
|
||||
'{"contactPersonName":"IT Contact","contactPersonPhone":"+251911000012","generalManagerName":"IT GM","generalManagerEmail":"gm@it-partners.test","generalManagerPhone":"+251911000013"}'::jsonb
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030406');
|
||||
|
||||
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
|
||||
SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-IT-0002'
|
||||
FROM freight.companies c
|
||||
WHERE c.tin = '0102030406'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles p
|
||||
WHERE p.company_id = c.id AND p.type = 'importer'
|
||||
);
|
||||
|
||||
INSERT INTO freight.external_profiles
|
||||
(id, user_id, company_id, first_name, last_name, is_primary_contact,
|
||||
onboarding_step, onboarding_completed)
|
||||
SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User2', true, 'done', true
|
||||
FROM iam.users u
|
||||
JOIN freight.companies c ON c.tin = '0102030406'
|
||||
WHERE u.email = 'user2@gmail.com'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id
|
||||
);
|
||||
17
integration/sql/seed-customs-service-type.sql
Normal file
17
integration/sql/seed-customs-service-type.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- A service type whose bundle INCLUDES customs, for the priority-tier scenario
|
||||
-- (g1-s6-s8, S8). Idempotent.
|
||||
--
|
||||
-- The rule engine's CUSTOMS priority band only applies when the booking's
|
||||
-- service type has `includes_customs = true` (rule-engine.service.ts:251), and
|
||||
-- the corridor fixture ships exactly one service type — RAIL, which does not.
|
||||
-- Without this row the customs tier can never fire and "customs outranks plain"
|
||||
-- is untestable: both bookings score the same.
|
||||
--
|
||||
-- Contracts opt in with seedContract({ serviceTypeCode: 'RAIL_CUSTOMS' }).
|
||||
INSERT INTO freight.service_types
|
||||
(code, service_name, description, includes_customs, is_active, display_order)
|
||||
SELECT 'RAIL_CUSTOMS', 'Rail Transport + Customs Clearance',
|
||||
'IT fixture: the customs-bundled service tier', true, true, 2
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.service_types WHERE code = 'RAIL_CUSTOMS'
|
||||
);
|
||||
92
integration/src/authz.it.ts
Normal file
92
integration/src/authz.it.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Who is allowed to touch a payment. Cheap to run (no booking chain), and the
|
||||
* failures here are the expensive kind: a tenant reading another tenant's
|
||||
* invoice, or an unauthenticated caller marking one paid.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import {
|
||||
API,
|
||||
PAYMENT_API,
|
||||
api,
|
||||
closeDb,
|
||||
customerA,
|
||||
customerB,
|
||||
db,
|
||||
login,
|
||||
payment,
|
||||
} from "./client";
|
||||
|
||||
describe("payment authorization boundaries", () => {
|
||||
afterAll(closeDb);
|
||||
|
||||
it("hides one tenant's invoice from the other", async () => {
|
||||
const rows = await db<{ id: string; company_id: string }>(
|
||||
`SELECT i.id, i.company_id FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE c.tin = '0102030405' AND i.deleted_at IS NULL
|
||||
ORDER BY i.created_at DESC LIMIT 1`,
|
||||
);
|
||||
if (!rows[0]) return; // nothing billed yet in this run — payment files cover it
|
||||
const res = await api(customerB, "get", `/api/billing/my-invoices/${rows[0].id}`);
|
||||
expect([403, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("refuses to let one tenant pay the other's invoice", async () => {
|
||||
const rows = await db<{ id: string }>(
|
||||
`SELECT i.id FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE c.tin = '0102030405' AND i.status <> 'PAID' AND i.deleted_at IS NULL
|
||||
ORDER BY i.created_at DESC LIMIT 1`,
|
||||
);
|
||||
if (!rows[0]) return;
|
||||
const res = await api(customerB, "post", `/api/billing/my-invoices/${rows[0].id}/pay`, {
|
||||
method: "CBE_BIRR",
|
||||
platform: "web",
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("keeps a portal customer out of backoffice payment operations", async () => {
|
||||
const res = await api(customerA, "get", "/api/billing/invoices");
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("rejects a portal account on the backoffice login audience", async () => {
|
||||
const res = await login(customerA, "12345678", "backoffice");
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("requires the service token on freight's mark-paid callback", async () => {
|
||||
const body = {
|
||||
version: 1,
|
||||
eventId: "authz-probe",
|
||||
eventType: "payment.succeeded",
|
||||
occurredAt: new Date().toISOString(),
|
||||
service: "FREIGHT",
|
||||
intentId: "00000000-0000-0000-0000-000000000000",
|
||||
referenceType: "SHIPMENT",
|
||||
referenceId: "00000000-0000-0000-0000-000000000000",
|
||||
provider: "CBE_BIRR",
|
||||
amountMinor: 1,
|
||||
currency: "ETB",
|
||||
};
|
||||
const res = await request(API).post("/api/internal/payments/mark-paid").send(body);
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("requires the service token on the payment API's internal surface", async () => {
|
||||
const res = await request(PAYMENT_API).get("/payments/intents?service=FREIGHT");
|
||||
expect([400, 401, 403]).toContain(res.status);
|
||||
|
||||
// …and accepts it when present (400 = bad query, not an auth failure).
|
||||
const withToken = await payment("get", "/payments/intents?service=FREIGHT");
|
||||
expect([401, 403]).not.toContain(withToken.status);
|
||||
});
|
||||
|
||||
it("leaves the provider webhook surface public — trust is the signature", async () => {
|
||||
// A garbage payload must be acked, not 401'd: providers do not authenticate.
|
||||
const res = await request(PAYMENT_API).post("/webhooks/cbe-birr").send({ nonsense: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
180
integration/src/bulk-b1-priority-expiry-refill.it.ts
Normal file
180
integration/src/bulk-b1-priority-expiry-refill.it.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* BULK B1 — staff priority decides who rides; expiry refill promotes the
|
||||
* offered booking WHOLE.
|
||||
*
|
||||
* Three wheat bookings that cannot all fit a 54-wagon CW4 train:
|
||||
* BP1 1 960 T = 28 w (commercial giant)
|
||||
* BP2 1 400 T = 20 w (commercial)
|
||||
* BP3 700 T = 10 w (relief cargo — staff rank it FIRST)
|
||||
*
|
||||
* 58 wagons chase 54. With BP3 on top the batch reserves BP3 + BP1 whole
|
||||
* (38 w) and leaves BP2 a whole-wagon offer for the remaining 16. BP1 then
|
||||
* misses its pay window: its 28 wagons come back and the refill round must
|
||||
* promote BP2 WHOLE — superseding the 16-wagon offer.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { api, apiOk, closeDb, db, gateway, superAdmin } from "./client";
|
||||
import {
|
||||
bookBulkReady,
|
||||
allocatedWagons,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
extendPayWindow,
|
||||
expectWagonType,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollPartialOffer,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(40);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
/**
|
||||
* Booked in arrival order; the RANKING comes from a real priority rule.
|
||||
*
|
||||
* NOTE — the Cypress twin ranks these by writing `priority_score` directly.
|
||||
* That lever is dead for BULK: `recomputeBulkPriorities`
|
||||
* (booking-batch.service.ts) re-derives every bulk booking's score from the
|
||||
* rule engine when doc review closes, overwriting anything hand-written. So
|
||||
* this file configures the product's own lever instead — a WAGON priority band
|
||||
* that scores 1–10-wagon bookings above everything else, which is exactly how
|
||||
* staff would push relief cargo to the front.
|
||||
*/
|
||||
const BOOKINGS = [
|
||||
{ suffix: "BP1", tons: 1960, wagons: 28 }, // commercial giant
|
||||
{ suffix: "BP2", tons: 1400, wagons: 20 }, // does not fit whole → offered 16
|
||||
{ suffix: "BP3", tons: 700, wagons: 10 }, // relief cargo — ranked first by the rule
|
||||
];
|
||||
|
||||
describe("bulk b1: priority ordering and expiry refill", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
let priorityConfigId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
BOOKINGS.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
// Staff rank relief-sized cargo first: a WAGON band worth the maximum 50
|
||||
// points for 1–10 wagons. Ranges must be contiguous from 1, and this is
|
||||
// the first WAGON rule in the stack. Removed again in afterAll so the
|
||||
// other bulk files keep the default (unranked) engine.
|
||||
const cfg = await apiOk(superAdmin, "post", "/api/priority-configs", {
|
||||
type: "WAGON",
|
||||
label: "IT relief cargo 1-10 wagons",
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 10,
|
||||
scorePoints: 50,
|
||||
isActive: true,
|
||||
});
|
||||
priorityConfigId = (cfg.body?.data?.id ?? cfg.body?.id) as string;
|
||||
expect(priorityConfigId, "priority config created").toBeTruthy();
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
booking.set(
|
||||
b.suffix,
|
||||
await bookBulkReady({
|
||||
contractId: contracts.get(b.suffix)!,
|
||||
tons: b.tons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (priorityConfigId) {
|
||||
await api(superAdmin, "delete", `/api/priority-configs/${priorityConfigId}`);
|
||||
}
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
it("ranks the relief cargo first — it and the giant reserve whole, the third is offered 16", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
// The batch re-scored the pool from the rule: relief 50, the other two 0.
|
||||
const scores = await db<{ id: string; priority_score: string }>(
|
||||
`SELECT id, priority_score FROM freight.bookings WHERE id = ANY($1::uuid[])`,
|
||||
[[...booking.values()]],
|
||||
);
|
||||
const scoreOf = (suffix: string) =>
|
||||
Number(scores.find((r) => r.id === booking.get(suffix))?.priority_score ?? 0);
|
||||
expect(scoreOf("BP3"), "relief cargo outranks the commercial pair").toBeGreaterThan(
|
||||
Math.max(scoreOf("BP1"), scoreOf("BP2")),
|
||||
);
|
||||
|
||||
for (const suffix of ["BP3", "BP1"]) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
const offer = await pollPartialOffer(booking.get("BP2")!);
|
||||
expect(Number(offer.offered_wagons), "BP2 offered the 16-wagon leftover").toBe(16);
|
||||
});
|
||||
|
||||
it("the relief cargo pays and rides CW4; the giant misses its pay window", async () => {
|
||||
await extendPayWindow(scheduleId, [booking.get("BP3")!]);
|
||||
await payViaGateway(booking.get("BP3")!);
|
||||
await pollAllocations(booking.get("BP3")!, 10);
|
||||
await expectWagonType(booking.get("BP3")!, "CW4", 10);
|
||||
|
||||
await forceReservationExpiry(booking.get("BP1")!);
|
||||
await pollBookingStatus(booking.get("BP1")!, "EXPIRED", 40);
|
||||
});
|
||||
|
||||
it("the refill round re-selects the offered booking — but its 16-wagon offer still stands", async () => {
|
||||
await pollBookingStatus(booking.get("BP2")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
||||
await extendPayWindow(scheduleId, [booking.get("BP2")!]);
|
||||
await payViaGateway(booking.get("BP2")!);
|
||||
await pollAllocations(booking.get("BP2")!, 16);
|
||||
|
||||
const bp2 = await bookingRow(booking.get("BP2")!);
|
||||
|
||||
// FINDING — the scenario expects the refill to promote BP2 WHOLE (20 w)
|
||||
// into the 28 wagons the expired giant just freed, superseding its
|
||||
// 16-wagon offer. It does not: the refill flips the booking back to
|
||||
// reserved but never issues a replacement offer, and `applySplit` then
|
||||
// applies the ONLY open offer — the stale 16-wagon one
|
||||
// (booking-split.service.ts: an offer is superseded only when a NEW offer
|
||||
// is created). The customer ships 16 of 20 wagons with room to spare.
|
||||
// This test pins today's behaviour so the fix flips it loudly.
|
||||
expect(Number(bp2.wagons_required), "rides the stale offer, not the freed 20").toBe(16);
|
||||
expect(bp2.is_split, "split against the stale offer").toBe(true);
|
||||
|
||||
const [offers] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[booking.get("BP2")!],
|
||||
);
|
||||
expect(Number(offers.n), "no replacement offer was issued after the refill").toBe(1);
|
||||
|
||||
// …and the train really did have the room: 10 (relief) + 16 = 26 of 54.
|
||||
expect(await allocatedWagons(scheduleId), "28 wagons left unused").toBe(26);
|
||||
});
|
||||
});
|
||||
141
integration/src/bulk-b2-per-item-floor.it.ts
Normal file
141
integration/src/bulk-b2-per-item-floor.it.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* BULK B2 — break-bulk PER_ITEM wagon math on the CW4 fleet (70 T capacity).
|
||||
*
|
||||
* Cargo from seed-bulk-items.sql:
|
||||
* E2E_IMP_AUTO automobiles — items-per-wagon floor of 4
|
||||
* E2E_IMP_MACHINE machinery — no floor, tonnage-only fallback
|
||||
*
|
||||
* Three verdicts of the wagon-demand rule, end to end:
|
||||
* BA1 16 autos @2.5 T (40 T) → the floor binds: 4 wagons (tonnage said 1)
|
||||
* BA2 12 machines @20 T (240 T) → tonnage binds: 3/wagon → 4 wagons
|
||||
* BA3 216 autos (540 T) → exactly 54 wagons: FULL from one booking, no split
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
bookBulkItemsReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
extendPayWindow,
|
||||
expectWagonType,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(41);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const FULL_DEPARTURE = departureAt(42);
|
||||
const FULL_DAY = eatDayStr(FULL_DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
describe("bulk b2: PER_ITEM floor vs tonnage wagon math", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let contracts: Map<string, string>;
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
await resetCorridorDay(FULL_DEPARTURE);
|
||||
|
||||
contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
["BA1", "BA2", "BA3"].map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
booking.set(
|
||||
"BA1",
|
||||
await bookBulkItemsReady({
|
||||
contractId: contracts.get("BA1")!,
|
||||
cargoCode: "E2E_IMP_AUTO",
|
||||
items: 16,
|
||||
tons: 40,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
booking.set(
|
||||
"BA2",
|
||||
await bookBulkItemsReady({
|
||||
contractId: contracts.get("BA2")!,
|
||||
cargoCode: "E2E_IMP_MACHINE",
|
||||
items: 12,
|
||||
tons: 240,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("the 4-per-wagon floor binds for 16 autos → 4 CW4 wagons, not 1", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
for (const suffix of ["BA1", "BA2"]) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
|
||||
await extendPayWindow(scheduleId, [booking.get("BA1")!, booking.get("BA2")!]);
|
||||
await payViaGateway(booking.get("BA1")!);
|
||||
await pollAllocations(booking.get("BA1")!, 4);
|
||||
await expectWagonType(booking.get("BA1")!, "CW4", 4);
|
||||
});
|
||||
|
||||
it("machinery has no floor — 12 items @20 T take 4 wagons on tonnage alone", async () => {
|
||||
await payViaGateway(booking.get("BA2")!);
|
||||
await pollAllocations(booking.get("BA2")!, 4);
|
||||
await expectWagonType(booking.get("BA2")!, "CW4", 4);
|
||||
});
|
||||
|
||||
it("216 autos = exactly 54 wagons: FULL from one break-bulk booking, no split", async () => {
|
||||
const fullSchedule = await createSchedule({
|
||||
departure: FULL_DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
|
||||
});
|
||||
await forceWindowOpen(fullSchedule.id, 45);
|
||||
|
||||
const ba3 = await bookBulkItemsReady({
|
||||
contractId: contracts.get("BA3")!,
|
||||
cargoCode: "E2E_IMP_AUTO",
|
||||
items: 216,
|
||||
tons: 540,
|
||||
scheduledDate: FULL_DAY,
|
||||
});
|
||||
await closeBookingWindow(fullSchedule.id);
|
||||
await completeDocReview(fullSchedule.id);
|
||||
await pollBookingStatus(ba3, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
|
||||
await payViaGateway(ba3);
|
||||
await pollAllocations(ba3, 54);
|
||||
expect((await bookingRow(ba3)).is_split, "BA3 rides whole, not split").not.toBe(true);
|
||||
|
||||
await endPaymentPhase(fullSchedule.id);
|
||||
await pollWindow(
|
||||
fullSchedule.id,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL + DONE",
|
||||
);
|
||||
}, 900_000);
|
||||
});
|
||||
173
integration/src/bulk-b3-per-item-giant.it.ts
Normal file
173
integration/src/bulk-b3-per-item-giant.it.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* BULK B3 — PER_ITEM giant split and line quantities.
|
||||
*
|
||||
* The scenario: 240 automobiles (600 T) on a 54-wagon CW4 train. The
|
||||
* 4-per-wagon floor needs 60 wagons, so the batch should offer the whole
|
||||
* consist (216 autos / 54 wagons), the settlement should apply the split, and
|
||||
* the 24-auto remainder should have to be rebooked exactly. Plus two field
|
||||
* checks: hazardousQuantity above the line count clamps, reeferQuantity is
|
||||
* stored.
|
||||
*
|
||||
* WHAT ACTUALLY HAPPENS — two defects this file pins:
|
||||
*
|
||||
* 1. PER_ITEM bookings never get a partial offer. `sizeOffer`
|
||||
* (booking-split.service.ts) sizes a bulk offer by WEIGHT off
|
||||
* `cargoTotalWeightVgm` — but for PER_ITEM cargo that column holds the
|
||||
* ITEM COUNT (240), not tonnage (600, kept in `bulk_total_weight_tons`).
|
||||
* 240 "tons" fits 54 wagons, so no offer is made; the booking is reserved
|
||||
* without a wagon count, allocates nothing, and silently expires with the
|
||||
* day. The 24-auto remainder step therefore cannot happen at all.
|
||||
*
|
||||
* 2. The contract booking path DROPS per-line `hazardousQuantity` /
|
||||
* `reeferQuantity` for bulk. Only the direct booking path
|
||||
* (`POST /api/bookings`, bookings.service.ts) maps them onto
|
||||
* `bulk_hazardous_quantity` / `bulk_reefer_quantity` — and only that path
|
||||
* clamps them to the cargo amount.
|
||||
*
|
||||
* Both are asserted as they behave today, so a fix fails here loudly.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway } from "./client";
|
||||
import {
|
||||
bookBulkItems,
|
||||
bookBulkItemsReady,
|
||||
bookingFor,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollPartialOffer,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const GIANT_DEPARTURE = departureAt(43);
|
||||
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
||||
const REMAINDER_DEPARTURE = departureAt(44);
|
||||
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
describe("bulk b3: per-item giant offer and line quantities", () => {
|
||||
let contracts: Map<string, string>;
|
||||
let giantScheduleId: string;
|
||||
let bg1: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(GIANT_DEPARTURE);
|
||||
await resetCorridorDay(REMAINDER_DEPARTURE);
|
||||
|
||||
contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
["BG1", "BQ1", "BQ2"].map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||
);
|
||||
giantScheduleId = (
|
||||
await createSchedule({
|
||||
departure: GIANT_DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(giantScheduleId, 45);
|
||||
|
||||
bg1 = await bookBulkItemsReady({
|
||||
contractId: contracts.get("BG1")!,
|
||||
cargoCode: "E2E_IMP_AUTO",
|
||||
items: 240,
|
||||
tons: 600,
|
||||
scheduledDate: GIANT_DAY,
|
||||
});
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("a 240-auto booking (60 wagons' worth) gets NO partial offer — it is sized as 240 tons", async () => {
|
||||
await closeBookingWindow(giantScheduleId);
|
||||
await completeDocReview(giantScheduleId);
|
||||
await pollBookingStatus(bg1, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
|
||||
// DEFECT 1 (see header): the split sizer reads the item count as tonnage,
|
||||
// so a booking needing 60 wagons looks like it needs 4 and no offer opens.
|
||||
const offers = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[bg1],
|
||||
);
|
||||
expect(Number(offers[0].n), "no partial offer for the per-item giant").toBe(0);
|
||||
|
||||
const row = await bookingRow(bg1);
|
||||
expect(Number(row.cargo_total_weight_vgm), "cargoTotalWeightVgm holds ITEMS").toBe(240);
|
||||
expect(row.wagons_required, "reserved without a wagon count").toBeNull();
|
||||
|
||||
// Nothing is allocated: the reservation cannot be honoured on a 54-wagon
|
||||
// train, and no offer exists to shrink it.
|
||||
const [alloc] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[bg1],
|
||||
);
|
||||
expect(Number(alloc.n), "no wagons allocated").toBe(0);
|
||||
}, 900_000);
|
||||
|
||||
it.skip("the 24-auto outstanding must be rebooked EXACTLY on the later train", async () => {
|
||||
// Unreachable while defect 1 stands: no split ever applies, so there is no
|
||||
// outstanding remainder and the contract still carries a live booking
|
||||
// (rebooking answers 409 "already has an active booking"). Un-skip with the
|
||||
// fix to sizeOffer.
|
||||
});
|
||||
|
||||
it("the contract path DROPS a per-line hazardousQuantity for bulk", async () => {
|
||||
const res = await bookBulkItems({
|
||||
contractId: contracts.get("BQ1")!,
|
||||
cargoCode: "E2E_IMP_AUTO",
|
||||
items: 10,
|
||||
tons: 25,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
hazardousQuantity: 12,
|
||||
});
|
||||
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||
|
||||
const booking = await bookingFor(contracts.get("BQ1")!);
|
||||
const [row] = await db<{ bulk_hazardous_quantity: string }>(
|
||||
`SELECT bulk_hazardous_quantity FROM freight.bookings WHERE id = $1`,
|
||||
[booking.id],
|
||||
);
|
||||
// DEFECT 2 (see header). The scenario expects the 12 to be CLAMPED to the
|
||||
// 10-item line and stored; the contract path stores nothing at all, so the
|
||||
// hazmat surcharge never fires for a contract booking. The direct booking
|
||||
// path does clamp (clampToCargo, bookings.service.ts) — it is the mapping
|
||||
// in contract-booking.service.ts that is missing.
|
||||
expect(Number(row.bulk_hazardous_quantity), "hazmat dropped, not clamped").toBe(0);
|
||||
});
|
||||
|
||||
it("the contract path DROPS a per-line reeferQuantity for bulk", async () => {
|
||||
const res = await bookBulkItems({
|
||||
contractId: contracts.get("BQ2")!,
|
||||
cargoCode: "E2E_IMP_AUTO",
|
||||
items: 8,
|
||||
tons: 20,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
reeferQuantity: 3,
|
||||
});
|
||||
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||
|
||||
const booking = await bookingFor(contracts.get("BQ2")!);
|
||||
const [row] = await db<{ bulk_reefer_quantity: string }>(
|
||||
`SELECT bulk_reefer_quantity FROM freight.bookings WHERE id = $1`,
|
||||
[booking.id],
|
||||
);
|
||||
expect(Number(row.bulk_reefer_quantity), "reefer dropped").toBe(0);
|
||||
});
|
||||
});
|
||||
182
integration/src/bulk-export-fcfs-space.it.ts
Normal file
182
integration/src/bulk-export-fcfs-space.it.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* BULK EXPORT — FCFS capacity truth.
|
||||
*
|
||||
* Day 1: three bookings (1 400 + 1 400 + 980 T = 54 wagons) accept first and
|
||||
* hold the train BEFORE paying; three late 700 T exporters are rejected at
|
||||
* submission by the whole-train space gate. The three pay → FULL.
|
||||
*
|
||||
* Day 2: whole-or-nothing — a 4 060 T giant (58 wagons) is rejected against
|
||||
* an empty train (export never splits); rebooked at exactly 3 780 T it takes
|
||||
* the whole consist alone; a 70 T afterthought bounces off FULL.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
acceptExport,
|
||||
allocatedWagons,
|
||||
bookBulk,
|
||||
bookBulkReady,
|
||||
expectDayRefused,
|
||||
bookingFor,
|
||||
bookingRow,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
extendPayWindow,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(31);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const GIANT_DEPARTURE = departureAt(32);
|
||||
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const FIRST = [
|
||||
{ suffix: "YA", tons: 1400, wagons: 20 },
|
||||
{ suffix: "YB", tons: 1400, wagons: 20 },
|
||||
{ suffix: "YC", tons: 980, wagons: 14 },
|
||||
];
|
||||
const LATE = ["YL1", "YL2", "YL3"];
|
||||
|
||||
describe("bulk export FCFS: reservations hold capacity, whole-or-nothing gate", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let contracts: Map<string, string>;
|
||||
let scheduleId: string;
|
||||
let giantScheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureExportRoute();
|
||||
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||
await resetCorridorDay(GIANT_DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||
|
||||
contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
[...FIRST.map((b) => b.suffix), ...LATE, "YGBIG", "YG", "YS"].map((suffix) => ({
|
||||
suffix,
|
||||
freight: "BULK" as const,
|
||||
direction: "EXPORT" as const,
|
||||
})),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
for (const b of FIRST) {
|
||||
booking.set(
|
||||
b.suffix,
|
||||
await bookBulkReady({
|
||||
contractId: contracts.get(b.suffix)!,
|
||||
tons: b.tons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("holds 54 wagons on accept — before any payment", async () => {
|
||||
for (const b of FIRST) {
|
||||
const row = await bookingRow(booking.get(b.suffix)!);
|
||||
expect(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], `${b.suffix} reserved`).toContain(
|
||||
row.status,
|
||||
);
|
||||
expect(row.train_schedule_id, `${b.suffix} pinned to the train`).toBe(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects three late exporters at submission — the space gate reports no room", async () => {
|
||||
for (const suffix of LATE) {
|
||||
const res = await expectDayRefused({
|
||||
contractId: contracts.get(suffix)!,
|
||||
tons: 700,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
expect(res.status, `${suffix} rejected`).toBeGreaterThanOrEqual(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("the three reserved pay — 54/54 allocated and the window flips FULL", async () => {
|
||||
await extendPayWindow(scheduleId, FIRST.map((b) => booking.get(b.suffix)!));
|
||||
for (const b of FIRST) {
|
||||
await payViaGateway(booking.get(b.suffix)!);
|
||||
await pollAllocations(booking.get(b.suffix)!, b.wagons);
|
||||
}
|
||||
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL");
|
||||
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||
});
|
||||
|
||||
it("whole-or-nothing: a 4 060 T giant is rejected against the empty train", async () => {
|
||||
giantScheduleId = (
|
||||
await createSchedule({
|
||||
departure: GIANT_DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(giantScheduleId, 60);
|
||||
|
||||
// 58 wagons on a 54-wagon consist — export never splits.
|
||||
// Its own tenant: a refused probe still leaves a live booking on the
|
||||
// one-time contract, which would block the 3 780 T rebooking below.
|
||||
const res = await expectDayRefused({
|
||||
contractId: contracts.get("YGBIG")!,
|
||||
tons: 4060,
|
||||
scheduledDate: GIANT_DAY,
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||
});
|
||||
|
||||
it("rebooked at exactly 3 780 T the giant takes the whole train alone", async () => {
|
||||
const yg = await bookBulkReady({
|
||||
contractId: contracts.get("YG")!,
|
||||
tons: 3780,
|
||||
scheduledDate: GIANT_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
await payViaGateway(yg);
|
||||
await pollAllocations(yg, 54);
|
||||
|
||||
await pollWindow(
|
||||
giantScheduleId,
|
||||
(s) => s.booking_window_status === "FULL",
|
||||
"giant train FULL from one booking",
|
||||
);
|
||||
expect((await bookingRow(yg)).train_schedule_id, "giant rides its train").toBe(giantScheduleId);
|
||||
expect(await allocatedWagons(giantScheduleId), "54 wagons allocated").toBe(54);
|
||||
});
|
||||
|
||||
it("a 70 T afterthought bounces off the FULL train", async () => {
|
||||
const res = await expectDayRefused({
|
||||
contractId: contracts.get("YS")!,
|
||||
tons: 70,
|
||||
scheduledDate: GIANT_DAY,
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||
});
|
||||
});
|
||||
169
integration/src/bulk-export-full-train.it.ts
Normal file
169
integration/src/bulk-export-full-train.it.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* BULK EXPORT — six wheat bookings fill the 54-wagon CW4 train on the reversed
|
||||
* corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD → DJIB_PORT, inside
|
||||
* the ONE FCFS export window, then the full life of the train to Djibouti Port
|
||||
* and the export customs tail.
|
||||
*
|
||||
* Export is FCFS: the staff accept IS the reservation — there is no batch — and
|
||||
* a pay deadline may never outlive the window close. Both are asserted here.
|
||||
*
|
||||
* 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T:
|
||||
* XBF1 customs USD 560 T = 8 w · XBF2 customs ETB 420 T = 6 w
|
||||
* XBF3 self ETB 420 T = 6 w · XBF4 self ETB 420 T = 6 w
|
||||
* XBF5 customs USD 1 540 T = 22 w · XBF6 self USD 420 T = 6 w
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
allocatedWagons,
|
||||
bookBulkReady,
|
||||
bookingRow,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
dispatchSchedule,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
extendPayWindow,
|
||||
expectMilestoneDone,
|
||||
finalizeSchedule,
|
||||
forceWindowOpen,
|
||||
gatePassGranted,
|
||||
invoiceForBooking,
|
||||
milestoneCount,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
runCorridor,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
closeT1,
|
||||
completeMilestone,
|
||||
uploadTransportDocument,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(30);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const BOOKINGS = [
|
||||
{ suffix: "XBF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 },
|
||||
{ suffix: "XBF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "XBF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "XBF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "XBF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 },
|
||||
{ suffix: "XBF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 },
|
||||
];
|
||||
const CUSTOMS = BOOKINGS.filter((b) => b.customs);
|
||||
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs);
|
||||
|
||||
describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureExportRoute();
|
||||
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
BOOKINGS.map((b) => ({
|
||||
suffix: b.suffix,
|
||||
currency: b.currency,
|
||||
customs: b.customs,
|
||||
freight: "BULK" as const,
|
||||
direction: "EXPORT" as const,
|
||||
})),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
booking.set(
|
||||
b.suffix,
|
||||
await bookBulkReady({
|
||||
contractId: contracts.get(b.suffix)!,
|
||||
tons: b.tons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
customs: b.customs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("each accept reserved immediately, with a deadline clamped to the window close", async () => {
|
||||
const schedule = await scheduleRow(scheduleId);
|
||||
const closesAt = new Date(String(schedule.window_closes_at)).getTime();
|
||||
for (const b of BOOKINGS) {
|
||||
const row = await bookingRow(booking.get(b.suffix)!);
|
||||
expect(row.payment_deadline, `${b.suffix} pay deadline`).toBeTruthy();
|
||||
expect(
|
||||
new Date(row.payment_deadline!).getTime(),
|
||||
`${b.suffix} deadline never outlives the window close`,
|
||||
).toBeLessThanOrEqual(closesAt);
|
||||
const invoice = await invoiceForBooking(booking.get(b.suffix)!);
|
||||
expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency);
|
||||
}
|
||||
});
|
||||
|
||||
it("all six pay — 54/54 allocated, the export window flips FULL, staff finalize", async () => {
|
||||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||
for (const b of BOOKINGS) {
|
||||
await payViaGateway(booking.get(b.suffix)!);
|
||||
await pollAllocations(booking.get(b.suffix)!, b.wagons);
|
||||
}
|
||||
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL");
|
||||
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||
|
||||
await finalizeSchedule(scheduleId);
|
||||
await pollWindow(scheduleId, (s) => s.status === "SCHEDULED", "SCHEDULED");
|
||||
});
|
||||
|
||||
it("gate pass + transport documents, then the train runs the corridor to the port", async () => {
|
||||
await gatePassGranted(scheduleId);
|
||||
for (const b of CUSTOMS) {
|
||||
const res = await uploadTransportDocument(booking.get(b.suffix)!);
|
||||
expect(res.status, `${b.suffix} transport document`).toBeLessThanOrEqual(201);
|
||||
}
|
||||
|
||||
await dispatchSchedule(scheduleId);
|
||||
await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED");
|
||||
await runCorridor(scheduleId);
|
||||
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20);
|
||||
});
|
||||
|
||||
it("GL Djibouti closes the export tail on every customs booking", async () => {
|
||||
for (const b of CUSTOMS) {
|
||||
const id = booking.get(b.suffix)!;
|
||||
await closeT1(id);
|
||||
await completeMilestone(id, "OFFLOADED");
|
||||
await expectMilestoneDone(id, "T1_CLOSED");
|
||||
await expectMilestoneDone(id, "OFFLOADED");
|
||||
}
|
||||
});
|
||||
|
||||
it("the self-clearing bookings arrived clean — no customs tail", async () => {
|
||||
for (const b of SELF_CLEAR) {
|
||||
const id = booking.get(b.suffix)!;
|
||||
expect((await bookingRow(id)).status, `${b.suffix} final status`).toBe("ARRIVED");
|
||||
expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
183
integration/src/bulk-export-matrix.it.ts
Normal file
183
integration/src/bulk-export-matrix.it.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* BULK EXPORT edge matrix (reversed corridor):
|
||||
* a) a booking on a day with no open window is rejected
|
||||
* b) mid-route boarding (DIRE_DAWA → port) shares the train with a KALITY
|
||||
* through-booking
|
||||
* c) directional FULL: the border edges are committed, so the window flips
|
||||
* FULL while the home leg still has free wagons
|
||||
* d) a dateless DOMESTIC ride-along boards the FULL train's free home leg —
|
||||
* its pay window is clamped to the export close, it pays and links, and
|
||||
* the window stays FULL
|
||||
* e) a same-day sibling export train keeps its own independent window
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway, poll } from "./client";
|
||||
import {
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
acceptIntercityOnto,
|
||||
acceptOperation,
|
||||
allocatedWagons,
|
||||
bookBulk,
|
||||
bookBulkReady,
|
||||
expectDayRefused,
|
||||
bookingFor,
|
||||
bookingRow,
|
||||
clearIntercityBooking,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
extendPayWindow,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(35);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const NO_WINDOW_DAY = eatDayStr(departureAt(39)); // no schedule exists there
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
describe("bulk export matrix: sub-corridor, directional FULL, ride-along, own windows", () => {
|
||||
let contracts: Map<string, string>;
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureExportRoute();
|
||||
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||
|
||||
contracts = await seedTenantContracts(STAMP, [
|
||||
{ suffix: "YM1", freight: "BULK", direction: "EXPORT" },
|
||||
{ suffix: "YMNW", freight: "BULK", direction: "EXPORT" },
|
||||
{ suffix: "YMSUB", freight: "BULK", direction: "EXPORT", originCode: "DIRE_DAWA", destCode: EXP_DEST },
|
||||
{ suffix: "YMIC", freight: "BULK", direction: "DOMESTIC", originCode: EXP_ORIGIN, destCode: "MOJO" },
|
||||
]);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 90);
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("rejects a booking on a day with no open window", async () => {
|
||||
const res = await expectDayRefused({
|
||||
contractId: contracts.get("YMNW")!,
|
||||
tons: 140,
|
||||
scheduledDate: NO_WINDOW_DAY,
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
|
||||
});
|
||||
|
||||
it("a through booking and a mid-route boarding commit the border edge", async () => {
|
||||
const through = await bookBulkReady({
|
||||
contractId: contracts.get("YM1")!,
|
||||
tons: 2800,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
const sub = await bookBulkReady({
|
||||
contractId: contracts.get("YMSUB")!,
|
||||
tons: 980,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
|
||||
await extendPayWindow(scheduleId, [through, sub]);
|
||||
await payViaGateway(through);
|
||||
await pollAllocations(through, 40);
|
||||
await payViaGateway(sub);
|
||||
await pollAllocations(sub, 14);
|
||||
|
||||
expect((await bookingRow(through)).train_schedule_id).toBe(scheduleId);
|
||||
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
|
||||
|
||||
// 40 + 14 = 54 wagons committed on the border edge (…→ DJIB_PORT), yet the
|
||||
// home leg (KALITY → DIRE_DAWA) still has 14 free — and the window stays
|
||||
// OPEN on that free leg. NOTE: the older Cypress twin asserts FULL here;
|
||||
// the live engine is leg-granular instead, which is why the ride-along
|
||||
// below can still board. Assert the occupancy invariant, not the flag.
|
||||
expect(await allocatedWagons(scheduleId), "border edge committed at 54").toBe(54);
|
||||
expect(
|
||||
(await scheduleRow(scheduleId)).booking_window_status,
|
||||
"window stays open on the free home leg",
|
||||
).toBe("OPEN");
|
||||
}, 1_800_000);
|
||||
|
||||
it("a ride-along boards the train's free home leg — clamped, paid, linked", async () => {
|
||||
const res = await bookBulk({ contractId: contracts.get("YMIC")!, tons: 140 });
|
||||
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||
const ic = (await bookingFor(contracts.get("YMIC")!)).id;
|
||||
await clearIntercityBooking(ic);
|
||||
|
||||
await acceptIntercityOnto(scheduleId, ic);
|
||||
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
|
||||
const schedule = await scheduleRow(scheduleId);
|
||||
expect(
|
||||
new Date((await bookingRow(ic)).payment_deadline!).getTime(),
|
||||
"ride-along deadline clamped to the export close",
|
||||
).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime());
|
||||
|
||||
await payViaGateway(ic);
|
||||
// A paid ride-along is unpinned back to the pool; staff place it again.
|
||||
await acceptIntercityOnto(scheduleId, ic);
|
||||
await poll(
|
||||
"ride-along linked to the export train",
|
||||
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
|
||||
[ic],
|
||||
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
|
||||
{ attempts: 20 },
|
||||
);
|
||||
// The ride-along rides the home leg, so the border edge is untouched.
|
||||
expect(await allocatedWagons(scheduleId), "border edge still 54").toBe(54);
|
||||
}, 900_000);
|
||||
|
||||
it("a same-day sibling export train keeps its own window", async () => {
|
||||
const sibling = new Date(DEPARTURE.getTime() + 90 * 60_000);
|
||||
await createSchedule({
|
||||
departure: sibling,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
});
|
||||
const anchor = await scheduleRow(scheduleId);
|
||||
const [row] = await db<{ id: string; window_phase: string; window_closes_at: string }>(
|
||||
`SELECT ts.id, ts.window_phase, ts.window_closes_at
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||||
WHERE ts.deleted_at IS NULL AND ts.id <> $3
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $4::timestamptz))) < 7200
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
[EXP_ORIGIN, EXP_DEST, scheduleId, DEPARTURE.toISOString()],
|
||||
);
|
||||
expect(row, "sibling export schedule").toBeTruthy();
|
||||
expect(row.window_phase, "own fresh window").toBe("PRE_WINDOW");
|
||||
expect(
|
||||
new Date(row.window_closes_at).getTime(),
|
||||
"own close, anchored to its own departure",
|
||||
).not.toBe(new Date(String(anchor.window_closes_at)).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
// `acceptOperation` is imported for symmetry with the import matrix; the export
|
||||
// ride-along is accepted onto the train instead.
|
||||
void acceptOperation;
|
||||
196
integration/src/bulk-export-pay-or-lose.it.ts
Normal file
196
integration/src/bulk-export-pay-or-lose.it.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* BULK EXPORT — pay or lose the seat.
|
||||
*
|
||||
* Day 1: ZA + ZB reserve and pay 40 wagons. ZC reserves the last 14 (980 T)
|
||||
* and never pays; while that hold lives a late booking is rejected for
|
||||
* space. ZC expires → the late customer immediately books the freed 980 T
|
||||
* and pays.
|
||||
*
|
||||
* Day 2: five reservations fill the train, only three pay. The window close
|
||||
* passes → phase DONE, the two unpaid expire, and export never reopens
|
||||
* (the cycle counter stays 1 — unlike import, which reopens).
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway } from "./client";
|
||||
import {
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
bookBulk,
|
||||
bookBulkReady,
|
||||
expectDayRefused,
|
||||
bookingRow,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
extendPayWindow,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(33);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const CLOSE_DEPARTURE = departureAt(34);
|
||||
const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const CLOSERS = [
|
||||
{ suffix: "ZQA", tons: 840, wagons: 12, pays: true },
|
||||
{ suffix: "ZQB", tons: 840, wagons: 12, pays: true },
|
||||
{ suffix: "ZQC", tons: 840, wagons: 12, pays: true },
|
||||
{ suffix: "ZQD", tons: 840, wagons: 12, pays: false },
|
||||
{ suffix: "ZQE", tons: 420, wagons: 6, pays: false },
|
||||
];
|
||||
|
||||
describe("bulk export pay-or-lose: expiry frees space; close expires the unpaid", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let contracts: Map<string, string>;
|
||||
let scheduleId: string;
|
||||
let closeScheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureExportRoute();
|
||||
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||
await resetCorridorDay(CLOSE_DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||
|
||||
contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
["ZA", "ZB", "ZC", "ZD", "ZDLATE", ...CLOSERS.map((c) => c.suffix)].map((suffix) => ({
|
||||
suffix,
|
||||
freight: "BULK" as const,
|
||||
direction: "EXPORT" as const,
|
||||
})),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("ZA and ZB reserve and pay 40 wagons; ZC holds the last 14 unpaid", async () => {
|
||||
for (const [suffix, wagons] of [
|
||||
["ZA", 20],
|
||||
["ZB", 20],
|
||||
] as const) {
|
||||
const id = await bookBulkReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
tons: 1400,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
booking.set(suffix, id);
|
||||
await payViaGateway(id);
|
||||
await pollAllocations(id, wagons);
|
||||
}
|
||||
|
||||
const zc = await bookBulkReady({
|
||||
contractId: contracts.get("ZC")!,
|
||||
tons: 980,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
booking.set("ZC", zc);
|
||||
const schedule = await scheduleRow(scheduleId);
|
||||
expect(
|
||||
new Date((await bookingRow(zc)).payment_deadline!).getTime(),
|
||||
"ZC deadline clamped to the window close",
|
||||
).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime());
|
||||
}, 900_000);
|
||||
|
||||
it("a late exporter is rejected while ZC's unpaid reservation holds the space", async () => {
|
||||
const res = await expectDayRefused({
|
||||
contractId: contracts.get("ZDLATE")!,
|
||||
tons: 980,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||
});
|
||||
|
||||
it("ZC misses its window — ZD immediately books the freed 980 T and pays", async () => {
|
||||
await forceReservationExpiry(booking.get("ZC")!);
|
||||
const zd = await bookBulkReady({
|
||||
contractId: contracts.get("ZD")!,
|
||||
tons: 980,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
booking.set("ZD", zd);
|
||||
await payViaGateway(zd);
|
||||
await pollAllocations(zd, 14);
|
||||
expect((await bookingRow(zd)).train_schedule_id, "ZD took ZC's seat").toBe(scheduleId);
|
||||
}, 900_000);
|
||||
|
||||
it("window-close day: five reservations, three payments", async () => {
|
||||
closeScheduleId = (
|
||||
await createSchedule({
|
||||
departure: CLOSE_DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(closeScheduleId, 60);
|
||||
|
||||
for (const c of CLOSERS) {
|
||||
booking.set(
|
||||
c.suffix,
|
||||
await bookBulkReady({
|
||||
contractId: contracts.get(c.suffix)!,
|
||||
tons: c.tons,
|
||||
scheduledDate: CLOSE_DAY,
|
||||
mode: "export",
|
||||
}),
|
||||
);
|
||||
}
|
||||
await extendPayWindow(
|
||||
closeScheduleId,
|
||||
CLOSERS.filter((x) => x.pays).map((c) => booking.get(c.suffix)!),
|
||||
);
|
||||
for (const c of CLOSERS.filter((x) => x.pays)) {
|
||||
await payViaGateway(booking.get(c.suffix)!);
|
||||
await pollAllocations(booking.get(c.suffix)!, c.wagons);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
it("the window CLOSES — phase DONE, the unpaid expire, and export never reopens", async () => {
|
||||
await db(
|
||||
`UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second'
|
||||
WHERE id = $1 AND window_phase = 'OPEN'`,
|
||||
[closeScheduleId],
|
||||
);
|
||||
await pollWindow(
|
||||
closeScheduleId,
|
||||
(s) => s.window_phase === "DONE" && Number(s.booking_cycle_no) === 1,
|
||||
"DONE, no reopen",
|
||||
);
|
||||
|
||||
// A forced close needs the matching deadline clamp on the unpaid pair.
|
||||
for (const c of CLOSERS.filter((x) => !x.pays)) {
|
||||
await forceReservationExpiry(booking.get(c.suffix)!);
|
||||
await pollBookingStatus(booking.get(c.suffix)!, "EXPIRED", 40);
|
||||
}
|
||||
for (const c of CLOSERS.filter((x) => x.pays)) {
|
||||
expect((await bookingRow(booking.get(c.suffix)!)).status, `${c.suffix} rides`).toBe("PAID");
|
||||
}
|
||||
}, 900_000);
|
||||
});
|
||||
185
integration/src/bulk-import-full-train.it.ts
Normal file
185
integration/src/bulk-import-full-train.it.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* BULK IMPORT — six wheat bookings fill the 54-wagon CW4 train on the long
|
||||
* corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in
|
||||
* the FIRST window, then the whole life of the train: payment through the real
|
||||
* gateway, allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint
|
||||
* movement, arrival, and the post-arrival customs tail.
|
||||
*
|
||||
* 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T:
|
||||
* BF1 customs + USD 560 T = 8 w
|
||||
* BF2 customs + ETB 420 T = 6 w
|
||||
* BF3 self + ETB 420 T = 6 w
|
||||
* BF4 self + ETB 420 T = 6 w
|
||||
* BF5 customs + USD 1 540 T = 22 w (the ≥22-wagon giant)
|
||||
* BF6 self + USD 420 T = 6 w
|
||||
*
|
||||
* Difference from the Cypress twin: every payment goes through the payment
|
||||
* microservice and a signed gateway callback, not the staff mark-paid shortcut.
|
||||
* Steps are sequential and not idempotent — the file runs as one journey.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway } from "./client";
|
||||
import {
|
||||
allocatedWagons,
|
||||
bookBulkReady,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
dispatchSchedule,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
extendPayWindow,
|
||||
ensureCorridorRoute,
|
||||
expectMilestoneDone,
|
||||
forceWindowOpen,
|
||||
gatePassGranted,
|
||||
invoiceForBooking,
|
||||
milestoneCount,
|
||||
payViaGateway,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
runCorridor,
|
||||
runImportCustomsTail,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
uploadT1,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(20);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const BOOKINGS = [
|
||||
{ suffix: "BF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 },
|
||||
{ suffix: "BF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "BF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "BF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "BF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 },
|
||||
{ suffix: "BF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 },
|
||||
];
|
||||
const CUSTOMS = BOOKINGS.filter((b) => b.customs);
|
||||
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs);
|
||||
|
||||
describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
BOOKINGS.map((b) => ({
|
||||
suffix: b.suffix,
|
||||
currency: b.currency,
|
||||
customs: b.customs,
|
||||
freight: "BULK" as const,
|
||||
})),
|
||||
);
|
||||
|
||||
const schedule = await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
|
||||
});
|
||||
scheduleId = schedule.id;
|
||||
// The cycle counter turns over when the window opens, not at creation.
|
||||
await forceWindowOpen(scheduleId, 45);
|
||||
expect((await scheduleRow(scheduleId)).booking_cycle_no, "FIRST window cycle").toBe(1);
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
booking.set(
|
||||
b.suffix,
|
||||
await bookBulkReady({
|
||||
contractId: contracts.get(b.suffix)!,
|
||||
tons: b.tons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
customs: b.customs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("reserves all six with invoices in their contract currency — they fit exactly", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
const row = await pollBookingStatus(booking.get(b.suffix)!, [
|
||||
"SELECTED_FOR_BATCH",
|
||||
"AWAITING_PAYMENT",
|
||||
]);
|
||||
expect(row.payment_deadline ?? "pending", `${b.suffix} pay deadline`).toBeTruthy();
|
||||
const invoice = await invoiceForBooking(booking.get(b.suffix)!);
|
||||
expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency);
|
||||
}
|
||||
});
|
||||
|
||||
it("all six pay through the gateway — 54/54 wagons, window FULL, schedule finalized", async () => {
|
||||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||
for (const b of BOOKINGS) {
|
||||
await payViaGateway(booking.get(b.suffix)!);
|
||||
}
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) =>
|
||||
s.booking_window_status === "FULL" &&
|
||||
s.window_phase === "DONE" &&
|
||||
s.status === "SCHEDULED",
|
||||
"FULL + DONE + finalized",
|
||||
);
|
||||
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||
});
|
||||
|
||||
it("GL Djibouti grants the gate pass and uploads T1 for the customs bookings", async () => {
|
||||
await gatePassGranted(scheduleId);
|
||||
for (const b of CUSTOMS) {
|
||||
const res = await uploadT1(booking.get(b.suffix)!);
|
||||
expect(res.status, `${b.suffix} T1 upload`).toBeLessThanOrEqual(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("the train dispatches and runs the corridor checkpoint by checkpoint", async () => {
|
||||
await dispatchSchedule(scheduleId);
|
||||
await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED");
|
||||
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "IN_TRANSIT", 15);
|
||||
|
||||
await runCorridor(scheduleId);
|
||||
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20);
|
||||
|
||||
const [row] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
|
||||
[scheduleId],
|
||||
);
|
||||
expect(Number(row.n), "wagon movement ledger rows").toBeGreaterThanOrEqual(54);
|
||||
});
|
||||
|
||||
it("GL runs the customs tail on every customs booking", async () => {
|
||||
for (const b of CUSTOMS) {
|
||||
const id = booking.get(b.suffix)!;
|
||||
await runImportCustomsTail(id);
|
||||
await expectMilestoneDone(id, "T1_CLOSED");
|
||||
await expectMilestoneDone(id, "RISK_ASSIGNED");
|
||||
await expectMilestoneDone(id, "IMPORT_RELEASE_GRANTED");
|
||||
await expectMilestoneDone(id, "IMPORT_PROCESS_COMPLETED");
|
||||
}
|
||||
});
|
||||
|
||||
it("the self-clearing bookings arrived clean — no customs tail", async () => {
|
||||
for (const b of SELF_CLEAR) {
|
||||
const id = booking.get(b.suffix)!;
|
||||
const row = await pollBookingStatus(id, "ARRIVED", 5);
|
||||
expect(row.status, `${b.suffix} final status`).toBe("ARRIVED");
|
||||
expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
224
integration/src/bulk-import-matrix.it.ts
Normal file
224
integration/src/bulk-import-matrix.it.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* BULK IMPORT edge matrix:
|
||||
* a) a wheat booking on a day with no open window is rejected at submission
|
||||
* b) a sub-corridor booking (NAGAD → MOJO) rides the through-train next to a
|
||||
* DJIB_PORT → KALITY booking — the batch is corridor-aware
|
||||
* c) bulk intercity ride-along (MOJO → KALITY, DOMESTIC, dateless): staff
|
||||
* assign it onto the import train's free leg, the pay window opens, it
|
||||
* pays and links
|
||||
* d) whole-train giant: 4 000 T (58 wagons' worth) alone on a 54-wagon train
|
||||
* → partial offer of the FULL consist (3 780 T); the gateway settlement
|
||||
* applies the split and the train is FULL from ONE booking; the 220 T
|
||||
* outstanding must be rebooked EXACTLY on a later train
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway, poll } from "./client";
|
||||
import {
|
||||
acceptIntercityOnto,
|
||||
acceptOperation,
|
||||
bookBulk,
|
||||
bookBulkReady,
|
||||
expectDayRefused,
|
||||
bookingFor,
|
||||
bookingRow,
|
||||
clearIntercityBooking,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
extendPayWindow,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollPartialOffer,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(25);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const GIANT_DEPARTURE = departureAt(26);
|
||||
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
||||
const REMAINDER_DEPARTURE = departureAt(27);
|
||||
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
||||
const NO_WINDOW_DAY = eatDayStr(departureAt(29)); // no schedule exists there
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
describe("bulk import matrix: gates, sub-corridor, ride-along, whole-train giant", () => {
|
||||
let contracts: Map<string, string>;
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
await resetCorridorDay(GIANT_DEPARTURE);
|
||||
await resetCorridorDay(REMAINDER_DEPARTURE);
|
||||
|
||||
contracts = await seedTenantContracts(STAMP, [
|
||||
{ suffix: "BM1", freight: "BULK" },
|
||||
{ suffix: "BMNW", freight: "BULK" },
|
||||
{ suffix: "BMSUB", freight: "BULK", originCode: "NAGAD", destCode: "MOJO" },
|
||||
{ suffix: "BMIC", freight: "BULK", direction: "DOMESTIC", originCode: "MOJO", destCode: "KALITY" },
|
||||
{ suffix: "BMG", freight: "BULK" },
|
||||
]);
|
||||
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("refuses a shipment day with no open window", async () => {
|
||||
const res = await expectDayRefused({
|
||||
contractId: contracts.get("BMNW")!,
|
||||
tons: 140,
|
||||
scheduledDate: NO_WINDOW_DAY,
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
// Wording differs by gate: the day probe answers "No departures available
|
||||
// on the selected day", the route probe "the import booking window …".
|
||||
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
|
||||
});
|
||||
|
||||
it("a through booking and a NAGAD→MOJO sub-corridor booking share the train", async () => {
|
||||
const bm1 = await bookBulkReady({
|
||||
contractId: contracts.get("BM1")!,
|
||||
tons: 1400,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
const sub = await bookBulkReady({
|
||||
contractId: contracts.get("BMSUB")!,
|
||||
tons: 700,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
for (const id of [bm1, sub]) {
|
||||
await pollBookingStatus(id, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
|
||||
await extendPayWindow(scheduleId, [bm1, sub]);
|
||||
await payViaGateway(bm1);
|
||||
await pollAllocations(bm1, 20);
|
||||
await payViaGateway(sub);
|
||||
await pollAllocations(sub, 10);
|
||||
|
||||
expect((await bookingRow(bm1)).train_schedule_id).toBe(scheduleId);
|
||||
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
|
||||
});
|
||||
|
||||
it("a dateless DOMESTIC ride-along boards the import train's free leg", async () => {
|
||||
// 140 T = 2 wagons MOJO → KALITY, no shipment day of its own.
|
||||
const res = await bookBulk({ contractId: contracts.get("BMIC")!, tons: 140 });
|
||||
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||
const ic = (await bookingFor(contracts.get("BMIC")!)).id;
|
||||
await clearIntercityBooking(ic);
|
||||
|
||||
await acceptIntercityOnto(scheduleId, ic);
|
||||
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
expect((await bookingRow(ic)).payment_deadline, "ride-along pay window opened").toBeTruthy();
|
||||
|
||||
await payViaGateway(ic);
|
||||
// Settlement UNPINS a paid ride-along back to the ride-along pool — staff
|
||||
// then place it on the train they want (acceptIntercity's `paid` branch,
|
||||
// booking-batch.service.ts). The Cypress twin never sees this: its staff
|
||||
// mark-paid shortcut leaves the reservation pinned.
|
||||
expect(
|
||||
(await bookingRow(ic)).train_schedule_id,
|
||||
"paid ride-along returns to the pool",
|
||||
).toBeNull();
|
||||
|
||||
await acceptIntercityOnto(scheduleId, ic);
|
||||
await poll(
|
||||
"ride-along linked to the import train",
|
||||
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
|
||||
[ic],
|
||||
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
|
||||
{ attempts: 20 },
|
||||
);
|
||||
const [link] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM freight.train_schedule_bookings
|
||||
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
|
||||
[ic, scheduleId],
|
||||
);
|
||||
expect(Number(link.n), "link row").toBe(1);
|
||||
});
|
||||
|
||||
it("a 4 000 T giant alone gets a FULL-consist partial offer and fills the train", async () => {
|
||||
const giantSchedule = await createSchedule({
|
||||
departure: GIANT_DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
|
||||
});
|
||||
await forceWindowOpen(giantSchedule.id, 45);
|
||||
|
||||
const bmg = await bookBulkReady({
|
||||
contractId: contracts.get("BMG")!,
|
||||
tons: 4000,
|
||||
scheduledDate: GIANT_DAY,
|
||||
});
|
||||
await closeBookingWindow(giantSchedule.id);
|
||||
await completeDocReview(giantSchedule.id);
|
||||
await pollBookingStatus(bmg, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
|
||||
const offer = await pollPartialOffer(bmg);
|
||||
expect(Number(offer.offered_wagons), "offered the whole consist").toBe(54);
|
||||
|
||||
await payViaGateway(bmg);
|
||||
await pollAllocations(bmg, 54);
|
||||
expect((await bookingRow(bmg)).is_split, "BMG is split").toBe(true);
|
||||
|
||||
await endPaymentPhase(giantSchedule.id);
|
||||
await pollWindow(
|
||||
giantSchedule.id,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL from one booking",
|
||||
);
|
||||
});
|
||||
|
||||
it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", async () => {
|
||||
const remainder = await createSchedule({
|
||||
departure: REMAINDER_DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
|
||||
});
|
||||
await forceWindowOpen(remainder.id, 45);
|
||||
|
||||
const wrong = await bookBulk({
|
||||
contractId: contracts.get("BMG")!,
|
||||
tons: 100,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
expect(wrong.status).toBeGreaterThanOrEqual(400);
|
||||
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
|
||||
|
||||
const exact = await bookBulk({
|
||||
contractId: contracts.get("BMG")!,
|
||||
tons: 220,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
|
||||
await acceptOperationIfPossible(contracts.get("BMG")!);
|
||||
});
|
||||
});
|
||||
|
||||
/** The remainder booking only needs to exist; walking its gate is out of scope. */
|
||||
async function acceptOperationIfPossible(contractId: string) {
|
||||
const booking = await bookingFor(contractId);
|
||||
if (booking.status === "OPERATION_REQUEST_PENDING") await acceptOperation(booking.id);
|
||||
}
|
||||
195
integration/src/bulk-import-split-promote.it.ts
Normal file
195
integration/src/bulk-import-split-promote.it.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* BULK IMPORT — split offer, exact-remainder rebooking, pay-window expiry and
|
||||
* priority-ordered waiting-list promotion on one 54-wagon CW4 train. Bulk
|
||||
* splits are FULL-WAGONS-ONLY at the base 70 T cap.
|
||||
*
|
||||
* reserved (priority order): BSA 1 400 T = 20 w, BSB 980 T = 14 w,
|
||||
* BSD 840 T = 12 w → 46 w. BSC 1 680 T = 24 w does NOT fit whole → PARTIAL
|
||||
* offer of the remaining 8 wagons = 560 T. BSC settles through the payment
|
||||
* service → the split applies (is_split + pre-split snapshot); the
|
||||
* outstanding 1 120 T must later be rebooked EXACTLY.
|
||||
* BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (6 w) and
|
||||
* BS2 (6 w) in priority order; BS3 (20 w) never fits and expires.
|
||||
*
|
||||
* Final consist: 20 + 14 + 8 + 6 + 6 = 54/54.
|
||||
*
|
||||
* The split is the reason this file pays through the gateway rather than the
|
||||
* staff shortcut: applying a pending partial offer hangs off
|
||||
* `booking.invoice.paid`, which only a real settlement emits.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
allocatedWagons,
|
||||
bookBulk,
|
||||
bookBulkReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
extendPayWindow,
|
||||
ensureCorridorRoute,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollPartialOffer,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
setPriority,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(23);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const REMAINDER_DEPARTURE = departureAt(24);
|
||||
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const;
|
||||
const TONS: Record<string, number> = {
|
||||
BSA: 1400,
|
||||
BSB: 980,
|
||||
BSD: 840,
|
||||
BSC: 1680,
|
||||
BS1: 420,
|
||||
BS2: 420,
|
||||
BS3: 1400,
|
||||
};
|
||||
|
||||
describe("bulk import: split offer, remainder rebooking, expiry + promotion", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let contracts: Map<string, string>;
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
await resetCorridorDay(REMAINDER_DEPARTURE);
|
||||
|
||||
contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"],
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 45);
|
||||
|
||||
for (const [i, suffix] of ORDER.entries()) {
|
||||
const id = await bookBulkReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
tons: TONS[suffix],
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
booking.set(suffix, id);
|
||||
await setPriority(id, i + 1);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const suffix of ["BSA", "BSB", "BSD", "BSC"]) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
const offer = await pollPartialOffer(booking.get("BSC")!);
|
||||
expect(Number(offer.offered_wagons), "BSC offered the remaining 8 wagons").toBe(8);
|
||||
|
||||
for (const suffix of ["BS1", "BS2", "BS3"]) {
|
||||
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} waiting`).toBe(
|
||||
"FULLY_EXECUTED",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("BSA and BSB pay; BSC settles through the gateway — the split applies", async () => {
|
||||
// Three sequential gateway settlements outlast the ~60s hold; BSD is left
|
||||
// alone because the next test is about its expiry.
|
||||
await extendPayWindow(scheduleId, [booking.get("BSA")!, booking.get("BSB")!, booking.get("BSC")!]);
|
||||
await payViaGateway(booking.get("BSA")!);
|
||||
await pollAllocations(booking.get("BSA")!, 20);
|
||||
await payViaGateway(booking.get("BSB")!);
|
||||
await pollAllocations(booking.get("BSB")!, 14);
|
||||
|
||||
await payViaGateway(booking.get("BSC")!);
|
||||
await pollAllocations(booking.get("BSC")!, 8);
|
||||
const bsc = await bookingRow(booking.get("BSC")!);
|
||||
expect(bsc.is_split, "BSC is split").toBe(true);
|
||||
expect(bsc.pre_split_quantities, "pre-split snapshot kept").toBeTruthy();
|
||||
});
|
||||
|
||||
it("BSD misses its pay window — the freed wagons promote BS1 + BS2 in priority order", async () => {
|
||||
await forceReservationExpiry(booking.get("BSD")!);
|
||||
for (const suffix of ["BS1", "BS2"]) {
|
||||
const row = await pollBookingStatus(booking.get(suffix)!, [
|
||||
"SELECTED_FOR_BATCH",
|
||||
"AWAITING_PAYMENT",
|
||||
]);
|
||||
expect(row.status, `${suffix} promoted`).not.toBe("FULLY_EXECUTED");
|
||||
expect(
|
||||
(await bookingRow(booking.get(suffix)!)).payment_deadline,
|
||||
`${suffix} got a pay window`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect((await bookingRow(booking.get("BS3")!)).status, "BS3 still has no seat").toBe(
|
||||
"FULLY_EXECUTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", async () => {
|
||||
await extendPayWindow(scheduleId, [booking.get("BS1")!, booking.get("BS2")!]);
|
||||
await payViaGateway(booking.get("BS1")!);
|
||||
await pollAllocations(booking.get("BS1")!, 6);
|
||||
await payViaGateway(booking.get("BS2")!);
|
||||
await pollAllocations(booking.get("BS2")!, 6);
|
||||
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL + DONE",
|
||||
);
|
||||
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||
await pollBookingStatus(booking.get("BS3")!, "EXPIRED", 40);
|
||||
});
|
||||
|
||||
it("the split customer must rebook EXACTLY the 1 120 T remainder", async () => {
|
||||
const remainderSchedule = await createSchedule({
|
||||
departure: REMAINDER_DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"],
|
||||
});
|
||||
await forceWindowOpen(remainderSchedule.id, 45);
|
||||
|
||||
// 1 680 booked − 560 shipped by the split = 1 120 T outstanding.
|
||||
const wrong = await bookBulk({
|
||||
contractId: contracts.get("BSC")!,
|
||||
tons: 560,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
expect(wrong.status, "partial remainder rejected").toBeGreaterThanOrEqual(400);
|
||||
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
|
||||
|
||||
const exact = await bookBulk({
|
||||
contractId: contracts.get("BSC")!,
|
||||
tons: 1120,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
|
||||
});
|
||||
});
|
||||
125
integration/src/bulk-import-waiting-expiry.it.ts
Normal file
125
integration/src/bulk-import-waiting-expiry.it.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* BULK IMPORT — the CW4 train fills from THREE wheat bookings; three more sit
|
||||
* in the waiting pool of the same window. The selected trio pays through the
|
||||
* gateway and allocates; when the cycle concludes FULL the waiting three
|
||||
* expire with the day — they never ride and never pay.
|
||||
*
|
||||
* 70 T per CW4 wagon, 54-wagon consist:
|
||||
* selected: BWA 1 400 T = 20 w, BWB 1 400 T = 20 w, BWC 980 T = 14 w → Σ 54
|
||||
* waiting: BW1 / BW2 / BW3 700 T = 10 w each
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
allocatedWagons,
|
||||
bookBulkReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
extendPayWindow,
|
||||
ensureCorridorRoute,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
setPriority,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(21);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const SELECTED = [
|
||||
{ suffix: "BWA", tons: 1400, wagons: 20 },
|
||||
{ suffix: "BWB", tons: 1400, wagons: 20 },
|
||||
{ suffix: "BWC", tons: 980, wagons: 14 },
|
||||
];
|
||||
const WAITING = [
|
||||
{ suffix: "BW1", tons: 700, wagons: 10 },
|
||||
{ suffix: "BW2", tons: 700, wagons: 10 },
|
||||
{ suffix: "BW3", tons: 700, wagons: 10 },
|
||||
];
|
||||
const ALL = [...SELECTED, ...WAITING];
|
||||
|
||||
describe("bulk import: three bookings fill the train, three wait and expire", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ALL.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"],
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 45);
|
||||
|
||||
for (const [i, b] of ALL.entries()) {
|
||||
const id = await bookBulkReady({
|
||||
contractId: contracts.get(b.suffix)!,
|
||||
tons: b.tons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
booking.set(b.suffix, id);
|
||||
// Priority order = the order above: the exact-fill trio picks first.
|
||||
await setPriority(id, i + 1);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const b of SELECTED) {
|
||||
await pollBookingStatus(booking.get(b.suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
for (const b of WAITING) {
|
||||
const row = await bookingRow(booking.get(b.suffix)!);
|
||||
expect(row.status, `${b.suffix} still waiting`).toBe("FULLY_EXECUTED");
|
||||
expect(row.payment_deadline, `${b.suffix} has no pay deadline`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("the three selected pay through the gateway and allocate — 54/54", async () => {
|
||||
await extendPayWindow(scheduleId, SELECTED.map((b) => booking.get(b.suffix)!));
|
||||
for (const b of SELECTED) {
|
||||
await payViaGateway(booking.get(b.suffix)!);
|
||||
await pollAllocations(booking.get(b.suffix)!, b.wagons);
|
||||
}
|
||||
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||
});
|
||||
|
||||
it("the cycle concludes FULL — the three waiting bookings expire with the day", async () => {
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL + DONE",
|
||||
);
|
||||
for (const b of WAITING) await pollBookingStatus(booking.get(b.suffix)!, "EXPIRED", 40);
|
||||
for (const b of SELECTED) {
|
||||
const row = await bookingRow(booking.get(b.suffix)!);
|
||||
expect(row.status, `${b.suffix} stays PAID`).toBe("PAID");
|
||||
}
|
||||
});
|
||||
});
|
||||
127
integration/src/bulk-import-window-reopen.it.ts
Normal file
127
integration/src/bulk-import-window-reopen.it.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* BULK IMPORT — nobody pays in the first cycle: both reserved wheat bookings
|
||||
* expire, the cycle concludes NOT-full, and the window REOPENS for a second
|
||||
* cycle on the same train. A fresh 700 T booking arrives in cycle 2, pays
|
||||
* through the gateway, and allocates. Import days reopen; they don't die.
|
||||
*
|
||||
* The expiry itself is only possible because the payment service is real here:
|
||||
* before expiring an unpaid hold the engine asks the gateway whether a late
|
||||
* payment landed, and defers forever on an unverifiable answer.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
bookBulkReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(22);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
const SUFFIXES = ["BRA", "BRB", "BRC"] as const;
|
||||
|
||||
describe("bulk import: dead first cycle — expire all, reopen, book again", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let contracts: Map<string, string>;
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
SUFFIXES.map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||
);
|
||||
scheduleId = (
|
||||
await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-23", "LOCO-IMP-24"],
|
||||
})
|
||||
).id;
|
||||
await forceWindowOpen(scheduleId, 45);
|
||||
expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 1").toBe(1);
|
||||
|
||||
for (const suffix of ["BRA", "BRB"] as const) {
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookBulkReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
tons: 1400,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("two customers are reserved in cycle 1", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
for (const suffix of ["BRA", "BRB"] as const) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("nobody pays — both reservations expire and the cycle concludes not-full", async () => {
|
||||
for (const suffix of ["BRA", "BRB"] as const) {
|
||||
await forceReservationExpiry(booking.get(suffix)!);
|
||||
}
|
||||
await endPaymentPhase(scheduleId);
|
||||
// When the reopen instant lands inside office hours the 10s tick chains
|
||||
// PRE_WINDOW straight into OPEN, so PRE_WINDOW is not a reliably observable
|
||||
// resting state — assert only that the cycle left PAYMENT without DONE.
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) => s.window_phase !== "PAYMENT" && s.window_phase !== "DONE",
|
||||
"concluded not-full",
|
||||
);
|
||||
});
|
||||
|
||||
it("the second window opens (cycle 2) and a fresh 700 T booking pays and allocates", async () => {
|
||||
await forceWindowOpen(scheduleId, 45);
|
||||
expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 2").toBe(2);
|
||||
|
||||
const brc = await bookBulkReady({
|
||||
contractId: contracts.get("BRC")!,
|
||||
tons: 700,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
booking.set("BRC", brc);
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
await pollBookingStatus(brc, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
|
||||
await payViaGateway(brc);
|
||||
await pollAllocations(brc, 10);
|
||||
|
||||
for (const suffix of ["BRA", "BRB"] as const) {
|
||||
const row = await bookingRow(booking.get(suffix)!);
|
||||
expect(row.status, `${suffix} stays expired`).toBe("EXPIRED");
|
||||
}
|
||||
expect((await bookingRow(brc)).train_schedule_id, "BRC rides the reopened train").toBe(
|
||||
scheduleId,
|
||||
);
|
||||
});
|
||||
});
|
||||
180
integration/src/cbe-bill.it.ts
Normal file
180
integration/src/cbe-bill.it.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* CBE Unified Bill — the INBOUND direction, and the only flow where the
|
||||
* payment service calls freight rather than the other way round:
|
||||
*
|
||||
* customer picks CBE_BILL → freight → payment API mints a bill reference
|
||||
* CBE POST /cbe/oauth/token → bearer token we issued
|
||||
* CBE POST /cbe/query → payment API → freight /internal/payments/bill-query
|
||||
* → payer name + live balance
|
||||
* CBE POST /cbe/payment → intent settles → freight invoice PAID
|
||||
*
|
||||
* Both hops run real code on both sides; nothing is stubbed here at all.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { PAYMENT_API, closeDb, db, gateway, poll } from "./client";
|
||||
import {
|
||||
createImportSchedule,
|
||||
currentInvoice,
|
||||
departureAt,
|
||||
ensureCorridorRoute,
|
||||
releaseUnpaidHolds,
|
||||
forceWindowOpen,
|
||||
gatewayIntent,
|
||||
invoiceForBooking,
|
||||
payInvoice,
|
||||
prepareBooking,
|
||||
resetCorridorDay,
|
||||
runBatch,
|
||||
type ReadyBooking,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(10);
|
||||
const STAMP = String(Date.now());
|
||||
const API_NAME = "EDR_FREIGHT";
|
||||
|
||||
const txnId = (tag: string) => `IT-${tag}-${Date.now()}`;
|
||||
|
||||
async function cbeToken(): Promise<string> {
|
||||
const res = await request(PAYMENT_API).post("/cbe/oauth/token").send({
|
||||
grant_type: "client_credentials",
|
||||
client_id: "it-cbe-bill",
|
||||
client_secret: "it-cbe-bill-secret",
|
||||
scope: "Unified_Outgoing",
|
||||
});
|
||||
const token = res.body?.access_token ?? res.body?.data?.access_token;
|
||||
if (!token) throw new Error(`cbe token failed: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
const cbe = (token: string, path: string, body: object) =>
|
||||
request(PAYMENT_API).post(path).set("Authorization", `Bearer ${token}`).send(body);
|
||||
|
||||
describe("CBE Unified Bill (payment service as biller)", () => {
|
||||
let booking: ReadyBooking;
|
||||
let invoiceId: string;
|
||||
let billId: string;
|
||||
let token: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||
await forceWindowOpen(schedule.id, 45);
|
||||
booking = await prepareBooking({
|
||||
suffix: "BILL1",
|
||||
departure: DEPARTURE,
|
||||
runStamp: STAMP,
|
||||
isoSeed: 300,
|
||||
twenty: 2,
|
||||
currency: "ETB",
|
||||
});
|
||||
await runBatch(schedule.id);
|
||||
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
|
||||
|
||||
const res = await payInvoice(invoiceId, { method: "CBE_BILL" });
|
||||
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(booking.bookingId);
|
||||
const [row] = await db<{ bill_reference: string }>(
|
||||
`SELECT bill_reference FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
);
|
||||
billId = row.bill_reference;
|
||||
token = await cbeToken();
|
||||
}, 600_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("mints a 12-digit bill reference the customer can quote at any CBE channel", () => {
|
||||
expect(billId).toMatch(/^\d{12}$/);
|
||||
});
|
||||
|
||||
it("refuses the query without a token we issued", async () => {
|
||||
const res = await request(PAYMENT_API)
|
||||
.post("/cbe/query")
|
||||
.send({ Destination_Api_Name: API_NAME, End_To_End_Txn_Id: txnId("noauth"), Bill_Id: billId });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("answers the bill lookup from live freight data", async () => {
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
const res = await cbe(token, "/cbe/query", {
|
||||
Destination_Api_Name: API_NAME,
|
||||
End_To_End_Txn_Id: txnId("q1"),
|
||||
Bill_Id: billId,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.Response_Code).toBe("0");
|
||||
expect(res.body.Bill_Id).toBe(billId);
|
||||
// The amount and payer come from freight's billQuery, not from a cached
|
||||
// copy in the payment service.
|
||||
expect(Number(res.body.Total_Amount)).toBeCloseTo(Number(invoice.balance_amount), 2);
|
||||
expect(res.body.Full_Name).toBe("E2E Logistics PLC");
|
||||
expect(res.body.Payment_Reason).toMatch(/invoice/i);
|
||||
});
|
||||
|
||||
it("reports an unknown bill as a business failure, not an error", async () => {
|
||||
const res = await cbe(token, "/cbe/query", {
|
||||
Destination_Api_Name: API_NAME,
|
||||
End_To_End_Txn_Id: txnId("q404"),
|
||||
Bill_Id: "000000000000",
|
||||
});
|
||||
// Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200
|
||||
// as a channel fault and retries.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.Response_Code).toBe("3");
|
||||
});
|
||||
|
||||
it("settles the freight invoice when CBE reports the debit", async () => {
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
const res = await cbe(token, "/cbe/payment", {
|
||||
Destination_Api_Name: API_NAME,
|
||||
End_To_End_Txn_Id: txnId("p1"),
|
||||
Cbe_Txn_Ref: `CBE${Date.now()}`,
|
||||
Timestamp: new Date().toISOString(),
|
||||
Bill_Id: billId,
|
||||
Amount: String(invoice.balance_amount),
|
||||
Currency: "ETB",
|
||||
Full_Name: "IT Payer",
|
||||
Phone_No: "+251911000001",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.Response_Code).toBe("0");
|
||||
|
||||
const paid = await poll<{ status: string }>(
|
||||
"invoice PAID via CBE bill",
|
||||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
expect(paid.status).toBe("PAID");
|
||||
});
|
||||
|
||||
it("rejects a second debit on the same bill", async () => {
|
||||
const res = await cbe(token, "/cbe/payment", {
|
||||
Destination_Api_Name: API_NAME,
|
||||
End_To_End_Txn_Id: txnId("p2"),
|
||||
Cbe_Txn_Ref: `CBE${Date.now()}`,
|
||||
Timestamp: new Date().toISOString(),
|
||||
Bill_Id: billId,
|
||||
Amount: "1.00",
|
||||
Currency: "ETB",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.Response_Code).toBe("3");
|
||||
});
|
||||
|
||||
it("reports an already-paid bill on a later query", async () => {
|
||||
const res = await cbe(token, "/cbe/query", {
|
||||
Destination_Api_Name: API_NAME,
|
||||
End_To_End_Txn_Id: txnId("q2"),
|
||||
Bill_Id: billId,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.Response_Code).toBe("3");
|
||||
});
|
||||
});
|
||||
218
integration/src/client.ts
Normal file
218
integration/src/client.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Plumbing for the integration suite: HTTP against the containerized freight
|
||||
* and payment APIs, SQL against their shared throwaway Postgres, and the
|
||||
* gateway mock's control plane.
|
||||
*
|
||||
* This is the Cypress-free port of e2e/freight/cypress/e2e/flows/import-utils.ts —
|
||||
* same request sequences, same SQL, `pg.Pool` instead of `cy.task`.
|
||||
*/
|
||||
import request from "supertest";
|
||||
import { Pool, type QueryResultRow } from "pg";
|
||||
|
||||
export const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||
export const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||
export const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||
export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token";
|
||||
|
||||
const DB_URL =
|
||||
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// users (e2e/freight/cypress/fixtures/seed-users.sql + users.json)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const customerA = "user@gmail.com";
|
||||
export const customerB = "user2@gmail.com";
|
||||
export const opsStaff = "operation@edr.local";
|
||||
export const chief = "chief@edr.local";
|
||||
/** isSuperAdmin bypasses assertFreightPermission — used for the GL/clearance steps. */
|
||||
export const superAdmin = "superadmin@tria.com";
|
||||
|
||||
const STAFF_PASSWORD = "password@tria";
|
||||
const CUSTOMER_PASSWORD = "12345678";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pool = new Pool({ connectionString: DB_URL, max: 12 });
|
||||
|
||||
export async function db<T extends QueryResultRow = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const res = await pool.query<T>(sql, params);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
/** Poll a query until `check` passes. Async settlement here is broker-driven. */
|
||||
export async function poll<T extends QueryResultRow = Record<string, unknown>>(
|
||||
label: string,
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
check: (row: T | undefined) => boolean,
|
||||
{ attempts = 40, intervalMs = 2000 } = {},
|
||||
): Promise<T> {
|
||||
let last: T | undefined;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
last = (await db<T>(sql, params))[0];
|
||||
if (check(last)) return last as T;
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
throw new Error(
|
||||
`timed out waiting for ${label} after ${attempts} attempts — last row: ${JSON.stringify(last)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tokens = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Bearer token for a seeded account. The login audience is not cosmetic:
|
||||
* `user_type='individual'` accounts are rejected on the backoffice audience and
|
||||
* vice versa (EDRFREIGHT-415), so it is derived from the address.
|
||||
*/
|
||||
export async function tokenFor(email: string): Promise<string> {
|
||||
const cached = tokens.get(email);
|
||||
if (cached) return cached;
|
||||
|
||||
const portal = email.endsWith("@gmail.com");
|
||||
const res = await request(API)
|
||||
.post("/api/auth/login")
|
||||
.set("x-client-app", portal ? "portal" : "backoffice")
|
||||
.send({ email, password: portal ? CUSTOMER_PASSWORD : STAFF_PASSWORD });
|
||||
|
||||
// The login response is flattened by the app (no `.data` envelope) and 201s.
|
||||
const token = res.body?.token ?? res.body?.data?.token;
|
||||
if (!token) {
|
||||
throw new Error(`login failed for ${email}: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
tokens.set(email, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Login without caching, so audience-rejection can be asserted. */
|
||||
export function login(email: string, password: string, app: "portal" | "backoffice") {
|
||||
return request(API).post("/api/auth/login").set("x-client-app", app).send({ email, password });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// freight API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Method = "get" | "post" | "patch" | "delete";
|
||||
|
||||
/** Authenticated call to the freight API as `email`. Never throws on 4xx/5xx. */
|
||||
export async function api(
|
||||
email: string,
|
||||
method: Method,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<request.Response> {
|
||||
const token = await tokenFor(email);
|
||||
const req = request(API)[method](path).set("Authorization", `Bearer ${token}`);
|
||||
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
|
||||
}
|
||||
|
||||
/** Same, but fails loudly on a non-2xx — for arrange steps that must succeed. */
|
||||
export async function apiOk(
|
||||
email: string,
|
||||
method: Method,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<request.Response> {
|
||||
const res = await api(email, method, path, body);
|
||||
if (res.status < 200 || res.status > 201) {
|
||||
throw new Error(
|
||||
`${method.toUpperCase()} ${path} as ${email} → ${res.status}: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Multipart upload (clearance documents). supertest handles the encoding. */
|
||||
export async function upload(
|
||||
email: string,
|
||||
path: string,
|
||||
filePath: string,
|
||||
field = "files",
|
||||
fields: Record<string, string> = {},
|
||||
): Promise<request.Response> {
|
||||
const token = await tokenFor(email);
|
||||
const req = request(API).post(path).set("Authorization", `Bearer ${token}`);
|
||||
for (const [k, v] of Object.entries(fields)) req.field(k, v);
|
||||
return req.attach(field, filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// payment API (service-to-service surface)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function payment(method: Method, path: string, body?: unknown) {
|
||||
const req = request(PAYMENT_API)[method](path).set("x-service-token", SERVICE_TOKEN);
|
||||
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
|
||||
}
|
||||
|
||||
/** Payment-side rows. The payment service owns its own schema in the same DB. */
|
||||
export function paymentDb<T extends QueryResultRow = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
) {
|
||||
return db<T>(sql, params);
|
||||
}
|
||||
|
||||
export interface IntentRow extends QueryResultRow {
|
||||
id: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
merchant_order_id: string;
|
||||
amount_minor: string;
|
||||
currency: string;
|
||||
reference_id: string;
|
||||
provider_txn_id: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export function intentByMerchantOrderId(merchantOrderId: string) {
|
||||
return db<IntentRow>(
|
||||
`SELECT * FROM edr_payment.payment_intent WHERE merchant_order_id = $1`,
|
||||
[merchantOrderId],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// gateway mock control plane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const gateway = {
|
||||
reset: () => request(GATEWAY).post("/__control/reset").send({}),
|
||||
|
||||
/** Force a provider's next `times` calls (or all of them) into a mode. */
|
||||
mode: (provider: string, mode: "ok" | "fail" | "timeout" | "pending" | "paid", times?: number) =>
|
||||
request(GATEWAY).post(`/__control/provider/${provider}`).send({ mode, times }),
|
||||
|
||||
/** Mark the order settled at the gateway WITHOUT a callback (polling path). */
|
||||
settle: (merchantOrderId: string) =>
|
||||
request(GATEWAY).post("/__control/settle").send({ merchantOrderId }),
|
||||
|
||||
/** Fire a signed provider callback at the payment API. */
|
||||
webhook: (opts: {
|
||||
merchantOrderId: string;
|
||||
provider?: string;
|
||||
status?: string;
|
||||
eventId?: string;
|
||||
transactionId?: string;
|
||||
signature?: "bad";
|
||||
}) => request(GATEWAY).post("/__control/webhook").send(opts),
|
||||
|
||||
calls: () => request(GATEWAY).get("/__control/calls").send(),
|
||||
};
|
||||
215
integration/src/concurrency.it.ts
Normal file
215
integration/src/concurrency.it.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Many users, at once. Each test drives one production race through the real
|
||||
* HTTP surface and asserts the guard that is supposed to hold:
|
||||
*
|
||||
* - two settlements of one invoice → billing.markInvoiceAsPaid pessimistic lock
|
||||
* - a replayed callback storm → webhook dedupe on externalEventId
|
||||
* - two tenants, one wagon budget → reserveOnExport re-verify under lock (H8)
|
||||
* - an invoice-number burst → pg_advisory_xact_lock in invoice-numbering
|
||||
* - pay after the window closed → payInvoice dueAt gate
|
||||
*
|
||||
* These are the tests expected to find things. When one fails, read it as a
|
||||
* finding, not as a flaky assertion.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeDb,
|
||||
customerA,
|
||||
customerB,
|
||||
db,
|
||||
gateway,
|
||||
poll,
|
||||
sleep,
|
||||
} from "./client";
|
||||
import {
|
||||
TIN_B,
|
||||
createImportSchedule,
|
||||
currentInvoice,
|
||||
departureAt,
|
||||
ensureCorridorRoute,
|
||||
releaseUnpaidHolds,
|
||||
forceWindowOpen,
|
||||
gatewayIntent,
|
||||
invoiceForBooking,
|
||||
payInvoice,
|
||||
prepareBooking,
|
||||
resetCorridorDay,
|
||||
runBatch,
|
||||
type ReadyBooking,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(8);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
describe("concurrency and multi-tenant races", () => {
|
||||
let scheduleId: string;
|
||||
let a: ReadyBooking;
|
||||
let b: ReadyBooking;
|
||||
let invoiceA: string;
|
||||
let invoiceB: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||
scheduleId = schedule.id;
|
||||
await forceWindowOpen(scheduleId, 45);
|
||||
|
||||
// Two DIFFERENT tenants on the same train-day.
|
||||
a = await prepareBooking({
|
||||
suffix: "CON-A",
|
||||
departure: DEPARTURE,
|
||||
runStamp: STAMP,
|
||||
isoSeed: 200,
|
||||
twenty: 2,
|
||||
});
|
||||
b = await prepareBooking({
|
||||
suffix: "CON-B",
|
||||
departure: DEPARTURE,
|
||||
runStamp: STAMP,
|
||||
isoSeed: 210,
|
||||
twenty: 2,
|
||||
tin: TIN_B,
|
||||
as: customerB,
|
||||
});
|
||||
await runBatch(scheduleId);
|
||||
invoiceA = (await invoiceForBooking(a.bookingId)).id;
|
||||
invoiceB = (await invoiceForBooking(b.bookingId)).id;
|
||||
}, 900_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("settles once when two callbacks land simultaneously", async () => {
|
||||
expect((await payInvoice(invoiceA, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||
const intent = await gatewayIntent(a.bookingId);
|
||||
|
||||
// Five concurrent deliveries of the SAME provider event.
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
gateway.webhook({
|
||||
merchantOrderId: intent.merchant_order_id,
|
||||
eventId: `RACE-${intent.merchant_order_id}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(results.every((r) => r.body.delivered === 200)).toBe(true);
|
||||
|
||||
await poll<{ status: string }>(
|
||||
"invoice PAID under duplicate delivery",
|
||||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceA],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
await sleep(3000);
|
||||
|
||||
// Dedupe is at the webhook table: one row, one outbox event, one ledger entry.
|
||||
const [{ n: events }] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
||||
WHERE merchant_order_id = $1`,
|
||||
[intent.merchant_order_id],
|
||||
);
|
||||
expect(Number(events)).toBe(1);
|
||||
|
||||
const [{ n: outbox }] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM edr_payment.notification_outbox
|
||||
WHERE intent_id = $1 AND event_type = 'payment.succeeded'`,
|
||||
[intent.id],
|
||||
);
|
||||
expect(Number(outbox)).toBe(1);
|
||||
|
||||
const invoice = await currentInvoice(invoiceA);
|
||||
expect((invoice.payments as unknown[]).length).toBe(1);
|
||||
expect(Number(invoice.balance_amount)).toBe(0);
|
||||
});
|
||||
|
||||
it("credits an invoice once even when two intents settle for it", async () => {
|
||||
// The per-reference unique index was dropped (migration 1782200000000), so
|
||||
// two intents on one booking are legal now. Both settling must still not
|
||||
// double-credit the invoice.
|
||||
const paid = await payInvoice(invoiceB, { method: "CBE_BIRR", as: customerB });
|
||||
expect(paid.status, JSON.stringify(paid.body)).toBeLessThanOrEqual(201);
|
||||
const first = await gatewayIntent(b.bookingId);
|
||||
|
||||
// A second initiate for the same reference, different provider.
|
||||
const second = await payInvoice(invoiceB, { method: "TELEBIRR", as: customerB });
|
||||
expect(second.status).toBeLessThanOrEqual(201);
|
||||
|
||||
await Promise.all([
|
||||
gateway.webhook({ merchantOrderId: first.merchant_order_id }),
|
||||
gateway.webhook({
|
||||
merchantOrderId: first.merchant_order_id,
|
||||
eventId: `SECOND-${first.merchant_order_id}`,
|
||||
}),
|
||||
]);
|
||||
|
||||
await poll<{ status: string }>(
|
||||
"invoice PAID once",
|
||||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceB],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
await sleep(4000);
|
||||
|
||||
const invoice = await currentInvoice(invoiceB);
|
||||
expect(Number(invoice.paid_amount)).toBeLessThanOrEqual(Number(invoice.total_amount));
|
||||
expect(Number(invoice.balance_amount)).toBe(0);
|
||||
});
|
||||
|
||||
it("gives two tenants distinct, gapless invoice numbers under a burst", async () => {
|
||||
const numbers = await db<{ invoice_number: string }>(
|
||||
`SELECT invoice_number FROM freight.invoices
|
||||
WHERE created_at > now() - interval '30 minutes' AND deleted_at IS NULL`,
|
||||
);
|
||||
const seen = numbers.map((r) => r.invoice_number);
|
||||
expect(new Set(seen).size).toBe(seen.length);
|
||||
});
|
||||
|
||||
it("never over-reserves the train when both tenants push at once", async () => {
|
||||
// The batch already ran for this day. Assert the invariant it must keep:
|
||||
// reserved wagons never exceed the consist.
|
||||
const [row] = await db<{ max_wagons: number; reserved: string }>(
|
||||
`SELECT ts.max_wagons,
|
||||
COALESCE(SUM(b.wagons_required), 0)::text AS reserved
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.bookings b
|
||||
ON b.train_schedule_id = ts.id AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN ('EXPIRED','CANCELLED','REJECTED')
|
||||
WHERE ts.id = $1
|
||||
GROUP BY ts.max_wagons`,
|
||||
[scheduleId],
|
||||
);
|
||||
expect(Number(row.reserved)).toBeLessThanOrEqual(Number(row.max_wagons));
|
||||
});
|
||||
|
||||
it("rejects a fresh payment once the pay window has closed", async () => {
|
||||
// A booking whose deadline has passed must not be able to START a payment
|
||||
// (billing.payInvoice dueAt gate) — a payment begun BEFORE the deadline is
|
||||
// still honoured later by the expire-time gateway reconcile, which is why
|
||||
// the gate lives on initiation and not on settlement.
|
||||
const departure = departureAt(9);
|
||||
await releaseUnpaidHolds();
|
||||
await resetCorridorDay(departure);
|
||||
const schedule = await createImportSchedule({ departure });
|
||||
await forceWindowOpen(schedule.id, 45);
|
||||
const third = await prepareBooking({
|
||||
suffix: "CON-C",
|
||||
departure,
|
||||
runStamp: STAMP,
|
||||
isoSeed: 220,
|
||||
twenty: 2,
|
||||
});
|
||||
await runBatch(schedule.id);
|
||||
const invoice = await invoiceForBooking(third.bookingId);
|
||||
|
||||
await db(`UPDATE freight.invoices SET due_at = now() - interval '1 minute' WHERE id = $1`, [
|
||||
invoice.id,
|
||||
]);
|
||||
const res = await payInvoice(invoice.id, { method: "CBE_BIRR", as: customerA });
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/payment window/i);
|
||||
}, 600_000);
|
||||
});
|
||||
1680
integration/src/flows.ts
Normal file
1680
integration/src/flows.ts
Normal file
File diff suppressed because it is too large
Load Diff
181
integration/src/g1-s1-expiry-promotes-waitlist.it.ts
Normal file
181
integration/src/g1-s1-expiry-promotes-waitlist.it.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* GROUP 1 · S1 — a no-pay expiry frees exactly the space the waiting list needs.
|
||||
*
|
||||
* A 3×40ft = 3 wagons
|
||||
* B 20×20ft + 10×40ft = 20 wagons
|
||||
* C 30×40ft = 30 wagons
|
||||
* ─────────
|
||||
* 53 = the whole BUILT train → all three reserved
|
||||
* D 6×20ft = 3 wagons → no room → WAITING LIST
|
||||
*
|
||||
* B and C pay through the real gateway. A never does: its deadline passes, A
|
||||
* EXPIRES, and its 3 wagons return to the day's pool. The top-up pass then
|
||||
* promotes D — an exact fit — and D pays.
|
||||
*
|
||||
* Final consist: B 20 + C 30 + D 3 = 53/53, FULL.
|
||||
* A is recoverable: its contract is untouched, so it can rebook a later day
|
||||
* with no re-approval.
|
||||
*
|
||||
* The 53 slots are the BUILT train's coupled consist (seed-g1-train.sql), not a
|
||||
* locomotive-length figure — see {@link createBuiltTrainSchedule}.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
allocatedWagons,
|
||||
bookContainersReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
containerWagons,
|
||||
createBuiltTrainSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectContractStillBookable,
|
||||
extendPayWindow,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
linkedBookings,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
setPriority,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(50);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
/** Booking order is also PRIORITY order: A must be INSIDE the batch, because
|
||||
* its expiry is what frees the space D needs. */
|
||||
const SHAPES = {
|
||||
A: { twenty: 0, forty: 3, wagons: 3 },
|
||||
B: { twenty: 20, forty: 10, wagons: 20 },
|
||||
C: { twenty: 0, forty: 30, wagons: 30 },
|
||||
D: { twenty: 6, forty: 0, wagons: 3 },
|
||||
} as const;
|
||||
const ORDER = ["A", "B", "C", "D"] as const;
|
||||
const IN_BATCH = ["A", "B", "C"] as const;
|
||||
|
||||
describe("g1 s1: expiry frees exactly the waiting list's space", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||
);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 20_000;
|
||||
for (const [i, suffix] of ORDER.entries()) {
|
||||
const shape = SHAPES[suffix];
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: shape.twenty,
|
||||
forty: shape.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
isoSeed += shape.twenty + shape.forty;
|
||||
await setPriority(booking.get(suffix)!, i + 1);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("the wagon math is exactly one trainload, and D fits exactly A's share", () => {
|
||||
for (const suffix of ORDER) {
|
||||
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||
SHAPES[suffix].wagons,
|
||||
);
|
||||
}
|
||||
const booked = IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0);
|
||||
expect(booked, "A+B+C fill the train exactly").toBe(G1_WAGONS);
|
||||
expect(SHAPES.D.wagons, "D fits exactly the space A frees").toBe(SHAPES.A.wagons);
|
||||
});
|
||||
|
||||
it("the batch reserves A, B and C; D holds a place in line rather than being rejected", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const suffix of IN_BATCH) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
expect(
|
||||
(await bookingRow(booking.get(suffix)!)).payment_deadline,
|
||||
`${suffix} got a pay window`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
const d = await bookingRow(booking.get("D")!);
|
||||
expect(d.status, "D waitlisted").toBe("FULLY_EXECUTED");
|
||||
expect(d.train_schedule_id, "D holds no seat").toBeNull();
|
||||
});
|
||||
|
||||
it("B and C pay inside the window; A never does and EXPIRES, freeing 3 wagons", async () => {
|
||||
// A is deliberately left out: the next assertion is about its expiry.
|
||||
await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]);
|
||||
await payViaGateway(booking.get("B")!);
|
||||
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
|
||||
await payViaGateway(booking.get("C")!);
|
||||
await pollAllocations(booking.get("C")!, SHAPES.C.wagons);
|
||||
|
||||
await forceReservationExpiry(booking.get("A")!);
|
||||
expect((await bookingRow(booking.get("A")!)).status, "A expired unpaid").toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("the freed 3 wagons promote D — an exact fit — and D pays", async () => {
|
||||
// fillFromWaitingList runs on the tick that follows the expiry; no second
|
||||
// staff action is needed.
|
||||
const promoted = await pollBookingStatus(
|
||||
booking.get("D")!,
|
||||
["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"],
|
||||
40,
|
||||
);
|
||||
expect(promoted.status, "D promoted off the waiting list").not.toBe("FULLY_EXECUTED");
|
||||
expect((await bookingRow(booking.get("D")!)).payment_deadline, "D got a pay window").toBeTruthy();
|
||||
|
||||
await extendPayWindow(scheduleId, [booking.get("D")!]);
|
||||
await payViaGateway(booking.get("D")!);
|
||||
await pollAllocations(booking.get("D")!, SHAPES.D.wagons);
|
||||
});
|
||||
|
||||
it("the train departs FULL at 53/53 — B 20 + C 30 + D 3, and A holds no seat", async () => {
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL + DONE",
|
||||
);
|
||||
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||
expect(await linkedBookings(scheduleId), "3 bookings linked").toBe(3);
|
||||
|
||||
for (const suffix of ["B", "C", "D"] as const) {
|
||||
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||||
}
|
||||
const a = await bookingRow(booking.get("A")!);
|
||||
expect(a.status, "A does not ride").toBe("EXPIRED");
|
||||
expect(a.train_schedule_id, "A holds no seat").toBeNull();
|
||||
});
|
||||
|
||||
it("A is recoverable — its contract needs no re-approval to rebook a later day", async () => {
|
||||
await expectContractStillBookable(booking.get("A")!);
|
||||
});
|
||||
});
|
||||
142
integration/src/g1-s2-exact-fill.it.ts
Normal file
142
integration/src/g1-s2-exact-fill.it.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* GROUP 1 · S2 — four bookings pay and fill the train to the slot.
|
||||
*
|
||||
* A 3×40ft = 3 wagons · 3 containers
|
||||
* B 20×20ft + 10×40ft = 20 wagons · 30 containers
|
||||
* C 25×40ft = 25 wagons · 25 containers
|
||||
* D 10×20ft = 5 wagons · 10 containers
|
||||
* ─────────
|
||||
* 53/53 → FULL
|
||||
*
|
||||
* Everyone is selected, everyone pays, nothing splits and nobody waits. What
|
||||
* this really guards is the ALLOCATION rather than the arithmetic: the 53
|
||||
* wagons carry 68 containers and every one must land on exactly one slot with
|
||||
* its number on it. A booking that took wagons but never mapped its units would
|
||||
* still read 53/53 on the board — hence the per-container assertion at the end.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
allocatedWagons,
|
||||
bookContainersReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
containerWagons,
|
||||
createBuiltTrainSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectContainersPlaced,
|
||||
expectNoPartialOffer,
|
||||
extendPayWindow,
|
||||
forceWindowOpen,
|
||||
linkedBookings,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(51);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const SHAPES = {
|
||||
A: { twenty: 0, forty: 3, wagons: 3, containers: 3 },
|
||||
B: { twenty: 20, forty: 10, wagons: 20, containers: 30 },
|
||||
C: { twenty: 0, forty: 25, wagons: 25, containers: 25 },
|
||||
D: { twenty: 10, forty: 0, wagons: 5, containers: 10 },
|
||||
} as const;
|
||||
const ORDER = ["A", "B", "C", "D"] as const;
|
||||
const TOTAL_CONTAINERS = ORDER.reduce((sum, s) => sum + SHAPES[s].containers, 0); // 68
|
||||
|
||||
describe("g1 s2: four bookings pay and fill the train exactly", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||
);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 21_000;
|
||||
for (const suffix of ORDER) {
|
||||
const shape = SHAPES[suffix];
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: shape.twenty,
|
||||
forty: shape.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
isoSeed += shape.containers;
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("the four bookings add up to exactly one trainload", () => {
|
||||
for (const suffix of ORDER) {
|
||||
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||
SHAPES[suffix].wagons,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||||
"A+B+C+D fill the train exactly",
|
||||
).toBe(G1_WAGONS);
|
||||
});
|
||||
|
||||
it("the batch reserves all four whole — an exact fit offers nobody a split", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
for (const suffix of ORDER) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||
}
|
||||
});
|
||||
|
||||
it("all four pay through the gateway and are allocated onto the train", async () => {
|
||||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||
for (const suffix of ORDER) {
|
||||
await payViaGateway(booking.get(suffix)!);
|
||||
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||||
}
|
||||
});
|
||||
|
||||
it("the train is FULL at 53/53 and every one of the 68 containers has a slot", async () => {
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL + DONE",
|
||||
);
|
||||
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||
expect(await linkedBookings(scheduleId), "4 bookings linked").toBe(4);
|
||||
for (const suffix of ORDER) {
|
||||
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||||
}
|
||||
|
||||
// Allocation is per CONTAINER, not just per wagon: 53 filled slots with
|
||||
// only some units mapped would still read as a full train.
|
||||
await expectContainersPlaced(scheduleId, TOTAL_CONTAINERS);
|
||||
});
|
||||
});
|
||||
141
integration/src/g1-s3-underfill-day-open.it.ts
Normal file
141
integration/src/g1-s3-underfill-day-open.it.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* GROUP 1 · S3 — an under-filled train keeps its day open.
|
||||
*
|
||||
* A 12×20ft = 6 wagons
|
||||
* B 10×40ft = 10 wagons
|
||||
* C 24×20ft = 12 wagons
|
||||
* ─────────
|
||||
* 28/53 → 25 slots still free
|
||||
*
|
||||
* Everyone pays, nobody splits, nobody waits. The assertion is the NEGATIVE
|
||||
* one: the window must NOT be marked FULL, because the day has to stay on offer
|
||||
* to customers who have not booked yet. A train that closed its day at 28/53
|
||||
* would silently refuse 25 wagons of business — so the claim is checked the way
|
||||
* a customer experiences it, through the portal's own day-availability query.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
allocatedWagons,
|
||||
bookContainersReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
containerWagons,
|
||||
createBuiltTrainSchedule,
|
||||
dayAvailability,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectNoPartialOffer,
|
||||
extendPayWindow,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollCycleConcluded,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(52);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const SHAPES = {
|
||||
A: { twenty: 12, forty: 0, wagons: 6 },
|
||||
B: { twenty: 0, forty: 10, wagons: 10 },
|
||||
C: { twenty: 24, forty: 0, wagons: 12 },
|
||||
} as const;
|
||||
const ORDER = ["A", "B", "C"] as const;
|
||||
const BOOKED_WAGONS = ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0); // 28
|
||||
const FREE_WAGONS = G1_WAGONS - BOOKED_WAGONS; // 25
|
||||
|
||||
describe("g1 s3: an under-filled train keeps its day open", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||
);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 22_000;
|
||||
for (const suffix of ORDER) {
|
||||
const shape = SHAPES[suffix];
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: shape.twenty,
|
||||
forty: shape.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
isoSeed += shape.twenty + shape.forty;
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("the three bookings leave 25 of the 53 slots free", () => {
|
||||
for (const suffix of ORDER) {
|
||||
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||
SHAPES[suffix].wagons,
|
||||
);
|
||||
}
|
||||
expect(BOOKED_WAGONS, "A+B+C = 28 wagons").toBe(28);
|
||||
expect(FREE_WAGONS, "25 slots unused").toBe(25);
|
||||
});
|
||||
|
||||
it("the batch reserves all three whole — with room to spare nobody is offered a split", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
for (const suffix of ORDER) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||
}
|
||||
});
|
||||
|
||||
it("all three pay — 28 of 53 wagons used and the window is NOT marked FULL", async () => {
|
||||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||
for (const suffix of ORDER) {
|
||||
await payViaGateway(booking.get(suffix)!);
|
||||
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||||
}
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollCycleConcluded(scheduleId);
|
||||
|
||||
expect(await allocatedWagons(scheduleId), "28 wagons allocated").toBe(BOOKED_WAGONS);
|
||||
expect(
|
||||
(await scheduleRow(scheduleId)).booking_window_status,
|
||||
"window not FULL at 28/53",
|
||||
).not.toBe("FULL");
|
||||
for (const suffix of ORDER) {
|
||||
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||||
}
|
||||
});
|
||||
|
||||
it("the day is still on offer to customers, with 25 free wagons", async () => {
|
||||
// The customer-facing consequence, asked the way the portal asks it. A
|
||||
// train that under-filled but stopped offering its day is the actual bug
|
||||
// this scenario guards, and `freeWagons` is where it would show.
|
||||
const day = await dayAvailability(booking.get("A")!, BOOKING_DAY);
|
||||
expect(day.trainsForDay, "the day still runs a train").toBe(true);
|
||||
expect(Number(day.freeWagons), "25 wagons still on offer").toBe(FREE_WAGONS);
|
||||
});
|
||||
});
|
||||
172
integration/src/g1-s4-split-closes-gap.it.ts
Normal file
172
integration/src/g1-s4-split-closes-gap.it.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* GROUP 1 · S4 — an over-subscribed day closes its last gap with a split.
|
||||
*
|
||||
* A 30×40ft = 30 wagons
|
||||
* B 40×20ft = 20 wagons
|
||||
* C 20×20ft = 10 wagons
|
||||
* ─────────
|
||||
* 60 wagons of demand for 53 slots
|
||||
*
|
||||
* The batch takes A and B whole — 50 used, 3 left. C needs 10 and cannot fit,
|
||||
* so rather than being skipped it is OFFERED the 3 remaining wagons (6×20ft).
|
||||
* C pays the offer THROUGH THE REAL PAYMENT PATH and the split applies: only a
|
||||
* settled `booking.invoice.paid` applies a pending offer, so the staff
|
||||
* mark-paid shortcut would allocate C whole and quietly defeat the scenario.
|
||||
*
|
||||
* What the split leaves behind is the other half of the case:
|
||||
* - `is_split` set and `pre_split_quantities` snapshotting the ORIGINAL 20;
|
||||
* - the booking itself reduced to the offered 6 containers;
|
||||
* - a 14×20ft remainder the customer rolls to a later window.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
allocatedWagons,
|
||||
bookContainersReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
containerCount,
|
||||
containerWagons,
|
||||
createBuiltTrainSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectNoPartialOffer,
|
||||
extendPayWindow,
|
||||
forceWindowOpen,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollPartialOffer,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
seedTenantContracts,
|
||||
setPriority,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(53);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const SHAPES = {
|
||||
A: { twenty: 0, forty: 30, wagons: 30 },
|
||||
B: { twenty: 40, forty: 0, wagons: 20 },
|
||||
C: { twenty: 20, forty: 0, wagons: 10 },
|
||||
} as const;
|
||||
const ORDER = ["A", "B", "C"] as const;
|
||||
/** A + B take 50 of 53; the gap is what C is offered. */
|
||||
const GAP_WAGONS = G1_WAGONS - SHAPES.A.wagons - SHAPES.B.wagons; // 3
|
||||
const OFFERED_CONTAINERS = GAP_WAGONS * 2; // 6 × 20ft
|
||||
const REMAINDER_CONTAINERS = SHAPES.C.twenty - OFFERED_CONTAINERS; // 14
|
||||
|
||||
describe("g1 s4: a split closes the last 3-wagon gap", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||
);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 23_000;
|
||||
for (const [i, suffix] of ORDER.entries()) {
|
||||
const shape = SHAPES[suffix];
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: shape.twenty,
|
||||
forty: shape.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
isoSeed += shape.twenty + shape.forty;
|
||||
// Priority decides who gets a whole seat and who gets the offer.
|
||||
await setPriority(booking.get(suffix)!, i + 1);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("demand exceeds the train by 7 wagons, leaving a 3-wagon gap after A and B", () => {
|
||||
for (const suffix of ORDER) {
|
||||
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||
SHAPES[suffix].wagons,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||||
"60 wagons of demand",
|
||||
).toBe(60);
|
||||
expect(GAP_WAGONS, "3-wagon gap after A+B").toBe(3);
|
||||
expect(SHAPES.C.wagons, "C cannot fit whole").toBeGreaterThan(GAP_WAGONS);
|
||||
});
|
||||
|
||||
it("the batch takes A and B whole and offers C exactly the 3 remaining wagons", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const suffix of ["A", "B"] as const) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||
}
|
||||
const offer = await pollPartialOffer(booking.get("C")!);
|
||||
expect(Number(offer.offered_wagons), "offer sized to the gap").toBe(GAP_WAGONS);
|
||||
});
|
||||
|
||||
it("A and B pay whole; C settles its partial and the split applies", async () => {
|
||||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||
await payViaGateway(booking.get("A")!);
|
||||
await pollAllocations(booking.get("A")!, SHAPES.A.wagons);
|
||||
await payViaGateway(booking.get("B")!);
|
||||
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
|
||||
|
||||
await payViaGateway(booking.get("C")!);
|
||||
await pollAllocations(booking.get("C")!, GAP_WAGONS);
|
||||
});
|
||||
|
||||
it("C is flagged split, snapshotted at 20×20ft, and reduced to the offered 6", async () => {
|
||||
const c = await bookingRow(booking.get("C")!);
|
||||
expect(c.is_split, "C is split").toBe(true);
|
||||
|
||||
const snapshot = c.pre_split_quantities as { bySize?: Record<string, number> } | null;
|
||||
expect(snapshot, "pre-split snapshot kept").toBeTruthy();
|
||||
// The remainder is later measured against this snapshot, so the ORIGINAL
|
||||
// quantity has to survive in it — not the reduced one.
|
||||
expect(
|
||||
Number(snapshot?.bySize?.["20FT"] ?? snapshot?.bySize?.["20ft"]),
|
||||
"snapshot holds the original 20 × 20ft",
|
||||
).toBe(SHAPES.C.twenty);
|
||||
|
||||
expect(await containerCount(booking.get("C")!), `C shrank to ${OFFERED_CONTAINERS} boxes`).toBe(
|
||||
OFFERED_CONTAINERS,
|
||||
);
|
||||
});
|
||||
|
||||
it("the train is FULL at 53/53 and C's 14-container remainder is outstanding", async () => {
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL + DONE",
|
||||
);
|
||||
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||
// 20 booked − 6 shipped = 14 still owed; the engine holds the customer to
|
||||
// rebooking exactly that (asserted for bulk in bulk-import-split-promote).
|
||||
expect(REMAINDER_CONTAINERS, "14 × 20ft outstanding").toBe(14);
|
||||
});
|
||||
});
|
||||
190
integration/src/g1-s5-cascading-expiry.it.ts
Normal file
190
integration/src/g1-s5-cascading-expiry.it.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* GROUP 1 · S5 — one expiry cascades into a second promotion.
|
||||
*
|
||||
* In the batch: A 10w + B 20w + C 23w = 53/53
|
||||
* Waiting: D 8w, E 5w (priority order D before E)
|
||||
*
|
||||
* A never pays and EXPIRES → 10 wagons freed. ONE settle then serves BOTH
|
||||
* waiting bookings in the same pass: D is reserved whole (8w) and E, which no
|
||||
* longer fits in the 2 wagons left, is OFFERED a partial of exactly those 2.
|
||||
* That is the assertion the scenario exists for — fillFromWaitingList loops
|
||||
* until a pass places nothing, so a single-pass top-up would leave E untouched
|
||||
* until the next window cycle.
|
||||
*
|
||||
* D then expires too, freeing 8 more wagons — and E's offer is NOT resized.
|
||||
* FINDING (the same one bulk-b1 pins): a refill never supersedes an open
|
||||
* partial offer, so E pays its stale 2-wagon offer and ships 2 of its 5 wagons
|
||||
* while 8 sit idle. The train settles at B 20 + C 23 + E 2 = 45/53.
|
||||
*
|
||||
* Every expiry must also leave an audit trail — a terminal EXPIRED booking with
|
||||
* its invoice closed out, never a silent disappearance. An open invoice on an
|
||||
* expired seat is money the customer could still pay for a train they are no
|
||||
* longer on.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
allocatedWagons,
|
||||
bookContainersReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
containerWagons,
|
||||
createBuiltTrainSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
extendPayWindow,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
livePayableInvoices,
|
||||
payViaGateway,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollCycleConcluded,
|
||||
pollPartialOffer,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
setPriority,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(54);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const SHAPES = {
|
||||
A: { twenty: 20, forty: 0, wagons: 10 },
|
||||
B: { twenty: 40, forty: 0, wagons: 20 },
|
||||
C: { twenty: 0, forty: 23, wagons: 23 },
|
||||
D: { twenty: 16, forty: 0, wagons: 8 },
|
||||
E: { twenty: 10, forty: 0, wagons: 5 },
|
||||
} as const;
|
||||
const ORDER = ["A", "B", "C", "D", "E"] as const;
|
||||
const IN_BATCH = ["A", "B", "C"] as const;
|
||||
const WAITING = ["D", "E"] as const;
|
||||
/** What A's expiry leaves loose once D takes its 8 — E's offer is sized to it. */
|
||||
const E_OFFER = SHAPES.A.wagons - SHAPES.D.wagons; // 2
|
||||
const RIDING = SHAPES.B.wagons + SHAPES.C.wagons + E_OFFER; // 45
|
||||
|
||||
describe("g1 s5: expiry cascades into a second promotion", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||
);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 24_000;
|
||||
for (const [i, suffix] of ORDER.entries()) {
|
||||
const shape = SHAPES[suffix];
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: shape.twenty,
|
||||
forty: shape.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
isoSeed += shape.twenty + shape.forty;
|
||||
await setPriority(booking.get(suffix)!, i + 1);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("A+B+C fill the train; D and E queue behind them, each fitting the hole above", () => {
|
||||
for (const suffix of ORDER) {
|
||||
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||
SHAPES[suffix].wagons,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||||
"A+B+C fill the train exactly",
|
||||
).toBe(G1_WAGONS);
|
||||
expect(SHAPES.D.wagons, "D fits inside A's 10").toBeLessThan(SHAPES.A.wagons);
|
||||
expect(SHAPES.E.wagons, "E fits inside D's 8").toBeLessThan(SHAPES.D.wagons);
|
||||
});
|
||||
|
||||
it("the batch reserves A, B and C; D and E wait in priority order", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
for (const suffix of IN_BATCH) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
for (const suffix of WAITING) {
|
||||
const row = await bookingRow(booking.get(suffix)!);
|
||||
expect(row.status, `${suffix} waitlisted`).toBe("FULLY_EXECUTED");
|
||||
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("B and C pay; A expires and ONE pass serves both waiting bookings", async () => {
|
||||
await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]);
|
||||
await payViaGateway(booking.get("B")!);
|
||||
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
|
||||
await payViaGateway(booking.get("C")!);
|
||||
await pollAllocations(booking.get("C")!, SHAPES.C.wagons);
|
||||
|
||||
await forceReservationExpiry(booking.get("A")!);
|
||||
// D fits A's hole whole…
|
||||
await pollBookingStatus(booking.get("D")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
||||
// …and the same pass keeps going: E no longer fits in the 2 wagons left, so
|
||||
// it is offered exactly those rather than being left for the next cycle.
|
||||
const offer = await pollPartialOffer(booking.get("E")!);
|
||||
expect(Number(offer.offered_wagons), "E offered the 2 loose wagons").toBe(E_OFFER);
|
||||
});
|
||||
|
||||
it("D expires too — but E's stale 2-wagon offer is never resized", async () => {
|
||||
await forceReservationExpiry(booking.get("D")!);
|
||||
// FINDING (bulk-b1 pins the same gap): the refill re-selects a booking that
|
||||
// already holds an OFFER without re-sizing it, so the 8 wagons D just freed
|
||||
// stay unsold and E ships 2 of the 5 it asked for. Assert what the engine
|
||||
// really does, so a fix to the offer path fails loudly here.
|
||||
await pollBookingStatus(booking.get("E")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
||||
expect((await bookingRow(booking.get("E")!)).payment_deadline, "E got a pay window").toBeTruthy();
|
||||
|
||||
await extendPayWindow(scheduleId, [booking.get("E")!]);
|
||||
await payViaGateway(booking.get("E")!);
|
||||
await pollAllocations(booking.get("E")!, E_OFFER);
|
||||
expect((await bookingRow(booking.get("E")!)).is_split, "E rode split, 2 of 5").toBe(true);
|
||||
});
|
||||
|
||||
it("both expiries are terminal and auditable — nothing vanished silently", async () => {
|
||||
for (const suffix of ["A", "D"] as const) {
|
||||
const row = await bookingRow(booking.get(suffix)!);
|
||||
expect(row.status, `${suffix} terminal EXPIRED`).toBe("EXPIRED");
|
||||
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
|
||||
expect(
|
||||
await livePayableInvoices(booking.get(suffix)!),
|
||||
`${suffix} has no live payable invoice`,
|
||||
).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("the train settles at 45/53 — eight wagons unsold, short of FULL", async () => {
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollCycleConcluded(scheduleId);
|
||||
expect(await allocatedWagons(scheduleId), "45 wagons allocated").toBe(RIDING);
|
||||
expect(
|
||||
(await scheduleRow(scheduleId)).booking_window_status,
|
||||
"window not FULL at 45/53",
|
||||
).not.toBe("FULL");
|
||||
});
|
||||
});
|
||||
420
integration/src/g1-s6-s8-offers-government-tiers.it.ts
Normal file
420
integration/src/g1-s6-s8-offers-government-tiers.it.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* GROUP 1 · S6–S8 — who gets the last wagons on the 53-wagon built train.
|
||||
*
|
||||
* S6 a split offer nobody takes: the offer lapses, the booking expires
|
||||
* WHOLE, and the wagons it was offered go unsold.
|
||||
* S7 a government booking jumps the queue — by PREEMPTION, not by ranking:
|
||||
* it displaces the lowest-priority commercial reservation and rides
|
||||
* unpaid, carrying the +50 000 bonus.
|
||||
* S8 commercial priority tiers decide who is offered the remainder:
|
||||
* USD payer > customs service > plain, with no per-booking priority set.
|
||||
*
|
||||
* TWO NOTES ON HOW THE PRODUCT REALLY WORKS
|
||||
*
|
||||
* S7 — the +50 000 bonus (GOVERNMENT_PRIORITY_BONUS) keys off
|
||||
* `bookings.is_government`, not a government-institution lookup, and
|
||||
* government does not merely outrank: `preemptForGovernment` EXPIRES the
|
||||
* lowest-priority commercial booking whose leg overlaps and allocates in its
|
||||
* place. Government bookings are created with POST /api/bookings against a
|
||||
* kind='government' company and promoted with /government-expedite — never
|
||||
* through the contract wizard.
|
||||
*
|
||||
* FINDING — preemption cannot reach a train whose window already reads FULL:
|
||||
* `isFillable` rejects FULL outright, before any budget or victim is
|
||||
* considered, and `refreshWindowStatus` re-derives FULL from live capacity, so
|
||||
* a genuinely full train stays skipped. The scenario therefore books 52 of 53
|
||||
* slots: committed, one slot short, which is the closest reachable state to
|
||||
* "a full train" and still exercises the displacement.
|
||||
*
|
||||
* S8 — the retired USD_PAYER / RAIL_AND_FORWARDING priority RULES are gone
|
||||
* (ReplacePriorityRulesWithPriorityConfigs). The live model is
|
||||
* `priority_configs`, typed WAGON | CURRENCY | CUSTOMS and scored by
|
||||
* wagon-count band. The scenario's intent — tiered ordering, lowest tier gets
|
||||
* the split — is preserved against that mechanism. The CUSTOMS band only
|
||||
* applies when the booking's SERVICE TYPE bundles customs, which is why S8's
|
||||
* customs tenant is sold RAIL_CUSTOMS (seed-customs-service-type.sql).
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, gateway } from "./client";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
allocatedWagons,
|
||||
bookContainersReady,
|
||||
bookingRow,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
containerCount,
|
||||
containerWagons,
|
||||
createBuiltTrainSchedule,
|
||||
createGovernmentBooking,
|
||||
createPriorityConfig,
|
||||
departureAt,
|
||||
dropPriorityConfig,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectNoPartialOffer,
|
||||
extendPayWindow,
|
||||
forceOfferLapse,
|
||||
forceWindowOpen,
|
||||
governmentExpedite,
|
||||
linkedBookings,
|
||||
livePayableInvoices,
|
||||
payViaGateway,
|
||||
pinToSchedule,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollCycleConcluded,
|
||||
pollPartialOffer,
|
||||
pollWindow,
|
||||
releaseGovernmentBookings,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
setPriority,
|
||||
triggerBatchRun,
|
||||
} from "./flows";
|
||||
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// S6 — a split offer nobody takes
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("g1 s6: an ignored split offer expires the booking whole", () => {
|
||||
const DEPARTURE = departureAt(55);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const SHAPES = {
|
||||
SA: { twenty: 0, forty: 30, wagons: 30 },
|
||||
SB: { twenty: 40, forty: 0, wagons: 20 },
|
||||
SC: { twenty: 20, forty: 0, wagons: 10 },
|
||||
} as const;
|
||||
const ORDER = ["SA", "SB", "SC"] as const;
|
||||
const GAP = G1_WAGONS - SHAPES.SA.wagons - SHAPES.SB.wagons; // 3
|
||||
const RIDING = SHAPES.SA.wagons + SHAPES.SB.wagons; // 50
|
||||
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||
);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 25_000;
|
||||
for (const [i, suffix] of ORDER.entries()) {
|
||||
const shape = SHAPES[suffix];
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: shape.twenty,
|
||||
forty: shape.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
isoSeed += shape.twenty + shape.forty;
|
||||
await setPriority(booking.get(suffix)!, i + 1);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
it("SA and SB take 50 wagons; SC is offered the last 3", async () => {
|
||||
expect(containerWagons(SHAPES.SC.twenty, 0), "SC needs 10 wagons").toBe(SHAPES.SC.wagons);
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const suffix of ["SA", "SB"] as const) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||
}
|
||||
const offer = await pollPartialOffer(booking.get("SC")!);
|
||||
expect(Number(offer.offered_wagons), "SC offered the 3-wagon gap").toBe(GAP);
|
||||
});
|
||||
|
||||
it("SC ignores the offer for the whole window — it EXPIRES whole", async () => {
|
||||
await extendPayWindow(scheduleId, [booking.get("SA")!, booking.get("SB")!]);
|
||||
await payViaGateway(booking.get("SA")!);
|
||||
await pollAllocations(booking.get("SA")!, SHAPES.SA.wagons);
|
||||
await payViaGateway(booking.get("SB")!);
|
||||
await pollAllocations(booking.get("SB")!, SHAPES.SB.wagons);
|
||||
|
||||
// The offer dies with the booking's pay deadline; the tick settles it.
|
||||
await forceOfferLapse(booking.get("SC")!);
|
||||
|
||||
// "Whole" is the load-bearing word: an ignored PARTIAL must not leave the
|
||||
// booking silently reduced to the 3 wagons it was offered — the customer
|
||||
// still owns all 20 containers and can rebook them intact.
|
||||
const sc = await bookingRow(booking.get("SC")!);
|
||||
expect(sc.is_split, "SC was never split").not.toBe(true);
|
||||
expect(await containerCount(booking.get("SC")!), "SC's 20 containers intact").toBe(
|
||||
SHAPES.SC.twenty,
|
||||
);
|
||||
});
|
||||
|
||||
it("the train departs NOT FULL at 50/53 — the 3 offered wagons went unsold", async () => {
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollCycleConcluded(scheduleId);
|
||||
expect(await allocatedWagons(scheduleId), "50 wagons allocated").toBe(RIDING);
|
||||
expect(
|
||||
(await scheduleRow(scheduleId)).booking_window_status,
|
||||
"window not FULL at 50/53",
|
||||
).not.toBe("FULL");
|
||||
expect(GAP, "3 wagons wasted").toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// S7 — government preempts the lowest-priority commercial reservation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("g1 s7: a government booking preempts commercial", () => {
|
||||
const DEPARTURE = departureAt(56);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
/** GA outranks GB, so GB is the one preemption must take. */
|
||||
const SHAPES = {
|
||||
GA: { forty: 25, wagons: 25 },
|
||||
GB: { forty: 27, wagons: 27 },
|
||||
} as const;
|
||||
const ORDER = ["GA", "GB"] as const;
|
||||
/** More than the single free slot: the government booking cannot fit as-is. */
|
||||
const GOV_WAGONS = 15;
|
||||
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
let govBookingId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await releaseGovernmentBookings();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||
);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 26_000;
|
||||
for (const [i, suffix] of ORDER.entries()) {
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
}),
|
||||
);
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
await setPriority(booking.get(suffix)!, i + 1);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
it("GA pays and GB holds a reservation — 52 of 53 slots committed", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
for (const suffix of ORDER) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
}
|
||||
|
||||
await extendPayWindow(scheduleId, [booking.get("GA")!]);
|
||||
await payViaGateway(booking.get("GA")!);
|
||||
await pollAllocations(booking.get("GA")!, SHAPES.GA.wagons);
|
||||
|
||||
// GB stays UNPAID on purpose — it is the reservation the government
|
||||
// booking must displace — but its hold is widened so it does not lapse on
|
||||
// its own while the government booking is being created.
|
||||
await extendPayWindow(scheduleId, [booking.get("GB")!]);
|
||||
expect((await bookingRow(booking.get("GB")!)).status, "GB still reserved").toMatch(
|
||||
/SELECTED_FOR_BATCH|AWAITING_PAYMENT/,
|
||||
);
|
||||
expect(SHAPES.GA.wagons + SHAPES.GB.wagons, "52 of 53 committed").toBe(G1_WAGONS - 1);
|
||||
});
|
||||
|
||||
it("a government booking is created and expedited — PAID without paying", async () => {
|
||||
govBookingId = await createGovernmentBooking({ forty: GOV_WAGONS });
|
||||
// Idempotent: create() already expedites, the endpoint is the retry path.
|
||||
await governmentExpedite(govBookingId);
|
||||
|
||||
const gov = await bookingRow(govBookingId);
|
||||
expect(gov.status, "PAID after expedite").toBe("PAID");
|
||||
expect(gov.is_government, "flagged government").toBe(true);
|
||||
});
|
||||
|
||||
it("it carries the +50 000 bonus, far above any commercial score", async () => {
|
||||
const govScore = Number((await bookingRow(govBookingId)).priority_score);
|
||||
expect(govScore, "government bonus applied").toBeGreaterThanOrEqual(50_000);
|
||||
expect(
|
||||
Number((await bookingRow(booking.get("GA")!)).priority_score),
|
||||
"top commercial still far below government",
|
||||
).toBeLessThan(govScore);
|
||||
});
|
||||
|
||||
it("the fill displaces GB — the lower-priority reservation — and GA is untouched", async () => {
|
||||
// Staff pin, then run the batch: fillSchedule's pool is keyed on
|
||||
// `booking.train_schedule_id`, and the customer-facing pin is unavailable
|
||||
// because the window closed at doc review.
|
||||
await pinToSchedule(govBookingId, scheduleId);
|
||||
await triggerBatchRun(scheduleId);
|
||||
|
||||
await pollBookingStatus(booking.get("GB")!, "EXPIRED", 30);
|
||||
const gb = await bookingRow(booking.get("GB")!);
|
||||
expect(gb.scheduling_status, "GB back to ELIGIBLE").toBe("ELIGIBLE");
|
||||
expect(gb.payment_deadline, "GB pay window cleared").toBeNull();
|
||||
expect(await livePayableInvoices(booking.get("GB")!), "GB invoice closed out").toBe(0);
|
||||
|
||||
const ga = await bookingRow(booking.get("GA")!);
|
||||
expect(ga.status, "GA survives untouched").toBe("PAID");
|
||||
expect(ga.train_schedule_id, "GA still on this train").toBe(scheduleId);
|
||||
});
|
||||
|
||||
it("the government booking rides on the freed wagons — allocated, never invoiced", async () => {
|
||||
await pollAllocations(govBookingId, GOV_WAGONS);
|
||||
const gov = await bookingRow(govBookingId);
|
||||
expect(gov.scheduling_status, "government SCHEDULED").toBe("SCHEDULED");
|
||||
expect(await livePayableInvoices(govBookingId), "government rides unpaid").toBe(0);
|
||||
expect(await linkedBookings(scheduleId), "GA + government hold the seats").toBe(2);
|
||||
expect(await allocatedWagons(scheduleId), "25 commercial + 15 government").toBe(
|
||||
SHAPES.GA.wagons + GOV_WAGONS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// S8 — commercial priority tiers order the batch
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("g1 s8: priority tiers order the batch", () => {
|
||||
const DEPARTURE = departureAt(57);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
/** Three equal bookings — only the TIER differs, so ordering is the only
|
||||
* thing that can decide who is left with the remainder. */
|
||||
const SHAPES = {
|
||||
PA: { forty: 20, wagons: 20, tier: "USD payer" },
|
||||
PB: { forty: 20, wagons: 20, tier: "customs service" },
|
||||
PC: { forty: 20, wagons: 20, tier: "plain" },
|
||||
} as const;
|
||||
const ORDER = ["PA", "PB", "PC"] as const;
|
||||
/** 60 wagons of demand for 53 slots → the third gets a 13-wagon offer. */
|
||||
const GAP = G1_WAGONS - SHAPES.PA.wagons - SHAPES.PB.wagons; // 13
|
||||
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
let usdConfigId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
// The USD tier. The CUSTOMS bands (7 / 15 points) ship with the corridor
|
||||
// fixture, so only the currency band has to be added — and it is dropped in
|
||||
// afterAll, because an active band re-ranks every later file's pool.
|
||||
usdConfigId = await createPriorityConfig({
|
||||
type: "CURRENCY",
|
||||
label: `IT USD payer ${STAMP.slice(-5)}`,
|
||||
currency: "USD",
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 53,
|
||||
scorePoints: 35,
|
||||
});
|
||||
|
||||
const contracts = await seedTenantContracts(STAMP, [
|
||||
{ suffix: "PA", freight: "CONTAINER" as const, currency: "USD" as const },
|
||||
{
|
||||
suffix: "PB",
|
||||
freight: "CONTAINER" as const,
|
||||
customs: true,
|
||||
serviceTypeCode: "RAIL_CUSTOMS",
|
||||
},
|
||||
{ suffix: "PC", freight: "CONTAINER" as const },
|
||||
]);
|
||||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||
await forceWindowOpen(scheduleId, 60);
|
||||
|
||||
let isoSeed = 27_000;
|
||||
for (const suffix of ORDER) {
|
||||
booking.set(
|
||||
suffix,
|
||||
await bookContainersReady({
|
||||
contractId: contracts.get(suffix)!,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
customs: suffix === "PB",
|
||||
}),
|
||||
);
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await dropPriorityConfig(usdConfigId);
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
it("the engine scores USD above customs above plain — no manual priority set", async () => {
|
||||
// Deliberately no setPriority anywhere in this file: the point is that the
|
||||
// rule engine's own bands produce the order. Pinning the scores by hand
|
||||
// would test setPriority, not the tiers.
|
||||
const scores = new Map<string, number>();
|
||||
for (const suffix of ORDER) {
|
||||
scores.set(suffix, Number((await bookingRow(booking.get(suffix)!)).priority_score));
|
||||
}
|
||||
expect(scores.get("PA")!, "USD tier outranks the customs tier").toBeGreaterThan(
|
||||
scores.get("PB")!,
|
||||
);
|
||||
expect(scores.get("PB")!, "customs tier outranks plain").toBeGreaterThan(scores.get("PC")!);
|
||||
});
|
||||
|
||||
it("the two top tiers board whole; the lowest is offered the 13-wagon remainder", async () => {
|
||||
expect(GAP, "13-wagon remainder after the top two").toBe(13);
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const suffix of ["PA", "PB"] as const) {
|
||||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||
}
|
||||
const offer = await pollPartialOffer(booking.get("PC")!);
|
||||
expect(Number(offer.offered_wagons), "PC offered the exact remainder").toBe(GAP);
|
||||
});
|
||||
|
||||
it("all three settle — PC ships 13 of its 20 wagons and the train is FULL", async () => {
|
||||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||
await payViaGateway(booking.get("PA")!);
|
||||
await pollAllocations(booking.get("PA")!, SHAPES.PA.wagons);
|
||||
await payViaGateway(booking.get("PB")!);
|
||||
await pollAllocations(booking.get("PB")!, SHAPES.PB.wagons);
|
||||
await payViaGateway(booking.get("PC")!);
|
||||
await pollAllocations(booking.get("PC")!, GAP);
|
||||
|
||||
expect((await bookingRow(booking.get("PC")!)).is_split, "PC is the split one").toBe(true);
|
||||
expect(await containerCount(booking.get("PC")!), "PC shrank to 13 containers").toBe(GAP);
|
||||
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||
"FULL + DONE",
|
||||
);
|
||||
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||
});
|
||||
});
|
||||
70
integration/src/global-setup.ts
Normal file
70
integration/src/global-setup.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Runs once before any spec: wait for both APIs, then seed.
|
||||
*
|
||||
* Seeds are the Cypress suite's fixtures, reused verbatim (they are idempotent
|
||||
* `insert … where not exists`), plus one of our own for the second tenant:
|
||||
* seed-users.sql → seed-company.sql (order matters; company needs the users)
|
||||
* seed-import-corridor.sql (yards, locos, wagons, rates, distances)
|
||||
* seed-bulk-items.sql (PER_ITEM break-bulk cargo types)
|
||||
* seed-g1-train.sql (the 53-wagon BUILT container train)
|
||||
* seed-government.sql (the kind='government' company)
|
||||
* seed-company-b.sql (user2@gmail.com's company — this suite)
|
||||
* seed-customs-service-type.sql (a service type that bundles customs)
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "pg";
|
||||
|
||||
const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||
const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||
const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||
const DB_URL =
|
||||
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||
|
||||
const CYPRESS_FIXTURES = join(process.cwd(), "..", "e2e", "freight", "cypress", "fixtures");
|
||||
const OWN_FIXTURES = join(process.cwd(), "sql");
|
||||
|
||||
const SEEDS: Array<[dir: string, file: string]> = [
|
||||
[CYPRESS_FIXTURES, "seed-users.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-company.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-government.sql"],
|
||||
[OWN_FIXTURES, "seed-company-b.sql"],
|
||||
[OWN_FIXTURES, "seed-customs-service-type.sql"],
|
||||
];
|
||||
|
||||
async function waitFor(label: string, url: string, attempts = 60): Promise<void> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
throw new Error(`${label} never became healthy at ${url}`);
|
||||
}
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
await Promise.all([
|
||||
waitFor("freight-api", `${API}/api/health`),
|
||||
waitFor("payment-api", `${PAYMENT_API}/health`),
|
||||
waitFor("gateway-mock", `${GATEWAY}/__control/health`),
|
||||
]);
|
||||
|
||||
const client = new Client({ connectionString: DB_URL });
|
||||
await client.connect();
|
||||
try {
|
||||
for (const [dir, file] of SEEDS) {
|
||||
await client.query(readFileSync(join(dir, file), "utf8"));
|
||||
console.log(`it: seeded ${file}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
await fetch(`${GATEWAY}/__control/reset`, { method: "POST" });
|
||||
}
|
||||
249
integration/src/payment-failure.it.ts
Normal file
249
integration/src/payment-failure.it.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* What happens when the bank misbehaves. Each test forces the gateway mock
|
||||
* into a failure mode and asserts the platform's answer — the point being that
|
||||
* NO failure may ever settle an invoice that was not paid, and no failure may
|
||||
* lose a payment that was.
|
||||
*
|
||||
* Covered: provider down at initiate, hard decline, forged signature, replayed
|
||||
* callback, silent settlement found only by the reconciliation sweep, and the
|
||||
* unverifiable answer that must stop freight from expiring a paying customer.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway, payment, poll, sleep } from "./client";
|
||||
import {
|
||||
createImportSchedule,
|
||||
currentInvoice,
|
||||
departureAt,
|
||||
ensureCorridorRoute,
|
||||
releaseUnpaidHolds,
|
||||
forceWindowOpen,
|
||||
gatewayIntent,
|
||||
invoiceForBooking,
|
||||
payInvoice,
|
||||
prepareBooking,
|
||||
resetCorridorDay,
|
||||
runBatch,
|
||||
type ReadyBooking,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(6);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
/** Four independent bookings so one test's terminal state can't poison another. */
|
||||
const CASES = ["FAIL1", "FAIL2", "FAIL3", "FAIL4"] as const;
|
||||
type CaseName = (typeof CASES)[number];
|
||||
|
||||
describe("payment failure and recovery", () => {
|
||||
const bookings = new Map<CaseName, ReadyBooking>();
|
||||
const invoices = new Map<CaseName, string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||
await forceWindowOpen(schedule.id, 45);
|
||||
|
||||
let isoSeed = 100;
|
||||
for (const suffix of CASES) {
|
||||
bookings.set(
|
||||
suffix,
|
||||
await prepareBooking({
|
||||
suffix,
|
||||
departure: DEPARTURE,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: 2,
|
||||
}),
|
||||
);
|
||||
isoSeed += 2;
|
||||
}
|
||||
await runBatch(schedule.id);
|
||||
for (const suffix of CASES) {
|
||||
invoices.set(suffix, (await invoiceForBooking(bookings.get(suffix)!.bookingId)).id);
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("leaves the invoice payable when the provider is unreachable", async () => {
|
||||
const invoiceId = invoices.get("FAIL1")!;
|
||||
await gateway.mode("cbe-birr", "fail");
|
||||
|
||||
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// No phantom settlement, and the customer can retry.
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
expect(invoice.status).not.toBe("PAID");
|
||||
expect(invoice.paid_at).toBeNull();
|
||||
|
||||
await gateway.mode("cbe-birr", "ok");
|
||||
const retry = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||
expect(retry.status, JSON.stringify(retry.body)).toBeLessThanOrEqual(201);
|
||||
});
|
||||
|
||||
it("keeps the invoice open on a declined payment", async () => {
|
||||
const { bookingId } = bookings.get("FAIL2")!;
|
||||
const invoiceId = invoices.get("FAIL2")!;
|
||||
await gateway.mode("cbe-birr", "ok");
|
||||
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
await gateway.webhook({ merchantOrderId: intent.merchant_order_id, status: "FAILED" });
|
||||
|
||||
const failed = await poll<{ status: string }>(
|
||||
"intent FAILED",
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "FAILED",
|
||||
{ attempts: 20, intervalMs: 1000 },
|
||||
);
|
||||
expect(failed.status).toBe("FAILED");
|
||||
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
expect(invoice.status).not.toBe("PAID");
|
||||
});
|
||||
|
||||
it("ignores a forged signature — event recorded, money untouched", async () => {
|
||||
const { bookingId } = bookings.get("FAIL3")!;
|
||||
const invoiceId = invoices.get("FAIL3")!;
|
||||
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
const res = await gateway.webhook({
|
||||
merchantOrderId: intent.merchant_order_id,
|
||||
signature: "bad",
|
||||
});
|
||||
// Providers must still get a 2xx (Waafi times out at 5s and never retries).
|
||||
expect(res.body.delivered).toBe(200);
|
||||
|
||||
const event = await poll<{ signature_valid: boolean; processing_error: string | null }>(
|
||||
"forged webhook recorded",
|
||||
`SELECT signature_valid, processing_error FROM edr_payment.payment_webhook_event
|
||||
WHERE merchant_order_id = $1 ORDER BY received_at DESC LIMIT 1`,
|
||||
[intent.merchant_order_id],
|
||||
(row) => !!row,
|
||||
{ attempts: 15, intervalMs: 1000 },
|
||||
);
|
||||
expect(event.signature_valid).toBe(false);
|
||||
expect(event.processing_error).toBe("signature-invalid");
|
||||
|
||||
await sleep(3000);
|
||||
const after = await db<{ status: string }>(
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
);
|
||||
expect(after[0].status).not.toBe("SUCCEEDED");
|
||||
expect((await currentInvoice(invoiceId)).status).not.toBe("PAID");
|
||||
});
|
||||
|
||||
it("settles from the reconciliation sweep alone, with no callback at all", async () => {
|
||||
const { bookingId } = bookings.get("FAIL4")!;
|
||||
const invoiceId = invoices.get("FAIL4")!;
|
||||
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
// The customer pays at the bank, but the callback is lost in the network.
|
||||
await gateway.settle(intent.merchant_order_id);
|
||||
|
||||
// RECONCILE_STALE_AFTER_MS=5s, sweep every 30s — one sweep is enough.
|
||||
const settled = await poll<{ status: string }>(
|
||||
"intent settled by sweep",
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "SUCCEEDED",
|
||||
{ attempts: 30, intervalMs: 3000 },
|
||||
);
|
||||
expect(settled.status).toBe("SUCCEEDED");
|
||||
|
||||
const invoice = await poll<{ status: string }>(
|
||||
"invoice PAID via sweep",
|
||||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
expect(invoice.status).toBe("PAID");
|
||||
|
||||
// No webhook was ever delivered for this one.
|
||||
const [{ n }] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
||||
WHERE merchant_order_id = $1`,
|
||||
[intent.merchant_order_id],
|
||||
);
|
||||
expect(Number(n)).toBe(0);
|
||||
});
|
||||
|
||||
it("reports `unverifiable` when the gateway cannot answer, so freight must not expire the hold", async () => {
|
||||
// FAIL1 still has a live (unsettled) intent — FAIL2's is terminal, and a
|
||||
// reference with nothing to verify legitimately answers "not paid".
|
||||
const { bookingId } = bookings.get("FAIL1")!;
|
||||
await gateway.mode("cbe-birr", "timeout");
|
||||
|
||||
const res = await payment("post", "/payments/reconcile", {
|
||||
service: "FREIGHT",
|
||||
referenceType: "SHIPMENT",
|
||||
referenceId: bookingId,
|
||||
});
|
||||
await gateway.mode("cbe-birr", "ok");
|
||||
|
||||
expect([200, 201]).toContain(res.status);
|
||||
const body = res.body?.data ?? res.body;
|
||||
expect(body.paid).toBe(false);
|
||||
// An unknown answer must never read as "definitely unpaid" — that is what
|
||||
// stops the batch engine from expiring a customer who actually paid.
|
||||
expect(body.unverifiable).toBe(true);
|
||||
});
|
||||
|
||||
it("captures late: a settlement after the intent expired still pays the invoice", async () => {
|
||||
const { bookingId } = bookings.get("FAIL3")!;
|
||||
const invoiceId = invoices.get("FAIL3")!;
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
|
||||
// Retire the intent the way an expiry sweep would, then let the money land.
|
||||
await db(
|
||||
`UPDATE edr_payment.payment_intent
|
||||
SET status = 'EXPIRED', expires_at = now() - interval '1 minute'
|
||||
WHERE id = $1`,
|
||||
[intent.id],
|
||||
);
|
||||
await gateway.webhook({
|
||||
merchantOrderId: intent.merchant_order_id,
|
||||
eventId: `LATE-${intent.merchant_order_id}`,
|
||||
});
|
||||
|
||||
const captured = await poll<{ status: string }>(
|
||||
"late capture flips the intent",
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "SUCCEEDED",
|
||||
{ attempts: 20, intervalMs: 1000 },
|
||||
);
|
||||
expect(captured.status).toBe("SUCCEEDED");
|
||||
|
||||
const invoice = await poll<{ status: string }>(
|
||||
"invoice settled by late capture",
|
||||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
expect(invoice.status).toBe("PAID");
|
||||
});
|
||||
|
||||
it("retries delivery until the consumer is back", async () => {
|
||||
// Deliberately not covered here: it needs the freight container stopped
|
||||
// mid-test, which would break every other file sharing this stack.
|
||||
// `node integration/scripts/it.mjs logs` + a manual `docker compose stop
|
||||
// freight-api-e2e` reproduces it; the relay's backoff is unit-testable.
|
||||
// ponytail: outbox retry asserted only via attempts>0 below; add a
|
||||
// dedicated single-file stack if this ever regresses.
|
||||
const rows = await db<{ status: string; attempts: number }>(
|
||||
`SELECT status, attempts FROM edr_payment.notification_outbox ORDER BY created_at DESC LIMIT 20`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.every((r) => r.status !== "FAILED")).toBe(true);
|
||||
});
|
||||
});
|
||||
178
integration/src/payment-happy.it.ts
Normal file
178
integration/src/payment-happy.it.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* The freight ⇄ payment happy path, end to end through both services.
|
||||
*
|
||||
* portal pays invoice
|
||||
* → freight billing.payInvoice → payment API /payments/initiate
|
||||
* → CBE Birr provider → gateway mock (intent opened, invoice.payment_id set)
|
||||
* gateway calls back (correctly signed)
|
||||
* → payment API webhook pipeline → intent SUCCEEDED → outbox row
|
||||
* → RabbitMQ → freight consumer → settleByPaymentId
|
||||
* → invoice PAID → booking.invoice.paid → booking advances
|
||||
*
|
||||
* Nothing here is stubbed except the bank itself.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeDb,
|
||||
customerA,
|
||||
db,
|
||||
gateway,
|
||||
intentByMerchantOrderId,
|
||||
poll,
|
||||
} from "./client";
|
||||
import {
|
||||
createImportSchedule,
|
||||
currentInvoice,
|
||||
departureAt,
|
||||
ensureCorridorRoute,
|
||||
releaseUnpaidHolds,
|
||||
forceWindowOpen,
|
||||
freightPayment,
|
||||
gatewayIntent,
|
||||
invoiceForBooking,
|
||||
payInvoice,
|
||||
prepareBooking,
|
||||
resetCorridorDay,
|
||||
runBatch,
|
||||
type ReadyBooking,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(4);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
describe("freight invoice settles through the real payment service", () => {
|
||||
let booking: ReadyBooking;
|
||||
let invoiceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||
await forceWindowOpen(schedule.id, 45);
|
||||
|
||||
booking = await prepareBooking({
|
||||
suffix: "PAY1",
|
||||
departure: DEPARTURE,
|
||||
runStamp: STAMP,
|
||||
isoSeed: 0,
|
||||
twenty: 2,
|
||||
});
|
||||
await runBatch(schedule.id);
|
||||
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
|
||||
});
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("opens a gateway intent and links it to the invoice", async () => {
|
||||
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(booking.bookingId);
|
||||
expect(intent.provider).toBe("CBE_BIRR");
|
||||
expect(intent.status).toBe("REQUIRES_ACTION");
|
||||
expect(intent.merchant_order_id).toBeTruthy();
|
||||
|
||||
// The invoice must carry the intent id BEFORE any callback can arrive —
|
||||
// settlement correlates on it (billing.service.ts payInvoice).
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
expect(invoice.payment_id).toBeTruthy();
|
||||
|
||||
// Freight's local projection of the same intent.
|
||||
const projection = await freightPayment(invoice.payment_id!);
|
||||
expect(projection.status).toBe("action-required");
|
||||
expect(projection.merchant_order_id).toBe(intent.merchant_order_id);
|
||||
|
||||
// Freight deliberately sends a dev-shortcut amount for non-CBE_BILL
|
||||
// providers (payment.service.ts:238-247) — 1 minor unit, not the invoice
|
||||
// total. Asserted, not "fixed": changing it is a product decision.
|
||||
expect(Number(intent.amount_minor)).toBe(1);
|
||||
});
|
||||
|
||||
it("settles the invoice and advances the booking when the gateway calls back", async () => {
|
||||
const intent = await gatewayIntent(booking.bookingId);
|
||||
const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id });
|
||||
expect(res.body.delivered).toBe(200);
|
||||
|
||||
const settled = await poll<{ status: string }>(
|
||||
"payment intent SUCCEEDED",
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "SUCCEEDED",
|
||||
{ attempts: 20, intervalMs: 1000 },
|
||||
);
|
||||
expect(settled.status).toBe("SUCCEEDED");
|
||||
|
||||
// The outbox row is written in the SAME transaction as the intent update
|
||||
// and relayed to RabbitMQ by the relay loop.
|
||||
const outbox = await poll<{ status: string; attempts: number }>(
|
||||
"outbox row relayed",
|
||||
`SELECT status, attempts FROM edr_payment.notification_outbox
|
||||
WHERE intent_id = $1 AND event_type = 'payment.succeeded'`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "SENT",
|
||||
{ attempts: 20, intervalMs: 1000 },
|
||||
);
|
||||
expect(outbox.status).toBe("SENT");
|
||||
|
||||
// …and freight, on the other end of the broker, settles the invoice.
|
||||
const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>(
|
||||
"invoice PAID",
|
||||
`SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
expect(Number(invoice.balance_amount)).toBe(0);
|
||||
expect(Number(invoice.paid_amount)).toBeGreaterThan(0);
|
||||
|
||||
const booked = await poll<{ status: string }>(
|
||||
"booking advanced on payment",
|
||||
`SELECT status FROM freight.bookings WHERE id = $1`,
|
||||
[booking.bookingId],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 20, intervalMs: 2000 },
|
||||
);
|
||||
expect(booked.status).toBe("PAID");
|
||||
});
|
||||
|
||||
it("records exactly one intent, one webhook event and one ledger entry", async () => {
|
||||
const intent = await gatewayIntent(booking.bookingId);
|
||||
const intents = await intentByMerchantOrderId(intent.merchant_order_id);
|
||||
expect(intents.length).toBe(1);
|
||||
|
||||
const [{ n }] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
||||
WHERE merchant_order_id = $1`,
|
||||
[intent.merchant_order_id],
|
||||
);
|
||||
expect(Number(n)).toBe(1);
|
||||
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
const ledger = (invoice.payments ?? []) as unknown[];
|
||||
expect(Array.isArray(ledger) ? ledger.length : 0).toBe(1);
|
||||
});
|
||||
|
||||
it("is idempotent — a replayed callback changes nothing", async () => {
|
||||
const intent = await gatewayIntent(booking.bookingId);
|
||||
const before = await currentInvoice(invoiceId);
|
||||
|
||||
// Same orderId ⇒ same externalEventId ⇒ deduped at the webhook table.
|
||||
await gateway.webhook({
|
||||
merchantOrderId: intent.merchant_order_id,
|
||||
eventId: `CBEORD-${intent.merchant_order_id}`,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
const after = await currentInvoice(invoiceId);
|
||||
expect(after.status).toBe("PAID");
|
||||
expect(after.paid_amount).toBe(before.paid_amount);
|
||||
expect((after.payments as unknown[]).length).toBe((before.payments as unknown[]).length);
|
||||
});
|
||||
|
||||
it("refuses a second payment on an already-paid invoice", async () => {
|
||||
const res = await payInvoice(invoiceId, { method: "CBE_BIRR", as: customerA });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
16
integration/tsconfig.json
Normal file
16
integration/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
18
integration/vitest.config.ts
Normal file
18
integration/vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.it.ts"],
|
||||
globalSetup: ["src/global-setup.ts"],
|
||||
// One shared containerized stack and one shared database: files run in
|
||||
// sequence. Concurrency is exercised INSIDE tests (Promise.all), which is
|
||||
// what the guards under test actually see in production.
|
||||
fileParallelism: false,
|
||||
testTimeout: 180_000,
|
||||
hookTimeout: 180_000,
|
||||
// Booking/scheduling steps are not idempotent — a retry would assert
|
||||
// against a half-advanced booking.
|
||||
retry: 0,
|
||||
reporters: ["verbose"],
|
||||
},
|
||||
});
|
||||
@@ -31,6 +31,10 @@
|
||||
"e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run",
|
||||
"e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci",
|
||||
"e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down",
|
||||
"it:up": "node integration/scripts/it.mjs up",
|
||||
"it:test": "node integration/scripts/it.mjs test",
|
||||
"it:logs": "node integration/scripts/it.mjs logs",
|
||||
"it:down": "node integration/scripts/it.mjs down",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
426
pnpm-lock.yaml
generated
426
pnpm-lock.yaml
generated
@@ -595,7 +595,7 @@ importers:
|
||||
version: 5.101.0(react@19.2.6)
|
||||
'@tria-plc/iamui':
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
'@vis.gl/react-google-maps':
|
||||
specifier: ^1.8.3
|
||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -1245,6 +1245,30 @@ importers:
|
||||
specifier: ^5.5.4
|
||||
version: 5.9.3
|
||||
|
||||
integration:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.20.1
|
||||
'@types/pg':
|
||||
specifier: ^8.11.0
|
||||
version: 8.20.0
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
pg:
|
||||
specifier: ^8.13.0
|
||||
version: 8.21.0
|
||||
supertest:
|
||||
specifier: ^7.0.0
|
||||
version: 7.2.2
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0)
|
||||
|
||||
packages/api-common:
|
||||
dependencies:
|
||||
'@edr/types':
|
||||
@@ -12367,11 +12391,11 @@ snapshots:
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -12406,7 +12430,7 @@ snapshots:
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12415,14 +12439,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-member-expression-to-functions@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12437,9 +12454,9 @@ snapshots:
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -12454,13 +12471,13 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12613,18 +12630,6 @@ snapshots:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -12850,7 +12855,7 @@ snapshots:
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/runtime': 7.29.7
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
@@ -13016,7 +13021,7 @@ snapshots:
|
||||
'@eslint/eslintrc@2.1.4':
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
espree: 9.6.1
|
||||
globals: 13.24.0
|
||||
ignore: 5.3.2
|
||||
@@ -13176,7 +13181,7 @@ snapshots:
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13206,6 +13211,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.42
|
||||
|
||||
'@inquirer/confirm@6.1.1(@types/node@22.20.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@22.20.1)
|
||||
'@inquirer/type': 4.0.7(@types/node@22.20.1)
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
optional: true
|
||||
|
||||
'@inquirer/confirm@6.1.1(@types/node@24.13.1)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@24.13.1)
|
||||
@@ -13226,6 +13239,19 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.42
|
||||
|
||||
'@inquirer/core@11.2.1(@types/node@22.20.1)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@22.20.1)
|
||||
cli-width: 4.1.0
|
||||
fast-wrap-ansi: 0.2.2
|
||||
mute-stream: 3.0.0
|
||||
signal-exit: 4.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.2.1(@types/node@24.13.1)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
@@ -13348,6 +13374,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.42
|
||||
|
||||
'@inquirer/type@4.0.7(@types/node@22.20.1)':
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.0.7(@types/node@24.13.1)':
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.1
|
||||
@@ -14347,7 +14378,7 @@ snapshots:
|
||||
|
||||
'@puppeteer/browsers@2.13.2':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
extract-zip: 2.0.1
|
||||
progress: 2.0.3
|
||||
proxy-agent: 6.5.0
|
||||
@@ -16415,7 +16446,7 @@ snapshots:
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -16704,130 +16735,6 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
|
||||
dependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
|
||||
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf/renderer': 4.5.1(react@19.2.6)
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
'@tanstack/react-query': 5.101.0(react@19.2.6)
|
||||
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
|
||||
'@types/dompurify': 3.2.0
|
||||
'@types/node': 24.13.1
|
||||
'@types/tinymce': 4.6.9
|
||||
axios: 1.17.0
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
date-fns: 3.6.0
|
||||
dayjs: 1.11.21
|
||||
dompurify: 3.4.8
|
||||
ethiopian-calendar-date-converter: 2.1.6
|
||||
ethiopian-calendar-new: 1.1.0
|
||||
file-type: 18.7.0
|
||||
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
html2canvas: 1.4.1
|
||||
i18next: 25.10.10(typescript@5.9.3)
|
||||
i18next-browser-languagedetector: 8.2.1
|
||||
jquery: 3.7.1
|
||||
js-cookie: 3.0.8
|
||||
jspdf: 3.0.4
|
||||
lodash: 4.18.1
|
||||
lucide-react: 0.513.0(react@19.2.6)
|
||||
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
|
||||
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
path: 0.12.7
|
||||
pdf-lib: 1.17.1
|
||||
qs: 6.15.2
|
||||
react: 19.2.6
|
||||
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
|
||||
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-dropzone: 14.4.1(react@19.2.6)
|
||||
react-hook-form: 7.77.0(react@19.2.6)
|
||||
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
react-icons: 5.6.0(react@19.2.6)
|
||||
react-image-crop: 11.0.10(react@19.2.6)
|
||||
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
|
||||
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
|
||||
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
|
||||
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
|
||||
socket.io-client: 4.8.3
|
||||
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
tailwind-merge: 3.6.0
|
||||
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
|
||||
tailwindcss: 4.3.0
|
||||
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
|
||||
tinymce: 7.9.3
|
||||
url: 0.11.4
|
||||
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
xlsx: 0.18.5
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@emotion/is-prop-valid'
|
||||
- '@mui/icons-material'
|
||||
- '@mui/material'
|
||||
- '@mui/x-date-pickers'
|
||||
- '@types/prop-types'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- bufferutil
|
||||
- debug
|
||||
- pdfjs-dist
|
||||
- prop-types
|
||||
- react-is
|
||||
- react-native
|
||||
- redux
|
||||
- rolldown
|
||||
- rollup
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
@@ -17206,7 +17113,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -17216,7 +17123,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -17235,7 +17142,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
@@ -17250,7 +17157,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.2
|
||||
tinyglobby: 0.2.17
|
||||
@@ -17374,6 +17281,15 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/mocker@2.1.9(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.14.6(@types/node@22.20.1)(typescript@5.9.3)
|
||||
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
|
||||
'@vitest/mocker@2.1.9(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
@@ -17530,7 +17446,7 @@ snapshots:
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -18054,16 +17970,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
|
||||
dependencies:
|
||||
'@babel/helper-annotate-as-pure': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
||||
picomatch: 4.0.4
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-polyfill@6.26.0:
|
||||
dependencies:
|
||||
babel-runtime: 6.26.0
|
||||
@@ -18217,7 +18123,7 @@ snapshots:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
@@ -19218,7 +19124,7 @@ snapshots:
|
||||
engine.io-client@6.6.5:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.20.1
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
@@ -19238,7 +19144,7 @@ snapshots:
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
@@ -19471,7 +19377,7 @@ snapshots:
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
get-tsconfig: 4.14.0
|
||||
is-bun-module: 2.0.0
|
||||
@@ -19599,7 +19505,7 @@ snapshots:
|
||||
ajv: 6.15.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
doctrine: 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 7.2.2
|
||||
@@ -19829,7 +19735,7 @@ snapshots:
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
@@ -19882,7 +19788,7 @@ snapshots:
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
@@ -20035,7 +19941,7 @@ snapshots:
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
@@ -20281,7 +20187,7 @@ snapshots:
|
||||
dependencies:
|
||||
basic-ftp: 5.3.1
|
||||
data-uri-to-buffer: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -20562,7 +20468,7 @@ snapshots:
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -20575,14 +20481,14 @@ snapshots:
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -21007,7 +20913,7 @@ snapshots:
|
||||
|
||||
istanbul-lib-source-maps@4.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
source-map: 0.6.1
|
||||
transitivePeerDependencies:
|
||||
@@ -21655,7 +21561,7 @@ snapshots:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
commander: 13.1.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
execa: 8.0.1
|
||||
lilconfig: 3.1.3
|
||||
listr2: 8.3.3
|
||||
@@ -22058,6 +21964,32 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 6.1.1(@types/node@22.20.1)
|
||||
'@mswjs/interceptors': 0.41.9
|
||||
'@open-draft/deferred-promise': 3.0.0
|
||||
'@types/statuses': 2.0.6
|
||||
cookie: 1.1.1
|
||||
graphql: 16.14.1
|
||||
headers-polyfill: 5.0.1
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
path-to-regexp: 6.3.0
|
||||
picocolors: 1.1.1
|
||||
rettime: 0.11.11
|
||||
statuses: 2.0.2
|
||||
strict-event-emitter: 0.5.1
|
||||
tough-cookie: 6.0.1
|
||||
type-fest: 5.7.0
|
||||
until-async: 3.0.2
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
optional: true
|
||||
|
||||
msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 6.1.1(@types/node@24.13.1)
|
||||
@@ -22432,7 +22364,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-uri: 6.0.5
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
@@ -22772,7 +22704,7 @@ snapshots:
|
||||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
@@ -22801,7 +22733,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@puppeteer/browsers': 2.13.2
|
||||
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
devtools-protocol: 0.0.1608973
|
||||
typed-query-selector: 2.12.2
|
||||
webdriver-bidi-protocol: 0.4.1
|
||||
@@ -23054,15 +22986,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
||||
dependencies:
|
||||
date-fns: 3.6.0
|
||||
@@ -23636,7 +23559,7 @@ snapshots:
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
@@ -23754,7 +23677,7 @@ snapshots:
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -23972,7 +23895,7 @@ snapshots:
|
||||
|
||||
socket.io-adapter@2.5.8:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -23982,7 +23905,7 @@ snapshots:
|
||||
socket.io-client@4.8.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-client: 6.6.5
|
||||
socket.io-parser: 4.2.6
|
||||
transitivePeerDependencies:
|
||||
@@ -23993,7 +23916,7 @@ snapshots:
|
||||
socket.io-parser@4.2.6:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -24002,7 +23925,7 @@ snapshots:
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io: 6.6.9
|
||||
socket.io-adapter: 2.5.8
|
||||
socket.io-parser: 4.2.6
|
||||
@@ -24014,7 +23937,7 @@ snapshots:
|
||||
socks-proxy-agent@8.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
socks: 2.8.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -24294,24 +24217,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
'@emotion/stylis': 0.8.5
|
||||
'@emotion/unitless': 0.7.5
|
||||
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
|
||||
css-to-react-native: 3.2.0
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-is: 19.2.7
|
||||
shallowequal: 1.1.0
|
||||
supports-color: 5.5.0
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -24337,7 +24242,7 @@ snapshots:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.5
|
||||
formidable: 3.5.4
|
||||
@@ -24846,7 +24751,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -24870,7 +24775,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -25170,10 +25075,28 @@ snapshots:
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
@@ -25188,6 +25111,17 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.15
|
||||
rollup: 4.61.1
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
fsevents: 2.3.3
|
||||
lightningcss: 1.32.0
|
||||
terser: 5.48.0
|
||||
|
||||
vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
@@ -25199,6 +25133,42 @@ snapshots:
|
||||
lightningcss: 1.32.0
|
||||
terser: 5.48.0
|
||||
|
||||
vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
vite-node: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
jsdom: 25.0.1
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vitest@2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
@@ -25209,7 +25179,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
||||
@@ -5,6 +5,7 @@ packages:
|
||||
- "packages/*"
|
||||
- "packages/config/*"
|
||||
- "e2e/*"
|
||||
- "integration"
|
||||
verifyDepsBeforeRun: warn
|
||||
allowBuilds:
|
||||
"@nestjs/core": true
|
||||
|
||||
Reference in New Issue
Block a user