mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
767dc662b7
* refactor(atlas): drop the last-trip tile from the stats bar The row beside the continent counts is statistics; a single trip sitting in it was neither, and the trip it named is already one click away in the country detail below. Its two locale keys go with it, along with atlas.lastVisitLabel, which nothing had rendered for a while. * feat(atlas): resolve a coordinate to its country and region Groundwork for searching the atlas by place: /locate answers with the admin1 region a point falls in, resolved against the same bundled polygons the visited colouring uses, so the answer is always something the map can highlight. A country without admin1 coverage resolves to a country and a null region, which is a real answer rather than a failure. * feat(atlas): search the map by place, not only by country Typing a city geocodes it through the same /maps/search everything else uses and offers the hits below the instant local country matches. Picking one flies there and opens the dialog for the region it sits in, so Milan marks Lombardy without the user knowing that is where Milan is. The country list stays offline and instant; only the place lookup waits on the network, debounced and sequence-guarded so a slow answer cannot overwrite a newer query. * fix(atlas): count cities instead of shop names The visited-cities figure scanned the address left to right and took the first part that was not a country and matched a latin-only pattern. The first part of a geocoded address is the place itself, so a trip to Tokyo counted Shibuya Sky, Shibuya Parco and Family Mart as three cities, while an address written in Japanese, Cyrillic, Greek or Korean counted none at all. Read from the administrative end instead, skipping the country, anything with digits, and the region already resolved for that place in place_regions, which is what separates the city from the region above it. On one real trip the count went from 20 entries, nearly all of them streets and shops, to Paris, Shibuya, Minato and three Berlin boroughs. * i18n(atlas): translate the place-search heading * docs(atlas): document place search and how cities are counted * fix(atlas): stop the search panel closing itself while results arrive The dropdown closed on mouseleave, which was survivable while it held only local country matches and appeared complete in one go. Geocoded places land a moment later and grow the list under a cursor already resting there, so the panel shut itself seconds after opening. Closes on a click outside now, Escape unchanged.
222 lines
11 KiB
TypeScript
222 lines
11 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';
|
|
|
|
const { db } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const tmp = new Database(':memory:');
|
|
tmp.exec('PRAGMA journal_mode = WAL');
|
|
tmp.exec('PRAGMA foreign_keys = ON');
|
|
return { db: tmp };
|
|
});
|
|
|
|
vi.mock('../../src/db/database', () => ({
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => undefined,
|
|
isOwner: () => false,
|
|
}));
|
|
|
|
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
|
|
|
|
import { createTables } from '../../src/db/schema';
|
|
import { runMigrations } from '../../src/db/migrations';
|
|
import { createUser, createTrip } from '../helpers/factories';
|
|
import { AtlasModule } from '../../src/nest/atlas/atlas.module';
|
|
import { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
|
|
describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let userId: number;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, 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 () => {
|
|
createTables(db as never);
|
|
runMigrations(db as never);
|
|
userId = createUser(db as never, { username: 'atlas-e2e', email: 'atlas-e2e@test.example' }).user.id;
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.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 () => {
|
|
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(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 },
|
|
});
|
|
db.prepare('DELETE FROM visited_countries WHERE user_id = ?').run(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 = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').get(userId);
|
|
expect(row).toEqual({ country_code: 'DE' });
|
|
db.prepare('DELETE FROM visited_countries WHERE user_id = ?').run(userId);
|
|
db.prepare('DELETE FROM hidden_countries WHERE user_id = ?').run(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(db.prepare('SELECT COUNT(*) AS n FROM visited_regions WHERE user_id = ?').get(userId)).toEqual({ n: 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 = db.prepare('SELECT name, user_id FROM bucket_list WHERE id = ?').get(res.body.item.id);
|
|
expect(row).toEqual({ name: 'Kyoto', user_id: userId });
|
|
});
|
|
|
|
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 insertPlace = db.prepare('INSERT INTO places (trip_id, name, address) VALUES (?, ?, ?)');
|
|
insertPlace.run(past.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
|
|
insertPlace.run(future.id, 'Senso-ji', '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]));
|
|
expect(byCode['IT']).toMatchObject({ status: 'visited' });
|
|
expect(byCode['JP']).toMatchObject({ status: 'planned' });
|
|
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');
|
|
});
|
|
// ── /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);
|
|
});
|
|
});
|
|
});
|