mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
49ba73fcae
Eleven more suites (atlas and settings e2e, the bucket list, shadow pick
and Dawarich suggestion repositories, the budget and collab MCP tools,
notification preferences, VAPID keys, school holidays and tours) write
and read their rows through the ORM factories. Time-relative fixtures
that leaned on SQLite's date('now', ...) build the same text in JS with
dbNow(), and helpers that returned undefined for a missing row keep doing
so where an assertion depends on it. The legacy statements the parity
cases compare against stay raw, marked as oracles.
351 lines
18 KiB
TypeScript
351 lines
18 KiB
TypeScript
/**
|
|
* Atlas e2e — drives /api/addons/atlas through the REAL JwtAuthGuard AND the
|
|
* real DI-native AtlasService (DatabaseModule + AtlasModule) against a temp
|
|
* SQLite db (full schema). No service mock (the legacy path-mock died with the
|
|
* fold): auth, status codes (mark POSTs stay 200, bucket create stays 201),
|
|
* cache headers, the bespoke 400/404 bodies, and the SQL effects are all real.
|
|
* countries/geo serves the real bundled admin-0 gz. Seeded data stays
|
|
* coordinate-free so the background place_regions geocode never fires.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { Test } from '@nestjs/testing';
|
|
import { sessionCookie } from './harness';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
return {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => undefined,
|
|
isOwner: () => false,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
|
|
|
|
import { db } from '../../src/db/database';
|
|
import { createUser, createTrip } from '../helpers/factories';
|
|
import { AtlasModule } from '../../src/nest/atlas/atlas.module';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { countRows, deleteRows, findRow, upsertRow } from '../helpers/factories/rows';
|
|
import { markCountryVisited } from '../helpers/factories/atlas';
|
|
import { makePlace } from '../helpers/factories/places';
|
|
import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations';
|
|
import { makeMcpToken } from '../helpers/factories/tokens';
|
|
import { BucketList } from '../../src/db/entities/BucketList.entity';
|
|
import { HiddenCountries } from '../../src/db/entities/HiddenCountries.entity';
|
|
import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity';
|
|
import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity';
|
|
import { VisitedRegions } from '../../src/db/entities/VisitedRegions.entity';
|
|
|
|
describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let userId: number;
|
|
let orm: TestOrm;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AtlasModule] }).compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
|
|
// by metatype, exactly as they do under buildApp().
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
userId = createUser(db as never, { username: 'atlas-e2e', email: 'atlas-e2e@test.example' }).user.id;
|
|
orm = await createTestOrm(db);
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
it('401 without a session cookie', async () => {
|
|
const res = await request(server).get('/api/addons/atlas/stats');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('200 countries/geo serves the bundled gzipped admin-0 that the client decompresses to a FeatureCollection', async () => {
|
|
const res = await request(server).get('/api/addons/atlas/countries/geo').set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-encoding']).toBe('gzip');
|
|
// superagent transparently decompresses, mirroring the browser.
|
|
expect(res.body.type).toBe('FeatureCollection');
|
|
expect(res.body.features.length).toBeGreaterThan(0);
|
|
expect(res.headers['cache-control']).toContain('max-age=86400');
|
|
});
|
|
|
|
it('200 stats for an authenticated user — the trip-less branch keeps its bespoke shape', async () => {
|
|
await markCountryVisited(orm, userId, 'JP');
|
|
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(200);
|
|
// Zero-trip early return: has a `trips` key and only the four base stats —
|
|
// no totalCities/mostVisited/continents/… (preserved quirk).
|
|
expect(res.body).toEqual({
|
|
countries: [{ code: 'JP', placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
|
trips: [],
|
|
stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 },
|
|
});
|
|
await deleteRows(orm, VisitedCountries, { user: userId });
|
|
});
|
|
|
|
it('200 (not 201) on POST country mark, with upper-cased code written to the db', async () => {
|
|
const res = await request(server).post('/api/addons/atlas/country/de/mark').set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true });
|
|
const row = await findRow(orm, VisitedCountries, { user: userId });
|
|
expect(row?.country_code).toBe('DE');
|
|
await deleteRows(orm, VisitedCountries, { user: userId });
|
|
await deleteRows(orm, HiddenCountries, { user: userId });
|
|
});
|
|
|
|
it('400 on region mark without country_code (ZodValidationPipe envelope)', async () => {
|
|
const res = await request(server).post('/api/addons/atlas/region/by/mark').set('Cookie', sessionCookie(userId)).send({ name: 'Bavaria' });
|
|
expect(res.status).toBe(400);
|
|
// The legacy hand-rolled 'name and country_code are required' body became
|
|
// the pipe's `field: message` envelope with the atlas DTO ratchet.
|
|
expect(res.body.error).toMatch(/^country_code: /);
|
|
expect(await countRows(orm, VisitedRegions, { user: userId })).toBe(0);
|
|
});
|
|
|
|
it("400 'Name is required' on whitespace-only bucket name (legacy trim guard survives the DTO)", async () => {
|
|
const res = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId)).send({ name: ' ' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Name is required' });
|
|
});
|
|
|
|
it('no-store cache header on /regions', async () => {
|
|
const res = await request(server).get('/api/addons/atlas/regions').set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ regions: {} });
|
|
expect(res.headers['cache-control']).toBe('no-cache, no-store');
|
|
});
|
|
|
|
it('empty FeatureCollection (no cache header) when /regions/geo has no countries', async () => {
|
|
const res = await request(server).get('/api/addons/atlas/regions/geo').set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ type: 'FeatureCollection', features: [] });
|
|
expect(res.headers['cache-control']).toBeUndefined();
|
|
});
|
|
|
|
it('201 on bucket-list create, row persisted', async () => {
|
|
const res = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId)).send({ name: 'Kyoto' });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.item.name).toBe('Kyoto');
|
|
const row = await findRow(orm, BucketList, { id: res.body.item.id });
|
|
expect({ name: row?.name, user_id: row?.user_id }).toEqual({ name: 'Kyoto', user_id: userId });
|
|
});
|
|
|
|
it('keeps a wished-for region, and refuses one outside the item country (#1901)', async () => {
|
|
const ok = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId))
|
|
.send({ name: 'Bayern', country_code: 'DE', region_code: 'DE-BY' });
|
|
expect(ok.status).toBe(201);
|
|
expect(ok.body.item.region_code).toBe('DE-BY');
|
|
const bad = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId))
|
|
.send({ name: 'Berlin', country_code: 'FR', region_code: 'DE-BE' });
|
|
expect(bad.status).toBe(400);
|
|
});
|
|
|
|
it('404 on delete of a missing bucket item', async () => {
|
|
const res = await request(server).delete('/api/addons/atlas/bucket-list/999').set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Item not found' });
|
|
});
|
|
|
|
// #1048 — the whole point of the feature over the real wire: a booked-but-not-taken
|
|
// trip must still reach the client (so the map can draw it) without counting as a
|
|
// visit. Runs on its own user so the trip-less pin above stays trip-less.
|
|
it('200 stats keeps a future trip out of the visited count but still ships it as planned', async () => {
|
|
const plannerId = createUser(db as never, { username: 'atlas-planner', email: 'atlas-planner@test.example' }).user.id;
|
|
// Offsets from today, not literal dates — a hardcoded future date expires.
|
|
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
|
const past = createTrip(db as never, plannerId, { title: 'Rome, last month', start_date: iso(-40), end_date: iso(-30) });
|
|
const future = createTrip(db as never, plannerId, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
|
|
// Address-only (no lat/lng), keeping the file's no-background-geocode property.
|
|
const unplaced = { lat: null, lng: null, category: null };
|
|
await makePlace(orm, past.id, { ...unplaced, name: 'Colosseum', address: 'Piazza del Colosseo, Rome, Italy' });
|
|
await makePlace(orm, future.id, { ...unplaced, name: 'Senso-ji', address: 'Asakusa, Tokyo, Japan' });
|
|
|
|
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(plannerId));
|
|
expect(res.status).toBe(200);
|
|
|
|
const byCode = Object.fromEntries((res.body.countries as { code: string }[]).map((c) => [c.code, c]));
|
|
// #1535: the dates run from the start to the end of the trip, not start to start.
|
|
expect(byCode['IT']).toMatchObject({ status: 'visited', firstVisit: iso(-40), lastVisit: iso(-30) });
|
|
expect(byCode['JP']).toMatchObject({ status: 'planned', firstVisit: iso(30), lastVisit: iso(40) });
|
|
expect(res.body.stats.totalCountries).toBe(1);
|
|
expect(res.body.stats.totalCountriesPlanned).toBe(1);
|
|
expect(res.body.countries.length).toBeGreaterThan(res.body.stats.totalCountries);
|
|
expect(res.body.continents).toEqual({ Europe: 1 });
|
|
expect(res.body.continentsPlanned).toEqual({ Asia: 1 });
|
|
|
|
// The country sheet agrees with the map.
|
|
const jp = await request(server).get('/api/addons/atlas/country/jp').set('Cookie', sessionCookie(plannerId));
|
|
expect(jp.status).toBe(200);
|
|
expect(jp.body.status).toBe('planned');
|
|
});
|
|
// #1535: the same journey booked as two flights, which is what an AirTrail import
|
|
// whose legs never chained leaves behind. The hub is a legitimate 'to'/'from' pair, so
|
|
// only the ground time between them says it was a plane change. Own user again.
|
|
it('200 stats leaves out a layover country whose two legs are separate bookings', async () => {
|
|
const flyerId = createUser(db as never, { username: 'atlas-flyer', email: 'atlas-flyer@test.example' }).user.id;
|
|
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
|
const trip = createTrip(db as never, flyerId, {
|
|
title: 'New York via Helsinki',
|
|
start_date: iso(-20),
|
|
end_date: iso(-10),
|
|
});
|
|
const day = iso(-15);
|
|
const insertFlight = async (title: string) => (await makeReservation(orm, trip.id, { title, type: 'flight' })).id;
|
|
const insertEndpoint = (
|
|
reservationId: number,
|
|
role: string,
|
|
sequence: number,
|
|
name: string,
|
|
lat: number,
|
|
lng: number,
|
|
code: string,
|
|
localDate: string,
|
|
localTime: string,
|
|
) =>
|
|
makeReservationEndpoint(orm, reservationId, { role, sequence, name, lat, lng, code, local_date: localDate, local_time: localTime });
|
|
const inbound = await insertFlight('BRU-HEL');
|
|
await insertEndpoint(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00');
|
|
await insertEndpoint(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30');
|
|
const onward = await insertFlight('HEL-JFK');
|
|
await insertEndpoint(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00');
|
|
await insertEndpoint(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00');
|
|
|
|
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(flyerId));
|
|
expect(res.status).toBe(200);
|
|
|
|
const codes = (res.body.countries as { code: string }[]).map((c) => c.code);
|
|
expect(codes).toContain('BE');
|
|
expect(codes).toContain('US');
|
|
expect(codes).not.toContain('FI');
|
|
expect(res.body.stats.totalCountries).toBe(2);
|
|
});
|
|
|
|
// ── /locate (#1115) ────────────────────────────────────────────────────────
|
|
describe('GET locate', () => {
|
|
it('resolves a coordinate to the country and the region the map can highlight', async () => {
|
|
// Rome. Italy has admin1 coverage in the bundle, so both come back.
|
|
const res = await request(server)
|
|
.get('/api/addons/atlas/locate?lat=41.9028&lng=12.4964')
|
|
.set('Cookie', sessionCookie(userId));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.country_code).toBe('IT');
|
|
expect(typeof res.body.region_code === 'string' || res.body.region_code === null).toBe(true);
|
|
});
|
|
|
|
it('answers with nulls out at sea rather than failing', async () => {
|
|
const res = await request(server)
|
|
.get('/api/addons/atlas/locate?lat=0&lng=-140')
|
|
.set('Cookie', sessionCookie(userId));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ country_code: null, region_code: null, region_name: null });
|
|
});
|
|
|
|
it('rejects a missing or out-of-range coordinate', async () => {
|
|
for (const query of ['', '?lat=41.9', '?lat=abc&lng=12.5', '?lat=91&lng=12.5', '?lat=41.9&lng=181']) {
|
|
const res = await request(server).get(`/api/addons/atlas/locate${query}`).set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Valid lat and lng are required' });
|
|
}
|
|
});
|
|
|
|
it('needs a session like every other atlas route', async () => {
|
|
expect((await request(server).get('/api/addons/atlas/locate?lat=41.9&lng=12.5')).status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ── GET /api/v1/stats (#1367) ─────────────────────────────────────────────
|
|
//
|
|
// Mounted from this module rather than public-api/ because its figures are
|
|
// AtlasService's; see the controller's docblock. Exercised here because this
|
|
// is the suite that already boots the real AtlasModule against a full schema —
|
|
// the public-api e2e builds a hand-rolled minimal one and could not.
|
|
describe('public API stats', () => {
|
|
async function mintApiKey(uid: number, kind = 'api'): Promise<string> {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { randomBytes } = require('crypto');
|
|
const raw = 'trek_' + randomBytes(24).toString('hex');
|
|
await makeMcpToken(orm, uid, { rawToken: raw, name: `key-${kind}-${uid}`, kind });
|
|
return raw;
|
|
}
|
|
|
|
it('401 without a key, and a session cookie is not a substitute', async () => {
|
|
expect((await request(server).get('/api/v1/stats')).status).toBe(401);
|
|
// The whole point of a machine credential: the browser session does not open it.
|
|
const res = await request(server).get('/api/v1/stats').set('Cookie', sessionCookie(userId));
|
|
expect(res.status).toBe(401);
|
|
expect(res.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
|
|
});
|
|
|
|
it('401 for an MCP token — the wrong kind is indistinguishable from no token', async () => {
|
|
const mcpKey = await mintApiKey(userId, 'mcp');
|
|
const res = await request(server).get('/api/v1/stats').set('X-API-Key', mcpKey);
|
|
expect(res.status).toBe(401);
|
|
expect(res.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
|
|
});
|
|
|
|
it('200 with counts and the last started trip, over both header spellings', async () => {
|
|
const { user } = createUser(db as never, { username: 'stats-e2e', email: 'stats-e2e@test.example' });
|
|
const key = await mintApiKey(user.id);
|
|
const iso = (offset: number) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
|
|
|
|
createTrip(db as never, user.id, { title: 'Older', start_date: iso(-90), end_date: iso(-80) });
|
|
const recent = createTrip(db as never, user.id, { title: 'Recent', start_date: iso(-40), end_date: iso(-30) });
|
|
createTrip(db as never, user.id, { title: 'Booked', start_date: iso(30), end_date: iso(40) });
|
|
// In the first category, with no coordinates of its own.
|
|
const placeId = (await makePlace(orm, recent.id, { name: 'Trevi', address: 'Piazza di Trevi, 00187, Roma, Italy', lat: null, lng: null })).id;
|
|
await upsertRow(orm, PlaceRegions, { place: placeId, country_code: 'IT', region_code: 'IT-62', region_name: 'Lazio' });
|
|
|
|
const res = await request(server).get('/api/v1/stats').set('Authorization', `Bearer ${key}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
total_trips: 3,
|
|
total_countries: 1,
|
|
total_places: 1,
|
|
last_trip: { title: 'Recent', country: 'IT', countries: ['IT'] },
|
|
next_trip: { title: 'Booked', start_date: iso(30), end_date: iso(40), days_until: 30, country: null, countries: [] },
|
|
});
|
|
// Scalars all the way — a widget maps fields, it cannot aggregate a list.
|
|
for (const k of ['total_trips', 'total_countries', 'total_cities', 'total_places', 'total_days', 'total_distance_km']) {
|
|
expect(typeof res.body[k]).toBe('number');
|
|
}
|
|
|
|
const viaHeader = await request(server).get('/api/v1/stats').set('X-API-Key', key);
|
|
expect(viaHeader.body).toEqual(res.body);
|
|
});
|
|
|
|
it('counts only the caller own trips', async () => {
|
|
const { user: stranger } = createUser(db as never, { username: 'stats-other', email: 'stats-other@test.example' });
|
|
const key = await mintApiKey(stranger.id);
|
|
const res = await request(server).get('/api/v1/stats').set('X-API-Key', key);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ total_trips: 0, total_countries: 0, last_trip: null, next_trip: null });
|
|
});
|
|
});
|
|
|
|
});
|