mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16: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.
549 lines
25 KiB
TypeScript
549 lines
25 KiB
TypeScript
import { db } from '../../src/db/database';
|
|
import { Addons } from '../../src/db/entities/Addons.entity';
|
|
import { Days } from '../../src/db/entities/Days.entity';
|
|
import { Places } from '../../src/db/entities/Places.entity';
|
|
import { RoadtripDayTracks } from '../../src/db/entities/RoadtripDayTracks.entity';
|
|
import { RoadtripPreferences } from '../../src/db/entities/RoadtripPreferences.entity';
|
|
import { RoadtripVias } from '../../src/db/entities/RoadtripVias.entity';
|
|
import { TripMembers } from '../../src/db/entities/TripMembers.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
/**
|
|
* Road trip module e2e — the real guard chain against a real migrated-and-
|
|
* seeded temp SQLite db (`createSnapshotTestDb()`, Plan 3d Task 1 — this used
|
|
* to hand-roll ten CREATE TABLEs, a second hand-maintained schema copy that
|
|
* omitted `day_assignments`/`day_accommodations`/most `users`/`trips`/
|
|
* `places` columns and even LEFT JOINed the inverse 1:1 `roadtrip_day_tracks`
|
|
* relation `Days` carries — the exact "e2e suites build their schema from
|
|
* hand-written partial DDL" risk the plan's inventory §15c flagged, and the
|
|
* same class of failure `days.e2e.test.ts`'s own conversion, Plan 3c Task 2,
|
|
* fixed for that file). Every service in this module now runs its real SQL
|
|
* through repositories (DI-injected, no service mock) against the real
|
|
* request-scoped `EntityManager` `createTestMikroOrmModule` wires in; only
|
|
* the permission check stays mocked.
|
|
*
|
|
* The unit test next door pins the handler bodies. What only a booted container
|
|
* can show is the thing the controller's own comment calls load-bearing: that
|
|
* `@RequireAddon` does nothing on its own, so an instance with the addon
|
|
* switched off has to answer 404 here, and has to do it before asking who is
|
|
* calling. Delete AddonGuard from the chain and every case in the first block
|
|
* below turns red.
|
|
*
|
|
* The addon ships disabled, so "off" is the state an existing install upgrades
|
|
* into. That is what makes it worth a test rather than a comment.
|
|
*/
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { ChargingService } from '../../src/nest/roadtrip/charging.service';
|
|
import { GoogleRouteService } from '../../src/nest/roadtrip/google-route.service';
|
|
import { RoadtripHazardsService } from '../../src/nest/roadtrip/roadtrip-hazards.service';
|
|
import { RoadtripSearchService } from '../../src/nest/roadtrip/roadtrip-search.service';
|
|
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
|
|
import { countRows, deleteRows, findRow, insertRow, insertRows, updateRows } from '../helpers/factories/rows';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
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 request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
let orm: TestOrm;
|
|
|
|
const ADDON_ID = 'roadtrip';
|
|
|
|
async function setAddon(enabled: boolean): Promise<void> {
|
|
// The seeder (`AddonSeeder`, run once as part of `createSnapshotTestDb()`'s
|
|
// migration pass) already inserted this row, disabled — a plain UPDATE,
|
|
// not an upsert, matching every other addon-toggling e2e in this file set.
|
|
await updateRows(orm, Addons, { id: ADDON_ID }, { enabled });
|
|
}
|
|
|
|
describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let checkPermission: MockInstance;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
RealtimeModule,
|
|
RoadtripModule,
|
|
],
|
|
}).compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(db);
|
|
// Pinned ids: sessionCookie(1) and (2) sign for exactly these users, and
|
|
// the routes below address the trips, days and places by their ids.
|
|
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test' });
|
|
await makeUser(orm, { id: 2, username: 'other', email: 'other@example.test' });
|
|
await insertRows(orm, Trips, [
|
|
{ id: 5, user: 1, title: 'Norway' },
|
|
{ id: 6, user: 2, title: 'Somebody else' },
|
|
]);
|
|
await insertRows(orm, Days, [
|
|
{ id: 3, trip: 5, day_number: 1 },
|
|
{ id: 4, trip: 6, day_number: 1 },
|
|
]);
|
|
// A track (a place carrying a route geometry) on each trip.
|
|
await insertRow(orm, Places, { id: 10, trip: 5, name: 'Scenic', route_geometry: '[[1,2]]' });
|
|
await insertRow(orm, Places, { id: 11, trip: 6, name: 'Theirs', route_geometry: '[[1,2]]' });
|
|
// An ordinary place, which is not a track.
|
|
await insertRow(orm, Places, { id: 12, trip: 5, name: 'Just a stop' });
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await deleteRows(orm, RoadtripVias);
|
|
await deleteRows(orm, RoadtripDayTracks);
|
|
checkPermission.mockReturnValue(true);
|
|
await setAddon(true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
await orm.close();
|
|
});
|
|
|
|
const cookie = () => sessionCookie(1);
|
|
it('gates charging data by addon and trip membership', async () => {
|
|
const read = vi.spyOn(app.get(ChargingService), 'read').mockResolvedValue({} as never);
|
|
try {
|
|
await request(server).get('/api/trips/5/roadtrip/charging/10').expect(401);
|
|
await request(server).get('/api/trips/6/roadtrip/charging/10').set('Cookie', cookie()).expect(404);
|
|
await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(200);
|
|
expect(read).toHaveBeenCalledWith(5, 10);
|
|
await setAddon(false);
|
|
await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(404);
|
|
expect(read).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
// L3 (Plan 3d Task 7 review): unlike every other case in this file,
|
|
// `ChargingService.read` is NOT mocked here — this is the one place CH1
|
|
// (`PlacesRepository.findChargingProbe`) runs for real, through the whole
|
|
// guard chain. Place 11 is a real row, just on trip 6, not trip 5 — the
|
|
// gate answers "Place not found", the same shape a place that never
|
|
// existed gets, never a leak of trip 6's row.
|
|
it('CH1 — a place id that belongs to a DIFFERENT trip is "Place not found", not trip 6\'s row (real findChargingProbe, no service mock)', async () => {
|
|
const res = await request(server).get('/api/trips/5/roadtrip/charging/11').set('Cookie', cookie());
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Place not found' });
|
|
});
|
|
it('gates the coordinate lookup like the saved stop and validates before fetching', async () => {
|
|
const lookup = vi.spyOn(app.get(ChargingService), 'lookup').mockResolvedValue({} as never);
|
|
const body = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' };
|
|
const post = () => request(server).post('/api/trips/5/roadtrip/charging-lookup').set('Cookie', cookie());
|
|
try {
|
|
await request(server).post('/api/trips/5/roadtrip/charging-lookup').send(body).expect(401);
|
|
await request(server)
|
|
.post('/api/trips/6/roadtrip/charging-lookup')
|
|
.set('Cookie', cookie())
|
|
.send(body)
|
|
.expect(404);
|
|
await post()
|
|
.send({ ...body, lat: 91 })
|
|
.expect(400);
|
|
// A list is the shape a caller would reach for to fan a whole corridor out in one
|
|
// request. There is no rate limiter here and each miss costs the upstream registry
|
|
// several requests, so the contract refuses it before anything leaves the server.
|
|
await post().send([body]).expect(400);
|
|
expect(lookup).not.toHaveBeenCalled();
|
|
await post().send(body).expect(200);
|
|
expect(lookup).toHaveBeenCalledWith(48.137, 11.575, 'Ladepark Nord');
|
|
await setAddon(false);
|
|
await post().send(body).expect(404);
|
|
expect(lookup).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
lookup.mockRestore();
|
|
}
|
|
});
|
|
it('validates Google route preview and import before executing either service', async () => {
|
|
const routes = app.get(GoogleRouteService);
|
|
const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] });
|
|
const save = vi.spyOn(routes, 'import').mockResolvedValue({ imported: 2 });
|
|
const input = {
|
|
dayId: 3,
|
|
stops: [
|
|
{ name: 'A', lat: 48, lng: 11 },
|
|
{ name: 'B', lat: 41, lng: 12 },
|
|
],
|
|
};
|
|
try {
|
|
await request(server)
|
|
.post('/api/roadtrip/google-maps-preview')
|
|
.send({ url: 'https://google.com/maps/dir/A/B' })
|
|
.expect(401);
|
|
await request(server)
|
|
.post('/api/roadtrip/google-maps-preview')
|
|
.set('Cookie', cookie())
|
|
.send({ url: 'bad' })
|
|
.expect(400);
|
|
await request(server)
|
|
.post('/api/roadtrip/google-maps-preview')
|
|
.set('Cookie', cookie())
|
|
.send({ url: 'https://google.com/maps/dir/A/B' })
|
|
.expect(200);
|
|
await request(server)
|
|
.post('/api/trips/6/roadtrip/google-maps-import')
|
|
.set('Cookie', cookie())
|
|
.send(input)
|
|
.expect(404);
|
|
await request(server)
|
|
.post('/api/trips/5/roadtrip/google-maps-import')
|
|
.set('Cookie', cookie())
|
|
.send({ ...input, stops: [{ name: 'A', lat: 999, lng: 0 }] })
|
|
.expect(400);
|
|
await request(server)
|
|
.post('/api/trips/5/roadtrip/google-maps-import')
|
|
.set('Cookie', cookie())
|
|
.send(input)
|
|
.expect(200);
|
|
expect(save).toHaveBeenCalledWith(5, 1, input, undefined);
|
|
await setAddon(false);
|
|
await request(server)
|
|
.post('/api/trips/5/roadtrip/google-maps-import')
|
|
.set('Cookie', cookie())
|
|
.send(input)
|
|
.expect(404);
|
|
await request(server)
|
|
.post('/api/roadtrip/google-maps-preview')
|
|
.set('Cookie', cookie())
|
|
.send({ url: 'https://google.com/maps/dir/A/B' })
|
|
.expect(404);
|
|
expect(save).toHaveBeenCalledTimes(1);
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
preview.mockRestore();
|
|
save.mockRestore();
|
|
}
|
|
});
|
|
it('validates and gates area search before calling plugins', async () => {
|
|
const search = vi
|
|
.spyOn(app.get(RoadtripSearchService), 'search')
|
|
.mockResolvedValue({ pois: [], sources: [], failedSources: [], truncated: false, clamped: false });
|
|
const input = { categories: ['fuel'], bbox: { south: 48, north: 49, west: 10, east: 11 } };
|
|
try {
|
|
await request(server).post('/api/roadtrip/search-area').send(input).expect(401);
|
|
await request(server)
|
|
.post('/api/roadtrip/search-area')
|
|
.set('Cookie', cookie())
|
|
.send({ ...input, categories: ['invalid'] })
|
|
.expect(400);
|
|
await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(200);
|
|
expect(search).toHaveBeenCalledWith(input, 1);
|
|
await setAddon(false);
|
|
await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(404);
|
|
expect(search).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
search.mockRestore();
|
|
}
|
|
});
|
|
it('gates live hazards by addon, authentication and trip access', async () => {
|
|
const read = vi
|
|
.spyOn(app.get(RoadtripHazardsService), 'read')
|
|
.mockResolvedValue({ fetchedAt: new Date().toISOString(), hazards: [], sources: [] });
|
|
try {
|
|
await request(server).get('/api/trips/5/roadtrip/hazards').expect(401);
|
|
await request(server).get('/api/trips/6/roadtrip/hazards').set('Cookie', cookie()).expect(404);
|
|
await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(200);
|
|
await setAddon(false);
|
|
await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(404);
|
|
expect(read).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
describe('shared trip driving preferences', () => {
|
|
it('shares values with members and isolates other trips', async () => {
|
|
await insertRow(orm, TripMembers, { trip: 5, user: 2 });
|
|
try {
|
|
await request(server)
|
|
.put('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', cookie())
|
|
.send({ roadtrip_range_km: 120, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' })
|
|
.expect(200);
|
|
const member = await request(server)
|
|
.get('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', sessionCookie(2))
|
|
.expect(200);
|
|
expect(member.body.preferences.roadtrip_range_km).toBe(120);
|
|
await request(server)
|
|
.put('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', sessionCookie(2))
|
|
.send({ roadtrip_range_km: 160 })
|
|
.expect(200);
|
|
const owner = await request(server)
|
|
.get('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', cookie())
|
|
.expect(200);
|
|
expect(owner.body.preferences.roadtrip_range_km).toBe(160);
|
|
const other = await request(server)
|
|
.get('/api/trips/6/roadtrip/preferences')
|
|
.set('Cookie', sessionCookie(2))
|
|
.expect(200);
|
|
expect(other.body.preferences).toEqual({});
|
|
checkPermission.mockReturnValue(false);
|
|
await request(server)
|
|
.put('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', sessionCookie(2))
|
|
.send({ roadtrip_range_km: 999 })
|
|
.expect(403);
|
|
expect((await findRow(orm, RoadtripPreferences, { trip: 5, key: 'roadtrip_range_km' }))?.value).toBe('160');
|
|
} finally {
|
|
await deleteRows(orm, TripMembers, { trip: 5, user: 2 });
|
|
}
|
|
});
|
|
it('ROADTRIP-E2E-013: the stay switch is off until it is set, and round-trips as a boolean', async () => {
|
|
await deleteRows(orm, RoadtripPreferences, { trip: 5, key: 'roadtrip_hotel_bookends' });
|
|
const before = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200);
|
|
expect(before.body.preferences).not.toHaveProperty('roadtrip_hotel_bookends');
|
|
|
|
const saved = await request(server)
|
|
.put('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', cookie())
|
|
.send({ roadtrip_hotel_bookends: true })
|
|
.expect(200);
|
|
expect(saved.body.preferences.roadtrip_hotel_bookends).toBe(true);
|
|
const after = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200);
|
|
expect(after.body.preferences.roadtrip_hotel_bookends).toBe(true);
|
|
|
|
await request(server)
|
|
.put('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', cookie())
|
|
.send({ roadtrip_hotel_bookends: false })
|
|
.expect(200);
|
|
expect((await findRow(orm, RoadtripPreferences, { trip: 5, key: 'roadtrip_hotel_bookends' }))?.value).toBe(
|
|
'false',
|
|
);
|
|
});
|
|
it('ROADTRIP-E2E-014: a stay switch that is not a boolean is a 400, not a stored row', async () => {
|
|
await deleteRows(orm, RoadtripPreferences, { trip: 5, key: 'roadtrip_hotel_bookends' });
|
|
for (const value of ['true', 1, null]) {
|
|
await request(server)
|
|
.put('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', cookie())
|
|
.send({ roadtrip_hotel_bookends: value })
|
|
.expect(400);
|
|
}
|
|
expect(await findRow(orm, RoadtripPreferences, { trip: 5, key: 'roadtrip_hotel_bookends' })).toBeNull();
|
|
});
|
|
it('refuses strangers and invalid daily windows', async () => {
|
|
await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', sessionCookie(2)).expect(404);
|
|
await request(server)
|
|
.put('/api/trips/5/roadtrip/preferences')
|
|
.set('Cookie', cookie())
|
|
.send({ roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' })
|
|
.expect(400);
|
|
});
|
|
});
|
|
|
|
describe('the addon gate', () => {
|
|
// Six routes, all of them. `@RequireAddon` is metadata and inert without the
|
|
// guard, and the guard is not global — so this block is the only thing that
|
|
// fails if somebody drops AddonGuard from the chain again.
|
|
const routes: [string, string, unknown?][] = [
|
|
['get', '/api/trips/5/roadtrip/preferences'],
|
|
['put', '/api/trips/5/roadtrip/preferences', { roadtrip_range_km: 120 }],
|
|
['get', '/api/trips/5/roadtrip/vias'],
|
|
['get', '/api/trips/5/roadtrip/days/3/vias'],
|
|
['post', '/api/trips/5/roadtrip/days/3/vias', { after_order_index: 0, lat: 53, lng: 10 }],
|
|
['post', '/api/trips/5/roadtrip/days/3/vias/batch', { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }],
|
|
['put', '/api/trips/5/roadtrip/days/3/vias', { vias: [] }],
|
|
['delete', '/api/trips/5/roadtrip/days/3/vias/1'],
|
|
];
|
|
|
|
it('ROADTRIP-E2E-001: answers 404 on every route while the addon is off', async () => {
|
|
await setAddon(false);
|
|
for (const [method, url, body] of routes) {
|
|
const req = (request(server) as never as Record<string, (u: string) => request.Test>)
|
|
[method](url)
|
|
.set('Cookie', cookie());
|
|
const res = await (body ? req.send(body as object) : req);
|
|
expect(res.status, `${method.toUpperCase()} ${url}`).toBe(404);
|
|
}
|
|
});
|
|
|
|
it('ROADTRIP-E2E-002: a disabled addon owes an anonymous caller a 404, not a 401', async () => {
|
|
// The gate leads the chain for this reason: a 401 tells a stranger the
|
|
// route exists. Both answers refuse; only one of them says nothing.
|
|
await setAddon(false);
|
|
const res = await request(server).get('/api/trips/5/roadtrip/vias');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('ROADTRIP-E2E-003: with the addon on, an anonymous caller gets 401', async () => {
|
|
const res = await request(server).get('/api/trips/5/roadtrip/vias');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('trip scoping', () => {
|
|
it('ROADTRIP-E2E-004: a trip the caller cannot reach is 404', async () => {
|
|
const res = await request(server).get('/api/trips/6/roadtrip/vias').set('Cookie', cookie());
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('ROADTRIP-E2E-005: a day from another trip is not reachable through one the caller has', async () => {
|
|
// Day 4 exists and is real; it just belongs to trip 6. The guard proves
|
|
// the trip, `requireDay` proves the day is part of it.
|
|
const res = await request(server).get('/api/trips/5/roadtrip/days/4/vias').set('Cookie', cookie());
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Day not found' });
|
|
});
|
|
|
|
it('ROADTRIP-E2E-006: a track from another trip cannot label this day', async () => {
|
|
const res = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias/batch')
|
|
.set('Cookie', cookie())
|
|
.send({ vias: [{ after_order_index: 0, lat: 53, lng: 10 }], track: { place_id: 11 } });
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Track not found' });
|
|
});
|
|
|
|
it('ROADTRIP-E2E-007: a place without a route geometry is not a track', async () => {
|
|
const res = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias/batch')
|
|
.set('Cookie', cookie())
|
|
.send({ vias: [{ after_order_index: 0, lat: 53, lng: 10 }], track: { place_id: 12 } });
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Track not found' });
|
|
});
|
|
});
|
|
|
|
describe('permission', () => {
|
|
it('ROADTRIP-E2E-008: reading needs no day_edit, writing does', async () => {
|
|
checkPermission.mockReturnValue(false);
|
|
expect((await request(server).get('/api/trips/5/roadtrip/vias').set('Cookie', cookie())).status).toBe(200);
|
|
const write = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias')
|
|
.set('Cookie', cookie())
|
|
.send({ after_order_index: 0, lat: 53, lng: 10 });
|
|
expect(write.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('the wire contract', () => {
|
|
it('ROADTRIP-E2E-009: a via round-trips through create, list, move and delete', async () => {
|
|
const created = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias')
|
|
.set('Cookie', cookie())
|
|
.send({ after_order_index: 0, lat: 53, lng: 10 });
|
|
expect(created.status).toBe(201);
|
|
const id = created.body.via.id;
|
|
expect(created.body.via).toMatchObject({ day_id: 3, after_order_index: 0, sequence: 0, lat: 53, lng: 10 });
|
|
|
|
const listed = await request(server).get('/api/trips/5/roadtrip/days/3/vias').set('Cookie', cookie());
|
|
expect(listed.body.vias.map((v: { id: number }) => v.id)).toEqual([id]);
|
|
|
|
const moved = await request(server)
|
|
.put(`/api/trips/5/roadtrip/days/3/vias/${id}`)
|
|
.set('Cookie', cookie())
|
|
.send({ lat: 54, lng: 11 });
|
|
// 200, not the 201 Nest defaults a body-carrying write to.
|
|
expect(moved.status).toBe(200);
|
|
expect(moved.body.via).toMatchObject({ lat: 54, lng: 11 });
|
|
|
|
expect(
|
|
(await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status,
|
|
).toBe(200);
|
|
const after = await request(server).get('/api/trips/5/roadtrip/days/3/vias').set('Cookie', cookie());
|
|
expect(after.body.vias).toEqual([]);
|
|
});
|
|
|
|
// L1 (Plan 3d Task 7 whole-plan review): `listForDay`/`listForTrip`/
|
|
// `tracksForTrip` used to bind `Number(tripId)`, which a hex-spelled id
|
|
// coerces to a real trip — `TripAccessGuard` had already authorised the
|
|
// SAME id through its own `Number()` (not a leak), but the legacy
|
|
// raw-bind statement's affinity never converts a hex string, so it
|
|
// always answered empty. Every one of these three now answers the
|
|
// legacy empty shape for the same hex id, not the real trip's rows.
|
|
it("L1 — vias/tracks by a hex-spelled trip id answer the legacy empty shape, not trip 5's real rows", async () => {
|
|
const created = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias')
|
|
.set('Cookie', cookie())
|
|
.send({ after_order_index: 0, lat: 53, lng: 10 });
|
|
expect(created.status).toBe(201);
|
|
|
|
const hexTripId = '0x' + (5).toString(16);
|
|
const res = await request(server).get(`/api/trips/${hexTripId}/roadtrip/vias`).set('Cookie', cookie());
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ vias: [], tracks: [] });
|
|
|
|
const hexDayId = '0x' + (3).toString(16);
|
|
const dayRes = await request(server).get(`/api/trips/5/roadtrip/days/${hexDayId}/vias`).set('Cookie', cookie());
|
|
// `requireDay` (`toRowId`, unrelated to this fix) already 404s a
|
|
// hex-spelled day id — asserted here only to document that this route
|
|
// never reaches `listForDay` with one, so `listForDay`'s own fix is
|
|
// pinned at the service level instead (see roadtrip.service.test.ts).
|
|
expect(dayRes.status).toBe(404);
|
|
});
|
|
|
|
it('ROADTRIP-E2E-010: the batch route answers 200 and records the track it was fitted to', async () => {
|
|
const res = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias/batch')
|
|
.set('Cookie', cookie())
|
|
.send({
|
|
vias: [
|
|
{ after_order_index: 0, lat: 53, lng: 10 },
|
|
{ after_order_index: 0, lat: 53.5, lng: 10.5 },
|
|
],
|
|
track: { place_id: 10, stray_km: 1.5 },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.vias).toHaveLength(2);
|
|
|
|
const all = await request(server).get('/api/trips/5/roadtrip/vias').set('Cookie', cookie());
|
|
expect(all.body.tracks).toEqual([{ day_id: 3, place_id: 10, stray_km: 1.5 }]);
|
|
});
|
|
|
|
it('ROADTRIP-E2E-011: the re-anchor route is declared before the id route', async () => {
|
|
// `PUT days/:dayId/vias` and `PUT days/:dayId/vias/:id` differ by one
|
|
// segment. Declared the other way round, the batch would be swallowed by
|
|
// the id route and every re-anchoring would 404 on a via named "vias".
|
|
const created = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias')
|
|
.set('Cookie', cookie())
|
|
.send({ after_order_index: 2, lat: 53, lng: 10 });
|
|
const res = await request(server)
|
|
.put('/api/trips/5/roadtrip/days/3/vias')
|
|
.set('Cookie', cookie())
|
|
.send({ vias: [{ id: created.body.via.id, after_order_index: 1 }] });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.vias[0]).toMatchObject({ after_order_index: 1 });
|
|
});
|
|
|
|
it('ROADTRIP-E2E-012: a body the contract refuses is a 400, not a stored row', async () => {
|
|
const res = await request(server)
|
|
.post('/api/trips/5/roadtrip/days/3/vias')
|
|
.set('Cookie', cookie())
|
|
.send({ after_order_index: 0, lat: 999, lng: 10 });
|
|
expect(res.status).toBe(400);
|
|
expect(await countRows(orm, RoadtripVias)).toBe(0);
|
|
});
|
|
});
|
|
});
|