mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
e2ce2ad05c
Service, MCP, integration and e2e suites with up to a dozen raw statements now seed and read through the factories. A buildApp() suite uses the app's own ORM, a partial-module e2e or a unit suite a createTestOrm() or sharedTestOrm() handle on its database. Reads that used to come back undefined now come back null, and the assertions say so; where a projected SELECT was compared whole, the same columns are picked off the row. Two reads inside the scheduler's delivery callback stay synchronous raw statements, marked, so they keep seeing the row at the moment of delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in the module no longer exports.
166 lines
6.5 KiB
TypeScript
166 lines
6.5 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { RouteUsageService, RETENTION_DAYS } from '../../../src/nest/route-usage/route-usage.service';
|
|
import type { RouteUsageEntry } from '@trek/shared';
|
|
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
|
import { RouteUsageDaily } from '../../../src/db/entities/RouteUsageDaily.entity';
|
|
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
|
import { countRows, insertRow } from '../../helpers/factories/rows';
|
|
|
|
/**
|
|
* SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router".
|
|
*
|
|
* Aggregates, not a log: the same day and kind adds onto one row, and the summary
|
|
* turns those rows into the two figures the decision needs, requests on an average
|
|
* day and requests on the busiest one.
|
|
*/
|
|
|
|
function makeDb(): Database.Database {
|
|
const db = new Database(':memory:');
|
|
db.exec(`
|
|
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);
|
|
CREATE TABLE route_usage_daily (
|
|
day TEXT NOT NULL, profile TEXT NOT NULL, surface TEXT NOT NULL,
|
|
self_hosted INTEGER NOT NULL, requests INTEGER NOT NULL DEFAULT 0,
|
|
waypoints INTEGER NOT NULL DEFAULT 0, km REAL NOT NULL DEFAULT 0,
|
|
failed INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (day, profile, surface, self_hosted)
|
|
);
|
|
`);
|
|
return db;
|
|
}
|
|
|
|
/**
|
|
* Plan 3h Task 4 (R4/RU2): the real `RouteUsageDailyRepository`/
|
|
* `AppSettingsRepository`, over the SAME MikroORM `createTestUnitOfWork`
|
|
* shares (`sharedTestOrm`, one ORM per handle) — the additive-counter Kysely
|
|
* upsert (RU2) needs a genuine MikroORM/Kysely connection, not a raw bridge.
|
|
*/
|
|
async function serviceOver(db: Database.Database): Promise<RouteUsageService> {
|
|
const t = await sharedTestOrm(db);
|
|
return new RouteUsageService(t.repo(RouteUsageDaily), t.repo(AppSettings), await createTestUnitOfWork(db));
|
|
}
|
|
|
|
/** One counter row, written as the table holds it: (day, profile, surface, self_hosted, requests, waypoints, km, failed). */
|
|
async function insertDay(
|
|
db: Database.Database,
|
|
day: string,
|
|
selfHosted: number,
|
|
requests: number,
|
|
waypoints: number,
|
|
km: number,
|
|
failed: number,
|
|
): Promise<void> {
|
|
await insertRow(await sharedTestOrm(db), RouteUsageDaily, {
|
|
day,
|
|
profile: 'driving',
|
|
surface: 'legs',
|
|
self_hosted: selfHosted,
|
|
requests,
|
|
waypoints,
|
|
km,
|
|
failed,
|
|
});
|
|
}
|
|
|
|
/** The UTC calendar date `days` days back, as SQLite's `date('now', '-N days')` renders it. */
|
|
const daysAgo = (days: number): string => new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
|
|
|
|
const entry = (over: Partial<RouteUsageEntry> = {}): RouteUsageEntry => ({
|
|
profile: 'driving', surface: 'legs', selfHosted: false,
|
|
requests: 1, waypoints: 3, km: 100, failed: 0, ...over,
|
|
});
|
|
|
|
describe('RouteUsageService', () => {
|
|
let db: Database.Database;
|
|
let svc: RouteUsageService;
|
|
|
|
beforeEach(async () => {
|
|
db = makeDb();
|
|
svc = await serviceOver(db);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', async () => {
|
|
expect(await svc.enabled()).toBe(true);
|
|
await insertRow(await sharedTestOrm(db), AppSettings, { key: 'route_usage_enabled', value: 'false' });
|
|
expect(await svc.enabled()).toBe(false);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', async () => {
|
|
await insertRow(await sharedTestOrm(db), AppSettings, { key: 'route_usage_enabled', value: 'false' });
|
|
expect(await svc.record({ entries: [entry()] })).toBe(false);
|
|
expect(await countRows(await sharedTestOrm(db), RouteUsageDaily)).toBe(0);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', async () => {
|
|
await svc.record({ entries: [entry({ requests: 5, waypoints: 12, km: 300, failed: 1 })] });
|
|
await svc.record({ entries: [entry({ requests: 3, waypoints: 6, km: 150, failed: 0 })] });
|
|
|
|
const rows = await svc.rows();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ requests: 8, waypoints: 18, km: 450, failed: 1 });
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-004: a different kind or engine is its own row', async () => {
|
|
await svc.record({ entries: [
|
|
entry({ surface: 'legs' }),
|
|
entry({ surface: 'alternatives' }),
|
|
entry({ surface: 'legs', selfHosted: true }),
|
|
entry({ surface: 'legs', profile: 'walking' }),
|
|
] });
|
|
expect(await svc.rows()).toHaveLength(4);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-005: an empty corpus answers with zeroes rather than nothing', async () => {
|
|
const s = await svc.summary();
|
|
expect(s.totalRequests).toBe(0);
|
|
expect(s.daysCovered).toBe(0);
|
|
expect(s.busiestDay).toBeNull();
|
|
expect(s.enabled).toBe(true);
|
|
expect(s.retentionDays).toBe(RETENTION_DAYS);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', async () => {
|
|
// Two days by hand: the service always writes "today", so the spread is set here.
|
|
await insertDay(db, '2026-09-01', 0, 10, 30, 900, 0);
|
|
await insertDay(db, '2026-09-02', 0, 30, 60, 1800, 2);
|
|
|
|
const s = await svc.summary();
|
|
expect(s.totalRequests).toBe(40);
|
|
expect(s.totalFailed).toBe(2);
|
|
expect(s.daysCovered).toBe(2);
|
|
expect(s.requestsPerDay).toBe(20);
|
|
expect(s.busiestDay).toBe('2026-09-02');
|
|
expect(s.busiestDayRequests).toBe(30);
|
|
expect(s.firstDay).toBe('2026-09-01');
|
|
expect(s.lastDay).toBe('2026-09-02');
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', async () => {
|
|
await insertDay(db, '2026-09-01', 0, 4, 20, 400, 0);
|
|
const s = await svc.summary();
|
|
expect(s.waypointsPerRequest).toBe(5);
|
|
expect(s.kmPerRequest).toBe(100);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', async () => {
|
|
await insertDay(db, '2026-09-01', 0, 30, 0, 0, 0);
|
|
await insertDay(db, '2026-09-01', 1, 10, 0, 0, 0);
|
|
expect((await svc.summary()).selfHostedShare).toBe(0.25);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', async () => {
|
|
await insertDay(db, daysAgo(1), 0, 1, 0, 0, 0);
|
|
await insertDay(db, daysAgo(RETENTION_DAYS + 5), 0, 1, 0, 0, 0);
|
|
|
|
expect(await svc.purgeExpired()).toBe(1);
|
|
expect(await svc.rows()).toHaveLength(1);
|
|
});
|
|
|
|
it('SRV-ROUTEUSAGE-010: clearing wipes every counter', async () => {
|
|
await svc.record({ entries: [entry(), entry({ surface: 'route' })] });
|
|
expect(await svc.clear()).toBe(2);
|
|
expect(await svc.rows()).toHaveLength(0);
|
|
});
|
|
});
|