mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place, and a row that was already there was never re-derived. Correcting a place's location in the trip left the old row in charge, so Atlas, the dashboard stats and the journey stats kept counting the country the place had just left, with the trip's dates on it. A trigger on places now drops the cached row whenever lat, lng or address actually change, so every writer is covered: the place editor, the MCP update tools, the plugin RPC and the import enrichment and address backfill. An edit that writes the same location back keeps the row. The background lookup that fills the cache also only writes while the place still sits where it was resolved from. A place moved while its lookup was running would otherwise get the old answer written back. Places corrected before this fix would have kept their old country until they were moved again, because the trigger only sees new edits. PlaceRegionsRepairJob goes over place_regions once, on the first start after the update, and remembers that in app_settings. It re-derives each row from the bundled borders only, never Nominatim, and replaces a row whose country or region no longer matches where its place is now. A row the bundle cannot answer for is left alone unless both the coordinates and the address put the place in another country; then it is dropped and the next Atlas load looks the place up again. A place without coordinates keeps no row. Its writes are guarded the same way as the background lookup, so a place edited during the pass is not overwritten. The in-memory region cache is now keyed by the country the address names as well as the coordinates, so correcting only the address of a border point no longer returns the old answer until a restart.
This commit is contained in:
@@ -536,3 +536,73 @@ describe('Regions geo', () => {
|
||||
expect(res.body).toHaveProperty('type', 'FeatureCollection');
|
||||
});
|
||||
});
|
||||
|
||||
describe('A place that moves takes its Atlas country with it (#2527)', () => {
|
||||
// The region cache is filled by a background task that GET /regions only starts,
|
||||
// so wait for the row the way the next Atlas load would find it.
|
||||
async function regionRowOf(placeId: number): Promise<{ country_code: string; region_code: string } | undefined> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const row = testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(placeId) as
|
||||
| { country_code: string; region_code: string }
|
||||
| undefined;
|
||||
if (row) return row;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function atlasCountries(userId: number): Promise<string[]> {
|
||||
const res = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(userId));
|
||||
expect(res.status).toBe(200);
|
||||
return (res.body.countries as { code: string }[]).map((c) => c.code);
|
||||
}
|
||||
|
||||
it('ATLAS-015: correcting a place from France to Germany moves it on the Atlas', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Wrongly imported', start_date: '2025-05-01', end_date: '2025-05-05' });
|
||||
const place = testDb
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id')
|
||||
.get(trip.id, 'Hotel', 48.8566, 2.3522, 'Rue de Rivoli, Paris, France') as { id: number };
|
||||
|
||||
// The place is seen on the Atlas once, which caches France for it.
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
expect((await regionRowOf(place.id))?.country_code).toBe('FR');
|
||||
expect(await atlasCountries(user.id)).toEqual(['FR']);
|
||||
|
||||
// The user fixes the location in the trip.
|
||||
const put = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ lat: 52.5163, lng: 13.3777, address: 'Pariser Platz, Berlin, Germany' });
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
expect(await atlasCountries(user.id)).toEqual(['DE']);
|
||||
|
||||
// And the regions follow on the next load, with nothing left in France.
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
expect((await regionRowOf(place.id))?.country_code).toBe('DE');
|
||||
const regions = await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
expect(Object.keys(regions.body.regions)).toEqual(['DE']);
|
||||
expect(await atlasCountries(user.id)).toEqual(['DE']);
|
||||
});
|
||||
|
||||
it('ATLAS-015: an edit that leaves the location alone keeps the cached country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris', start_date: '2025-05-01', end_date: '2025-05-05' });
|
||||
const place = testDb
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id')
|
||||
.get(trip.id, 'Louvre', 48.8606, 2.3376, 'Rue de Rivoli, Paris, France') as { id: number };
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
const cached = await regionRowOf(place.id);
|
||||
expect(cached?.country_code).toBe('FR');
|
||||
|
||||
// The place editor sends the whole form back, location included, for a notes edit.
|
||||
const put = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'Closed on Tuesdays', lat: 48.8606, lng: 2.3376, address: 'Rue de Rivoli, Paris, France' });
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
expect(testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(place.id)).toEqual(cached);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ const ALLOWED_DESTRUCTIVE: Record<string, string> = {
|
||||
'DELETE FROM journey_entries':
|
||||
"Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.",
|
||||
'DELETE FROM place_regions':
|
||||
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.',
|
||||
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) to invalidate a stale region cache, re-resolved on next request. Also the body of trg_place_regions_follow_place (#2527): DELETE ... WHERE place_id = NEW.id when that one place\'s lat, lng or address changed. place_regions is a re-derivable cache either way.',
|
||||
'DELETE FROM visited_regions':
|
||||
'Atlas geoBoundaries swap (#1119): DELETE ... WHERE id = ? — after UPDATE OR IGNORE re-codes a manually-marked region to its current code, drop only the single leftover row whose UNIQUE(user_id, region_code) collision caused the update to be skipped (a duplicate of a region the user already has).',
|
||||
'DELETE FROM hidden_regions':
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { createTestDb } from '../../helpers/test-db';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// place_regions is Atlas's cache of the country and region a place resolved to.
|
||||
// It is derived from the place's lat, lng and address, so a change to any of them
|
||||
// has to take the row with it, whichever code path wrote the change (#2527).
|
||||
|
||||
// The trigger ships at schema version 244. Later migrations are appended after it,
|
||||
// so the replay case rewinds to just before it; the steps after it are idempotent.
|
||||
const PLACE_REGIONS_TRIGGER_VERSION = 244;
|
||||
|
||||
describe('place_regions follows the place it was resolved from (#2527)', () => {
|
||||
let db: Database.Database;
|
||||
let placeId: number;
|
||||
let otherPlaceId: number;
|
||||
|
||||
const cached = (id: number) =>
|
||||
db.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(id) as
|
||||
| { country_code: string; region_code: string }
|
||||
| undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
db.exec("INSERT INTO users (username, email, password_hash) VALUES ('traveller', 'traveller@example.test', 'x')");
|
||||
const userId = (db.prepare('SELECT id FROM users').get() as { id: number }).id;
|
||||
const tripId = db.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'Trip')").run(userId)
|
||||
.lastInsertRowid as number;
|
||||
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?)');
|
||||
placeId = insertPlace.run(tripId, 'Hotel', 48.8566, 2.3522, 'Rue de Rivoli, Paris, France')
|
||||
.lastInsertRowid as number;
|
||||
otherPlaceId = insertPlace.run(tripId, 'Museum', 48.8606, 2.3376, 'Rue de Rivoli, Paris, France')
|
||||
.lastInsertRowid as number;
|
||||
const insertRegion = db.prepare(
|
||||
"INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'FR', 'FR-IDF', 'Ile-de-France')",
|
||||
);
|
||||
insertRegion.run(placeId);
|
||||
insertRegion.run(otherPlaceId);
|
||||
});
|
||||
|
||||
afterEach(() => db?.close());
|
||||
|
||||
it('drops the cached region when the coordinates move', () => {
|
||||
db.prepare('UPDATE places SET lat = ?, lng = ? WHERE id = ?').run(52.5163, 13.3777, placeId);
|
||||
|
||||
expect(cached(placeId)).toBeUndefined();
|
||||
expect(cached(otherPlaceId)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
});
|
||||
|
||||
it('drops it when only the latitude or only the longitude changes', () => {
|
||||
db.prepare('UPDATE places SET lat = ? WHERE id = ?').run(48.9, placeId);
|
||||
db.prepare('UPDATE places SET lng = ? WHERE id = ?').run(2.4, otherPlaceId);
|
||||
|
||||
expect(cached(placeId)).toBeUndefined();
|
||||
expect(cached(otherPlaceId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops it when the address changes, including one filled in where there was none', () => {
|
||||
db.prepare('UPDATE places SET address = ? WHERE id = ?').run('Pariser Platz, Berlin, Germany', placeId);
|
||||
db.prepare('UPDATE places SET address = NULL WHERE id = ?').run(otherPlaceId);
|
||||
db.prepare(
|
||||
"INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'FR', 'FR-IDF', 'Ile-de-France')",
|
||||
).run(otherPlaceId);
|
||||
// The import backfill only ever fills an empty address.
|
||||
db.prepare('UPDATE places SET address = COALESCE(address, ?) WHERE id = ?').run('Paris, France', otherPlaceId);
|
||||
|
||||
expect(cached(placeId)).toBeUndefined();
|
||||
expect(cached(otherPlaceId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops it when the location is cleared', () => {
|
||||
db.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(placeId);
|
||||
|
||||
expect(cached(placeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps it when an edit writes the same location back', () => {
|
||||
// The place editor and update_place write every column, location included.
|
||||
db.prepare(
|
||||
"UPDATE places SET name = 'Hotel du Louvre', notes = 'late check-in', lat = ?, lng = ?, address = ? WHERE id = ?",
|
||||
).run(48.8566, 2.3522, 'Rue de Rivoli, Paris, France', placeId);
|
||||
db.prepare("UPDATE places SET place_time = '09:00', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(otherPlaceId);
|
||||
|
||||
expect(cached(placeId)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
expect(cached(otherPlaceId)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
});
|
||||
|
||||
it('ships at its own schema version and replays without a second trigger', () => {
|
||||
const triggers = () =>
|
||||
db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'places' AND name = 'trg_place_regions_follow_place'",
|
||||
)
|
||||
.all();
|
||||
expect(triggers()).toHaveLength(1);
|
||||
|
||||
db.exec('DROP TRIGGER trg_place_regions_follow_place');
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION);
|
||||
runMigrations(db);
|
||||
expect(triggers()).toHaveLength(0);
|
||||
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION - 1);
|
||||
runMigrations(db);
|
||||
runMigrations(db);
|
||||
expect(triggers()).toHaveLength(1);
|
||||
|
||||
db.prepare('UPDATE places SET lat = ? WHERE id = ?').run(52.5163, placeId);
|
||||
expect(cached(placeId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -237,6 +237,26 @@ describe('Tool: update_place', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('moving a place drops the country Atlas cached for it, renaming it does not (#2527)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel', lat: 48.8566, lng: 2.3522 });
|
||||
testDb.prepare("INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'FR', 'FR-IDF', 'Ile-de-France')").run(place.id);
|
||||
const cachedRegion = () => testDb.prepare('SELECT country_code FROM place_regions WHERE place_id = ?').get(place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Hotel Adlon' } });
|
||||
expect(cachedRegion()).toEqual({ country_code: 'FR' });
|
||||
|
||||
const moved = await h.client.callTool({
|
||||
name: 'update_place',
|
||||
arguments: { tripId: trip.id, placeId: place.id, lat: 52.5163, lng: 13.3777, address: 'Unter den Linden 77, Berlin, Germany' },
|
||||
});
|
||||
expect(moved.isError).toBeFalsy();
|
||||
expect(cachedRegion()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts place:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -1155,6 +1155,60 @@ describe('getVisitedRegions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── A place that moves (#2527) ───────────────────────────────────────────────
|
||||
|
||||
describe('a place that moves keeps no stale region', () => {
|
||||
function cachedCountryOf(placeId: number): string | undefined {
|
||||
const row = testDb.prepare('SELECT country_code FROM place_regions WHERE place_id = ?').get(placeId) as
|
||||
| { country_code: string }
|
||||
| undefined;
|
||||
return row?.country_code;
|
||||
}
|
||||
|
||||
// The lookup runs in the background; the first one in a worker also loads the admin-1 bundle.
|
||||
async function settledCountryOf(placeId: number): Promise<string | undefined> {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const code = cachedCountryOf(placeId);
|
||||
if (code) return code;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
it('ATLAS-SVC-2527a: a lookup still running when the place moves does not write the old country back', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Lyon, then Munich' });
|
||||
const place = insertPlaceWithCoords(testDb, trip.id, 'Hotel', 45.764, 4.8357, 'Lyon, France');
|
||||
|
||||
// The Atlas load starts resolving Lyon, and the place is corrected before it answers.
|
||||
const pending = atlas.visitedRegions(user.id);
|
||||
testDb
|
||||
.prepare('UPDATE places SET lat = ?, lng = ?, address = ? WHERE id = ?')
|
||||
.run(48.1374, 11.5755, 'Marienplatz, Munich, Germany', place.id);
|
||||
await pending;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(cachedCountryOf(place.id)).toBeUndefined();
|
||||
|
||||
// The next load resolves where the place is now.
|
||||
expect((await atlas.stats(user.id)).countries.map((c) => c.code)).toEqual(['DE']);
|
||||
await atlas.visitedRegions(user.id);
|
||||
expect(await settledCountryOf(place.id)).toBe('DE');
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-2527b: a place deleted while its lookup runs leaves nothing behind', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Cancelled' });
|
||||
const place = insertPlaceWithCoords(testDb, trip.id, 'Hotel', 43.2965, 5.3698, 'Marseille, France');
|
||||
|
||||
const pending = atlas.visitedRegions(user.id);
|
||||
testDb.prepare('DELETE FROM places WHERE id = ?').run(place.id);
|
||||
await pending;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM place_regions').get()).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── unmarkRegionVisited — tombstones + country cascade ──────────────────────
|
||||
|
||||
// Places are region-resolved by a fire-and-forget background task (see reverseGeocodeRegion
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { reverseGeocodeRegion } from '../../../src/nest/atlas/atlas-geo';
|
||||
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import { PLACE_REGIONS_REPAIR_DONE_KEY, PlaceRegionsRepairJob } from '../../../src/nest/atlas/place-regions-repair.job';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
import { createTrip, createUser } from '../../helpers/factories';
|
||||
import { createTestDb } from '../../helpers/test-db';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// An install that upgrades to the #2527 fix still holds the place_regions rows its
|
||||
// corrected places were cached with before it. The trigger only stops new ones from
|
||||
// going stale, so a one time pass re-derives the old rows from the bundled borders.
|
||||
|
||||
const BERLIN = { lat: 52.5163, lng: 13.3777, address: 'Unter den Linden 77, 10117 Berlin, Germany' };
|
||||
// Kehl sits inside France's bounding box, so a box check alone would call its FR row fine.
|
||||
const KEHL = { lat: 48.5761, lng: 7.8152, address: 'Hauptstrasse 1, 77694 Kehl, Germany' };
|
||||
const PARIS = { lat: 48.8566, lng: 2.3522, address: 'Rue de Rivoli, Paris, France' };
|
||||
const LYON = { lat: 45.764, lng: 4.8357, address: 'Place Bellecour, Lyon, France' };
|
||||
const MUNICH = { lat: 48.1374, lng: 11.5755, address: 'Marienplatz, Munich, Germany' };
|
||||
// The coordinates fall in Germany's simplified polygon, the address puts it in Luxembourg.
|
||||
const BOLLENDORF_PONT = {
|
||||
lat: 49.8502458,
|
||||
lng: 6.3576404,
|
||||
address: '4 Gruusswiss, Bollendorf-Pont, Distrikt Gréiwemaacher 6555, Luxembourg',
|
||||
};
|
||||
// Points the bundle has no region for: only Nominatim can answer them.
|
||||
const NICE_HARBOUR = { lat: 43.695, lng: 7.285, address: 'Quai des Etats-Unis, Nice, France' };
|
||||
const SYLT_BEACH = { lat: 54.9, lng: 8.29, address: 'Westerland, Sylt, Germany' };
|
||||
|
||||
type Location = { lat: number | null; lng: number | null; address: string | null };
|
||||
|
||||
describe('the one time repair of place_regions rows cached before #2527', () => {
|
||||
let db: Database.Database;
|
||||
let atlas: AtlasService;
|
||||
let job: PlaceRegionsRepairJob;
|
||||
let userId: number;
|
||||
let tripId: number;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const addPlace = (name: string, at: Location, cached?: [string, string, string]) => {
|
||||
const id = db
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(tripId, name, at.lat, at.lng, at.address).lastInsertRowid as number;
|
||||
if (cached) {
|
||||
db.prepare(
|
||||
'INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)',
|
||||
).run(id, ...cached);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
const cachedRow = (id: number) =>
|
||||
db.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(id) as
|
||||
| { country_code: string; region_code: string }
|
||||
| undefined;
|
||||
const markedDone = () => db.prepare('SELECT 1 FROM app_settings WHERE key = ?').get(PLACE_REGIONS_REPAIR_DONE_KEY);
|
||||
const registrar = (enabled: boolean) => ({ isEnabled: () => enabled }) as unknown as CronRegistrarService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
const dbService = new DatabaseService(db);
|
||||
atlas = new AtlasService(dbService);
|
||||
job = new PlaceRegionsRepairJob(atlas, registrar(true), dbService);
|
||||
userId = createUser(db).user.id;
|
||||
tripId = createTrip(db, userId, { title: 'Rhine', start_date: '2025-05-01', end_date: '2025-05-05' }).id;
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('shows an upgraded install the country its corrected places are in now, without editing them again', async () => {
|
||||
// The shape from the report: moved to Germany on 4.3.2, still cached in France.
|
||||
const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const rhine = addPlace('Rhine Hotel', KEHL, ['FR', 'FR-GES', 'Grand Est']);
|
||||
expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['FR']);
|
||||
|
||||
await job.runOnce();
|
||||
|
||||
expect(cachedRow(hotel)).toEqual({ country_code: 'DE', region_code: 'DE-BE' });
|
||||
expect(cachedRow(rhine)).toEqual({ country_code: 'DE', region_code: 'DE-BW' });
|
||||
expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['DE']);
|
||||
expect(Object.keys((await atlas.visitedRegions(userId)).regions)).toEqual(['DE']);
|
||||
expect(atlas.getTravelStats(userId).countries).toEqual(['DE']);
|
||||
expect(atlas.lastTrip(userId)?.countries).toEqual(['DE']);
|
||||
expect(markedDone()).toBeTruthy();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-derives a region that moved inside the same country', async () => {
|
||||
const museum = addPlace('Musee des Confluences', LYON, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 1, dropped: 0 });
|
||||
expect(cachedRow(museum)).toEqual({ country_code: 'FR', region_code: 'FR-ARA' });
|
||||
});
|
||||
|
||||
it('leaves good rows alone, the address fallback and rows only Nominatim could judge included', async () => {
|
||||
const paris = addPlace('Louvre', PARIS, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const airbnb = addPlace('Airbnb', BOLLENDORF_PONT, ['LU', 'LU-EC', 'Canton Echternach']);
|
||||
// The bundle calls this point Italian and has no region for it; the address backs the row.
|
||||
const harbour = addPlace('Port Lympia', NICE_HARBOUR, ['FR', 'FR-PAC', "Provence-Alpes-Cote d'Azur"]);
|
||||
const ship = addPlace('Cruise ship', { lat: 0.5, lng: -30, address: null }, ['BR', 'BR-PE', 'Pernambuco']);
|
||||
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 });
|
||||
expect(cachedRow(paris)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
expect(cachedRow(airbnb)).toEqual({ country_code: 'LU', region_code: 'LU-EC' });
|
||||
expect(cachedRow(harbour)).toEqual({ country_code: 'FR', region_code: 'FR-PAC' });
|
||||
expect(cachedRow(ship)).toEqual({ country_code: 'BR', region_code: 'BR-PE' });
|
||||
});
|
||||
|
||||
it('drops a row the bundle cannot re-derive when coordinates and address both name another country', async () => {
|
||||
const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']);
|
||||
const cleared = addPlace('Somewhere', { lat: null, lng: null, address: null }, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 2 });
|
||||
expect(cachedRow(beach)).toBeUndefined();
|
||||
expect(cachedRow(cleared)).toBeUndefined();
|
||||
// The dashboard, which reads only the cache, no longer counts France.
|
||||
expect(atlas.getTravelStats(userId).countries).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not write over a place that is edited while the pass runs', async () => {
|
||||
const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']);
|
||||
|
||||
// The pass has read both rows before it gives way for the first time.
|
||||
const pass = atlas.repairStaleRegionCache();
|
||||
db.prepare('UPDATE places SET lat = ?, lng = ?, address = ? WHERE id = ?').run(
|
||||
MUNICH.lat,
|
||||
MUNICH.lng,
|
||||
MUNICH.address,
|
||||
hotel,
|
||||
);
|
||||
db.prepare("UPDATE places SET address = 'Westerland, Germany' WHERE id = ?").run(beach);
|
||||
db.prepare(
|
||||
"INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'DE', 'DE-SH', 'Schleswig-Holstein')",
|
||||
).run(beach);
|
||||
|
||||
expect(await pass).toEqual({ replaced: 0, dropped: 0 });
|
||||
// The trigger dropped the moved place's row and nothing wrote Berlin back.
|
||||
expect(cachedRow(hotel)).toBeUndefined();
|
||||
// The row a fresh lookup wrote for the edited place stays.
|
||||
expect(cachedRow(beach)).toEqual({ country_code: 'DE', region_code: 'DE-SH' });
|
||||
});
|
||||
|
||||
it('runs once, and a second pass over the same rows changes nothing', async () => {
|
||||
// Enough rows for the pass to give way to other work along the way.
|
||||
const moved = Array.from({ length: 250 }, (_, i) =>
|
||||
addPlace(`Stop ${i}`, BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']),
|
||||
);
|
||||
|
||||
await job.runOnce();
|
||||
expect(moved.every((id) => cachedRow(id)?.region_code === 'DE-BE')).toBe(true);
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 });
|
||||
|
||||
// Marked as done, the next start does not read the cache again.
|
||||
const later = addPlace('Later', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
await job.runOnce();
|
||||
expect(cachedRow(later)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
});
|
||||
|
||||
it('is not marked as done when the pass fails, so the next start tries again', async () => {
|
||||
const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
vi.spyOn(atlas, 'repairStaleRegionCache').mockRejectedValueOnce(new Error('disk I/O error'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await job.runOnce();
|
||||
expect(markedDone()).toBeUndefined();
|
||||
expect(cachedRow(hotel)?.country_code).toBe('FR');
|
||||
|
||||
await job.runOnce();
|
||||
expect(markedDone()).toBeTruthy();
|
||||
expect(cachedRow(hotel)?.country_code).toBe('DE');
|
||||
});
|
||||
|
||||
it('starts from the boot hook, but not where the scheduler is off', () => {
|
||||
const dbService = new DatabaseService(db);
|
||||
const offJob = new PlaceRegionsRepairJob(atlas, registrar(false), dbService);
|
||||
const onJob = new PlaceRegionsRepairJob(atlas, registrar(true), dbService);
|
||||
const offRun = vi.spyOn(offJob, 'runOnce').mockResolvedValue();
|
||||
const onRun = vi.spyOn(onJob, 'runOnce').mockResolvedValue();
|
||||
|
||||
offJob.onApplicationBootstrap();
|
||||
onJob.onApplicationBootstrap();
|
||||
|
||||
expect(offRun).not.toHaveBeenCalled();
|
||||
expect(onRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the in-memory region cache (#2527)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('answers again when only the address of a border point changes', async () => {
|
||||
// Without an address this point falls to Nominatim, which is asked once.
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ address: { country_code: 'de', state: 'Rheinland-Pfalz', 'ISO3166-2-lvl4': 'DE-RP' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const before = await reverseGeocodeRegion(BOLLENDORF_PONT.lat, BOLLENDORF_PONT.lng, null);
|
||||
const after = await reverseGeocodeRegion(BOLLENDORF_PONT.lat, BOLLENDORF_PONT.lng, BOLLENDORF_PONT.address);
|
||||
const again = await reverseGeocodeRegion(BOLLENDORF_PONT.lat, BOLLENDORF_PONT.lng, null);
|
||||
|
||||
expect(before?.country_code).toBe('DE');
|
||||
expect(after).toEqual({ country_code: 'LU', region_code: 'LU-EC', region_name: 'Canton Echternach' });
|
||||
expect(again?.country_code).toBe('DE');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user