mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
168 lines
7.3 KiB
TypeScript
168 lines
7.3 KiB
TypeScript
/**
|
|
* `airtrail-sync` cron tick request-context ratchet (Plan 3d Task 0,
|
|
* deliverable 4b — inventory §12, the Plan 3a `*-003` shape
|
|
* `orm-request-context-seams.test.ts`'s SEAM-003 established for
|
|
* `PlaceShadowRetentionJob`).
|
|
*
|
|
* `AirtrailSyncJob.tick` → `AirtrailSyncService.runAirtrailSync` →
|
|
* `syncOwner` is the ONLY cron in the program that reaches a 3d service
|
|
* (`ReservationsService.getReservation`/`.update`, inventory §12's boot/cron
|
|
* table) — `airtrail-sync-boot` is already in BOOT-SWEEP-001's name list
|
|
* (it reads `app_settings` only, raw), but nothing before this file drove
|
|
* the TICK itself (not the boot sweep) through the real, production-wired
|
|
* `CronRegistrarService` into `ReservationsService`. `register()` already
|
|
* wraps every registered tick in `withRequestContext` unconditionally, fail
|
|
* closed on a missing ORM (`cron-registrar.service.ts`) — this pins that the
|
|
* wrap is actually reached from THIS job's tick, the same way SEAM-003 pins
|
|
* it for `PlaceShadowRetentionJob`'s.
|
|
*
|
|
* `ReservationsService` is not yet repository-backed (Plan 3d Tasks 2/4
|
|
* convert it) — the wrap around `getReservation` below performs a REAL
|
|
* repository read from exactly the point in the tick where the real,
|
|
* converted implementation will sit, proving the context is live now, ready
|
|
* for that conversion, the same technique `feeds-request-context.test.ts`'s
|
|
* SEAM-FEED-001 uses for the anonymous ICS routes.
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
|
import { Users } from '../../src/db/entities/Users.entity';
|
|
import { AirtrailLinkService } from '../../src/nest/integrations/airtrail-link.service';
|
|
import { AirtrailSyncJob } from '../../src/nest/integrations/airtrail-sync.job';
|
|
import { AirtrailClient, type AirtrailFlightRaw } from '../../src/nest/integrations/airtrail.client';
|
|
import { AirtrailService } from '../../src/nest/integrations/airtrail.service';
|
|
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
|
|
import { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
|
|
import { createTrip, createUser } from '../helpers/factories';
|
|
import { deleteRows, insertRow } from '../helpers/factories/rows';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
describe('airtrail-sync cron tick runs inside a request context', () => {
|
|
let app: INestApplication;
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
testDb.close();
|
|
});
|
|
|
|
it('AIRTRAIL-CTX-001: firing the real registered tick reaches ReservationsService.getReservation with a live request context, against the SAME MikroORM app.get(MikroORM) does', async () => {
|
|
const orm = app.get(MikroORM);
|
|
const registrar = app.get(CronRegistrarService);
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const reservationId = await insertRow(orm, Reservations, {
|
|
trip: trip.id,
|
|
title: 'AirTrail Flight',
|
|
type: 'flight',
|
|
status: 'confirmed',
|
|
external_source: 'airtrail',
|
|
external_id: '999',
|
|
external_owner_user_id: user.id,
|
|
sync_enabled: 1,
|
|
external_hash: 'stale-hash',
|
|
});
|
|
|
|
const flight: AirtrailFlightRaw = {
|
|
id: 999,
|
|
from: null,
|
|
to: null,
|
|
date: '2026-01-01',
|
|
datePrecision: 'day',
|
|
departure: null,
|
|
arrival: null,
|
|
departureScheduled: null,
|
|
arrivalScheduled: null,
|
|
airline: null,
|
|
flightNumber: null,
|
|
aircraft: null,
|
|
aircraftReg: null,
|
|
note: null,
|
|
};
|
|
|
|
const link = app.get(AirtrailLinkService);
|
|
const airtrail = app.get(AirtrailService);
|
|
const client = app.get(AirtrailClient);
|
|
const reservations = app.get(ReservationsService);
|
|
|
|
const syncEnabledSpy = vi.spyOn(link, 'syncGloballyEnabled').mockResolvedValue(true);
|
|
const credsSpy = vi.spyOn(airtrail, 'getAirtrailCredentials').mockResolvedValue({
|
|
baseUrl: 'https://airtrail.test',
|
|
apiKey: 'test-key',
|
|
allowInsecureTls: false,
|
|
});
|
|
const flightsSpy = vi.spyOn(client, 'listFlights').mockResolvedValue([flight]);
|
|
|
|
let repoRead: unknown;
|
|
let caught: unknown;
|
|
const originalGetReservation = reservations.getReservation.bind(reservations);
|
|
const getReservationSpy = vi.spyOn(reservations, 'getReservation').mockImplementation(async (id, tripId) => {
|
|
try {
|
|
repoRead = await orm.em.getRepository(Users).findOne({ id: user.id });
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
return originalGetReservation(id, tripId);
|
|
});
|
|
// Item 10 (Plan 3d Task 7 review): the tick's own request-context proof
|
|
// stopped at `getReservation` — `syncOwner` only calls `update` AFTER
|
|
// that read, when the incoming flight's hash actually differs from the
|
|
// stored `external_hash` (it does here, 'stale-hash' vs anything real),
|
|
// so a context regression that only showed up on the WRITE half of this
|
|
// same tick was invisible to this test. `update` is real here (not
|
|
// re-mocked), so this also proves it runs to completion without itself
|
|
// throwing `cannotUseGlobalContext`.
|
|
const updateSpy = vi.spyOn(reservations, 'update');
|
|
|
|
const isEnabledSpy = vi.spyOn(registrar, 'isEnabled').mockReturnValue(true);
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
try {
|
|
// Real onApplicationBootstrap: registers the tick for real through
|
|
// CronRegistrarService.register (task-6's withRequestContext wrap),
|
|
// the same choke point SEAM-003 pins for PlaceShadowRetentionJob.
|
|
await app.get(AirtrailSyncJob).onApplicationBootstrap();
|
|
const job = app.get(SchedulerRegistry).getCronJob('airtrail-sync');
|
|
await job.fireOnTick();
|
|
// cron's CronJob.fireOnTick() only awaits its callback with
|
|
// `waitForCompletion: true` (SEAM-003's own docstring) — register()
|
|
// does not set that option either, so give the awaited
|
|
// withRequestContext(orm, tick) promise a chance to actually settle.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(caught).toBeUndefined();
|
|
expect(repoRead).toBeTruthy();
|
|
expect(getReservationSpy).toHaveBeenCalled();
|
|
expect(updateSpy).toHaveBeenCalled();
|
|
|
|
const suspicious = errSpy.mock.calls
|
|
.map((args) => args.map(String).join(' '))
|
|
.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
|
|
expect(suspicious).toEqual([]);
|
|
} finally {
|
|
registrar.unregister('airtrail-sync');
|
|
isEnabledSpy.mockRestore();
|
|
errSpy.mockRestore();
|
|
getReservationSpy.mockRestore();
|
|
updateSpy.mockRestore();
|
|
flightsSpy.mockRestore();
|
|
credsSpy.mockRestore();
|
|
syncEnabledSpy.mockRestore();
|
|
await deleteRows(orm, Reservations, { id: reservationId });
|
|
}
|
|
});
|
|
});
|