mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
961d223c7c
The notices to the admins brought NotificationsModule into PaddleObstaclesModule, which needs RealtimeService and opens the storage registry at init. The suite now mounts RealtimeModule with a FakeRealtimeService, as the tours and wanderer suites do, and its DataPathsService stand-in names an uploads and a backups folder in its temp dir.
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
/**
|
|
* Paddle obstacles e2e (PAD-05A): `GET /api/paddle/obstacles` and the admin
|
|
* routes `/api/admin/paddle-regions*` through the real AddonGuard,
|
|
* JwtAuthGuard and AdminGuard, the Zod pipe and the response contracts, over
|
|
* the real services and repositories on a migrated temp SQLite db.
|
|
*
|
|
* Replaced: `GeofabrikClient`, so no test reaches Geofabrik; its download
|
|
* writes an extract the test builds (tests/helpers/osm-pbf-fixture.ts), which
|
|
* the real reader then reads. Broadcasts go to a FakeRealtimeService.
|
|
*/
|
|
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
|
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
|
|
import { DataPathsService } from '../../src/nest/app-config/data-paths.service';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { GeofabrikClient, type GeofabrikRegion } from '../../src/nest/paddle-obstacles/geofabrik.client';
|
|
import { PaddleObstaclesModule } from '../../src/nest/paddle-obstacles/paddle-obstacles.module';
|
|
import { PADDLE_BOX_INVALID_MESSAGE } from '../../src/nest/paddle-obstacles/paddle-obstacles.service';
|
|
import {
|
|
CATALOG_UNAVAILABLE_MESSAGE,
|
|
PaddleRegionsService,
|
|
REGION_NOT_FOUND_MESSAGE,
|
|
REGION_UNKNOWN_MESSAGE,
|
|
} from '../../src/nest/paddle-obstacles/paddle-regions.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
|
import { createSnapshotTestDb } from '../helpers/db-mock';
|
|
import { deleteRows, findRows } from '../helpers/factories/rows';
|
|
import { setAddonEnabled } from '../helpers/factories/settings';
|
|
import { makeAdmin, makeUser } from '../helpers/factories/users';
|
|
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
|
import { pbfFile } from '../helpers/osm-pbf-fixture';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { sessionCookie } from './harness';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
|
LOG_LEVEL: 'error',
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
|
|
/** A migrated temp database handed to the code under test, rather than a mocked database module. */
|
|
const db = createSnapshotTestDb();
|
|
|
|
const USER = 1;
|
|
const ADMIN = 2;
|
|
const REGION: GeofabrikRegion = {
|
|
id: 'oberpfalz',
|
|
name: 'Oberpfalz',
|
|
parent: 'bayern',
|
|
pbfUrl: 'https://download.geofabrik.de/europe/germany/bayern/oberpfalz-latest.osm.pbf',
|
|
boundary: {
|
|
type: 'Polygon',
|
|
coordinates: [
|
|
[
|
|
[11.5, 48.9],
|
|
[13, 48.9],
|
|
[13, 50],
|
|
[11.5, 50],
|
|
[11.5, 48.9],
|
|
],
|
|
],
|
|
},
|
|
};
|
|
const BAYERN: GeofabrikRegion = { ...REGION, id: 'bayern', name: 'Bayern', parent: null, boundary: null };
|
|
/** The weir at Lindenmühle, Regenstauf, as a line across the Regen. */
|
|
const EXTRACT = pbfFile(
|
|
[
|
|
{
|
|
dense: [
|
|
{ id: 1, lat: 49.1231, lon: 12.1301 },
|
|
{ id: 2, lat: 49.124, lon: 12.1315 },
|
|
],
|
|
},
|
|
{ ways: [{ id: 29256405, refs: [1, 2], tags: { waterway: 'weir', name: 'Wehr Lindenmühle' } }] },
|
|
],
|
|
{ timestamp: Date.UTC(2026, 9, 1) / 1000 },
|
|
);
|
|
const BOX = '49.1,12.1,49.2,12.2';
|
|
|
|
const geofabrik = {
|
|
regions: vi.fn<() => Promise<Map<string, GeofabrikRegion>>>(),
|
|
download: vi.fn<(url: string, target: string) => Promise<void>>(),
|
|
};
|
|
|
|
let orm: TestOrm;
|
|
let server: Server;
|
|
let dir: string;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
AppConfigModule,
|
|
RealtimeModule,
|
|
PaddleObstaclesModule,
|
|
],
|
|
})
|
|
.overrideProvider(RealtimeService)
|
|
.useValue(new FakeRealtimeService())
|
|
.overrideProvider(GeofabrikClient)
|
|
.useValue(geofabrik)
|
|
.overrideProvider(DataPathsService)
|
|
// The notices to the admins pull the storage registry in, which opens its local backends at init.
|
|
.useValue({ tmpDir: dir, dataDir: dir, uploadsDir: join(dir, 'uploads'), backupsDir: join(dir, 'backups') })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
|
|
const obstacles = (bbox: string, userId?: number) => {
|
|
const req = request(server).get('/api/paddle/obstacles').query({ bbox });
|
|
return userId === undefined ? req : req.set(as(userId));
|
|
};
|
|
const admin = {
|
|
list: (userId = ADMIN) => request(server).get('/api/admin/paddle-regions').set(as(userId)),
|
|
catalog: (q: string, userId = ADMIN) =>
|
|
request(server).get('/api/admin/paddle-regions/catalog').query({ q }).set(as(userId)),
|
|
add: (id: string, userId = ADMIN) => request(server).post('/api/admin/paddle-regions').set(as(userId)).send({ id }),
|
|
refresh: (id: string, userId = ADMIN) =>
|
|
request(server).post('/api/admin/paddle-regions/refresh').set(as(userId)).send({ id }),
|
|
remove: (id: string, userId = ADMIN) =>
|
|
request(server).post('/api/admin/paddle-regions/remove').set(as(userId)).send({ id }),
|
|
};
|
|
const settle = () => app.get(PaddleRegionsService).idle();
|
|
|
|
beforeAll(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), 'trek-paddle-e2e-'));
|
|
orm = await createTestOrm(db);
|
|
await makeUser(orm, { id: USER, email: 'paddler@example.test' });
|
|
await makeAdmin(orm, { id: ADMIN, email: 'admin@example.test' });
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
geofabrik.regions.mockResolvedValue(
|
|
new Map([
|
|
[REGION.id, REGION],
|
|
[BAYERN.id, BAYERN],
|
|
]),
|
|
);
|
|
geofabrik.download.mockImplementation((_url, target) => writeFile(target, EXTRACT));
|
|
await setAddonEnabled(orm, 'tours', true);
|
|
await deleteRows(orm, AuditLog);
|
|
await admin.remove(REGION.id);
|
|
await admin.remove(BAYERN.id);
|
|
await settle();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
db.close();
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('GET /api/paddle/obstacles', () => {
|
|
it('PADE2E-001: 404 while Tours is off, ahead of the session check; 401 without a cookie once on', async () => {
|
|
await setAddonEnabled(orm, 'tours', false);
|
|
for (const res of [await obstacles(BOX), await obstacles(BOX, USER)]) {
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Tours addon is not enabled' });
|
|
}
|
|
await setAddonEnabled(orm, 'tours', true);
|
|
const anonymous = await obstacles(BOX);
|
|
expect(anonymous.status).toBe(401);
|
|
expect(anonymous.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
|
});
|
|
|
|
it('PADE2E-002: 400 for a box that is not one, and for one wider than a degree', async () => {
|
|
expect((await obstacles('1,2,3', USER)).status).toBe(400);
|
|
expect((await request(server).get('/api/paddle/obstacles').set(as(USER))).status).toBe(400);
|
|
const wide = await obstacles('48,11,50,13', USER);
|
|
expect(wide.status).toBe(400);
|
|
expect(wide.body).toEqual({ error: PADDLE_BOX_INVALID_MESSAGE });
|
|
});
|
|
|
|
it('PADE2E-003: an area without data answers none, never an empty all-clear', async () => {
|
|
const res = await obstacles(BOX, USER);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ features: [], coverage: { state: 'none', regions: [] }, truncated: false });
|
|
});
|
|
});
|
|
|
|
describe('/api/admin/paddle-regions', () => {
|
|
it('PADE2E-004: 401 without a cookie and 403 for a user who is no admin, on every route', async () => {
|
|
expect((await request(server).get('/api/admin/paddle-regions')).status).toBe(401);
|
|
for (const res of [
|
|
await admin.list(USER),
|
|
await admin.catalog('ober', USER),
|
|
await admin.add(REGION.id, USER),
|
|
await admin.refresh(REGION.id, USER),
|
|
await admin.remove(REGION.id, USER),
|
|
]) {
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Admin access required' });
|
|
}
|
|
expect(geofabrik.regions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PADE2E-005: an admin adds a region, its read lands, and the map reads the weir as a line with the date of the data', async () => {
|
|
const added = await admin.add(REGION.id);
|
|
expect(added.status).toBe(201);
|
|
expect(await findRows(orm, AuditLog, { action: 'admin.paddle_region_add' })).toMatchObject([
|
|
{ details: JSON.stringify({ region: 'oberpfalz' }) },
|
|
]);
|
|
expect(added.body).toMatchObject({ id: 'oberpfalz', name: 'Oberpfalz', feature_count: 0 });
|
|
await settle();
|
|
|
|
const list = await admin.list();
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.regions).toEqual([
|
|
expect.objectContaining({
|
|
id: 'oberpfalz',
|
|
status: 'ready',
|
|
feature_count: 1,
|
|
source_timestamp: '2026-10-01T00:00:00.000Z',
|
|
error: null,
|
|
progress: null,
|
|
}),
|
|
]);
|
|
|
|
const res = await obstacles(BOX, USER);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.coverage).toMatchObject({ state: 'covered', regions: [{ id: 'oberpfalz', name: 'Oberpfalz' }] });
|
|
expect(res.body.features).toEqual([
|
|
expect.objectContaining({
|
|
ref: 'way/29256405',
|
|
kind: 'weir',
|
|
name: 'Wehr Lindenmühle',
|
|
geometry: { type: 'LineString', coordinates: expect.any(Array) },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('PADE2E-006: refresh answers 200 with the region queued, remove 200 with success; both 404 for a region not held', async () => {
|
|
await admin.add(REGION.id);
|
|
await settle();
|
|
|
|
const refreshed = await admin.refresh(REGION.id);
|
|
expect(refreshed.status).toBe(200);
|
|
expect(refreshed.body).toMatchObject({ id: 'oberpfalz', status: 'queued' });
|
|
await settle();
|
|
|
|
const removed = await admin.remove(REGION.id);
|
|
expect(removed.status).toBe(200);
|
|
expect(removed.body).toEqual({ success: true });
|
|
expect((await obstacles(BOX, USER)).body.coverage.state).toBe('none');
|
|
|
|
for (const res of [await admin.refresh(REGION.id), await admin.remove(REGION.id)]) {
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: REGION_NOT_FOUND_MESSAGE });
|
|
}
|
|
});
|
|
|
|
it('PADE2E-007: an id Geofabrik does not list is 400 with the reason, a malformed one 400 from the pipe', async () => {
|
|
const unknown = await admin.add('atlantis');
|
|
expect(unknown.status).toBe(400);
|
|
expect(unknown.body).toEqual({ error: REGION_UNKNOWN_MESSAGE, code: 'unknown_region' });
|
|
expect((await admin.add('Not An Id!')).status).toBe(400);
|
|
expect((await admin.add('')).status).toBe(400);
|
|
});
|
|
|
|
it('PADE2E-008: the catalogue finds regions with their path; a short query is 400, an unreachable Geofabrik 502', async () => {
|
|
const found = await admin.catalog('ober');
|
|
expect(found.status).toBe(200);
|
|
expect(found.body).toEqual({ regions: [{ id: 'oberpfalz', name: 'Oberpfalz', path: ['Bayern'] }] });
|
|
|
|
expect((await admin.catalog('o')).status).toBe(400);
|
|
|
|
geofabrik.regions.mockRejectedValue(new Error('Geofabrik index 503'));
|
|
const down = await admin.catalog('ober');
|
|
expect(down.status).toBe(502);
|
|
expect(down.body).toEqual({ error: CATALOG_UNAVAILABLE_MESSAGE, code: 'catalog_unavailable' });
|
|
});
|
|
|
|
it('PADE2E-009: the admin keeps the regions while Tours is off, so data can be prepared before it is switched on', async () => {
|
|
await setAddonEnabled(orm, 'tours', false);
|
|
expect((await admin.list()).status).toBe(200);
|
|
expect((await admin.add(REGION.id)).status).toBe(201);
|
|
await settle();
|
|
});
|
|
});
|