mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 15:21:08 +00:00
f92aea80a7
The UI ships with the server and the binary through the stores, so the two drift. TrekShell.getCapabilities now reports the binary's shell API, version and native features by name (iOS and Android), and the UI turns each native feature on only where the binary has it; a binary from before the handshake counts as shell API 1 with none. The other way round, the server names the oldest binary its UI works with (minNativeShellApi), and an app below it is asked to update with a link to its store.
206 lines
8.2 KiB
TypeScript
206 lines
8.2 KiB
TypeScript
import { ADDON_IDS } from '../../../src/addons';
|
|
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
|
|
import { BookingImportModule } from '../../../src/nest/booking-import/booking-import.module';
|
|
import { KitineraryExtractorModule } from '../../../src/nest/booking-import/kitinerary-extractor.module';
|
|
import { KitineraryExtractorService } from '../../../src/nest/booking-import/kitinerary-extractor.service';
|
|
import { MaintenanceModule } from '../../../src/nest/database/maintenance.module';
|
|
import { FeaturesController } from '../../../src/nest/health/features.controller';
|
|
import { HealthModule } from '../../../src/nest/health/health.module';
|
|
import { ReadinessService } from '../../../src/nest/health/readiness.service';
|
|
import { expectRegisteredController, expectRegisteredProvider } from '../../helpers/module-providers';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
type Extractor = Pick<KitineraryExtractorService, 'isAvailable'>;
|
|
type Addons = { isAddonEnabled: (id: string) => boolean };
|
|
|
|
function make(available: boolean, aiEnabled: boolean) {
|
|
const extractor = { isAvailable: vi.fn(() => available) };
|
|
const addons = { isAddonEnabled: vi.fn(() => aiEnabled) };
|
|
return {
|
|
extractor,
|
|
addons,
|
|
controller: new FeaturesController(
|
|
extractor as Extractor as KitineraryExtractorService,
|
|
addons as never,
|
|
{} as never,
|
|
new ReadinessService(),
|
|
),
|
|
};
|
|
}
|
|
|
|
describe('FeaturesController (GET /api/health/features)', () => {
|
|
it('FEAT-001: reports both flags on', async () => {
|
|
const { controller } = make(true, true);
|
|
expect(await controller.features()).toEqual({
|
|
bookingImport: true,
|
|
aiParsing: true,
|
|
aiAssistant: true,
|
|
nativeApp: true,
|
|
nativeApi: 1,
|
|
minNativeShellApi: 1,
|
|
});
|
|
});
|
|
|
|
it('FEAT-002: reports both flags off', async () => {
|
|
const { controller } = make(false, false);
|
|
expect(await controller.features()).toEqual({
|
|
bookingImport: false,
|
|
aiParsing: false,
|
|
aiAssistant: false,
|
|
nativeApp: true,
|
|
nativeApi: 1,
|
|
minNativeShellApi: 1,
|
|
});
|
|
});
|
|
|
|
it('FEAT-003: the two flags are independent', async () => {
|
|
expect(await make(true, false).controller.features()).toEqual({
|
|
bookingImport: true,
|
|
aiParsing: false,
|
|
aiAssistant: false,
|
|
nativeApp: true,
|
|
nativeApi: 1,
|
|
minNativeShellApi: 1,
|
|
});
|
|
expect(await make(false, true).controller.features()).toEqual({
|
|
bookingImport: false,
|
|
aiParsing: true,
|
|
aiAssistant: true,
|
|
nativeApp: true,
|
|
nativeApi: 1,
|
|
minNativeShellApi: 1,
|
|
});
|
|
});
|
|
|
|
it('FEAT-004: aiParsing asks the addons service for the LLM parsing addon specifically', async () => {
|
|
const { controller, addons } = make(true, true);
|
|
await controller.features();
|
|
expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.LLM_PARSING);
|
|
});
|
|
|
|
it('FEAT-013: aiAssistant follows its own addon, and is off on a managed install whatever the addon says', async () => {
|
|
const { controller, addons } = make(true, true);
|
|
await controller.features();
|
|
expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.AI_ASSISTANT);
|
|
vi.stubEnv('TREK_MANAGED', 'true');
|
|
try {
|
|
expect((await controller.features()).aiAssistant).toBe(false);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it('FEAT-014: tells the native apps it speaks their sign-in hand-off, and at which version', async () => {
|
|
const features = await make(false, false).controller.features();
|
|
expect(features.nativeApp).toBe(true);
|
|
expect(features.nativeApi).toBe(1);
|
|
// And which app binaries its UI still works with.
|
|
expect(features.minNativeShellApi).toBe(1);
|
|
});
|
|
|
|
it('FEAT-005: bookingImport is read live, not cached at construction', async () => {
|
|
const { controller, extractor } = make(false, false);
|
|
await controller.features();
|
|
extractor.isAvailable.mockReturnValue(true);
|
|
expect((await controller.features()).bookingImport).toBe(true);
|
|
expect(extractor.isAvailable).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('FEAT-006: the route lives on the health module, not on booking-import', () => {
|
|
expectRegisteredController(HealthModule, FeaturesController);
|
|
const bookingControllers = Reflect.getMetadata('controllers', BookingImportModule) as unknown[];
|
|
expect(bookingControllers).not.toContain(FeaturesController);
|
|
});
|
|
|
|
it('FEAT-007: the extractor is reachable from health without importing the booking-import domain', () => {
|
|
// The whole point of the leaf module: health depends on the probe, not on
|
|
// llm-parse/reservations/budget/maps/places behind BookingImportModule.
|
|
expectRegisteredProvider(KitineraryExtractorModule, KitineraryExtractorService);
|
|
const healthImports = Reflect.getMetadata('imports', HealthModule) as unknown[];
|
|
expect(Array.isArray(healthImports)).toBe(true);
|
|
expect(healthImports).toEqual(expect.arrayContaining([KitineraryExtractorModule, MaintenanceModule]));
|
|
expect(healthImports).not.toContain(BookingImportModule);
|
|
});
|
|
|
|
it('FEAT-008: the controller keeps its public path', () => {
|
|
expect(Reflect.getMetadata('path', FeaturesController)).toBe('api/health');
|
|
expect(Reflect.getMetadata('path', FeaturesController.prototype.features)).toBe('features');
|
|
});
|
|
|
|
it('FEAT-009: GET /api/health writes the exact probe bytes (legacy parity)', () => {
|
|
const { controller } = make(true, true);
|
|
const res = {
|
|
headers: {} as Record<string, string>,
|
|
body: undefined as unknown,
|
|
setHeader: vi.fn(function (this: { headers: Record<string, string> }, k: string, v: string) {
|
|
this.headers[k] = v;
|
|
}),
|
|
json: vi.fn(function (this: { body: unknown }, b: unknown) {
|
|
this.body = b;
|
|
}),
|
|
};
|
|
controller.health(res as never);
|
|
expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate');
|
|
expect(res.body).toEqual({ status: 'ok' });
|
|
});
|
|
|
|
describe('the readiness probe (GET /api/health/ready)', () => {
|
|
function response() {
|
|
const res = { setHeader: vi.fn(), status: vi.fn(), json: vi.fn() };
|
|
res.status.mockReturnValue(res);
|
|
return res;
|
|
}
|
|
|
|
it('FEAT-010: answers ready while the database answers, and 503 when it does not', async () => {
|
|
const execute = vi.fn().mockResolvedValue([]);
|
|
const em = { getConnection: () => ({ execute }), getContext: () => em };
|
|
const extractor = { isAvailable: vi.fn(() => true) };
|
|
const controller = new FeaturesController(
|
|
extractor as Extractor as KitineraryExtractorService,
|
|
{} as never,
|
|
new MaintenanceRepository(em as never),
|
|
new ReadinessService(),
|
|
);
|
|
|
|
const ok = response();
|
|
await controller.ready(ok as never);
|
|
expect(execute).toHaveBeenCalledWith('SELECT 1');
|
|
expect(ok.json).toHaveBeenCalledWith({ status: 'ready' });
|
|
expect(ok.status).not.toHaveBeenCalled();
|
|
|
|
execute.mockRejectedValueOnce(new Error('connection is not available (restore in progress?)'));
|
|
const down = response();
|
|
await controller.ready(down as never);
|
|
expect(down.status).toHaveBeenCalledWith(503);
|
|
expect(down.json).toHaveBeenCalledWith({ status: 'unavailable' });
|
|
});
|
|
|
|
it('FEAT-011: answers 503 once a shutdown has started, without asking the database', async () => {
|
|
const execute = vi.fn().mockResolvedValue([]);
|
|
const em = { getConnection: () => ({ execute }), getContext: () => em };
|
|
const readiness = new ReadinessService();
|
|
const controller = new FeaturesController(
|
|
{} as never,
|
|
{} as never,
|
|
new MaintenanceRepository(em as never),
|
|
readiness,
|
|
);
|
|
|
|
readiness.markDraining();
|
|
const draining = response();
|
|
await controller.ready(draining as never);
|
|
expect(draining.status).toHaveBeenCalledWith(503);
|
|
expect(draining.json).toHaveBeenCalledWith({ status: 'unavailable' });
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FEAT-012: Nest closing the app marks the process as draining too', () => {
|
|
const readiness = new ReadinessService();
|
|
expect(readiness.isDraining()).toBe(false);
|
|
readiness.beforeApplicationShutdown();
|
|
expect(readiness.isDraining()).toBe(true);
|
|
});
|
|
});
|
|
});
|