mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge
This commit is contained in:
@@ -30,19 +30,20 @@ describe('ALL_ENTITIES', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan 2 Task 3: every one of the 132 entities the migrated schema
|
||||
* Plan 2 Task 3: every one of the 133 entities the migrated schema
|
||||
* describes (120 rewritten + the 5 document-sync tables added this task,
|
||||
* + push_subscriptions and google_api_usage from legacy steps 245 and 254,
|
||||
* + tour_types, tours and tour_waypoints from the Tours migration,
|
||||
* + scheduler_leases from the cron lease migration
|
||||
* + user_sessions, the session rows behind revocable session tokens)
|
||||
* + user_sessions, the session rows behind revocable session tokens
|
||||
* + ferrata_tiles, the stored via ferrata grid cells)
|
||||
* is registered, and no two share a class name (a silent duplicate would
|
||||
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
|
||||
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
|
||||
* which de-duplicates by class name).
|
||||
*/
|
||||
it('ENT-003: exactly 132 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(132);
|
||||
it('ENT-003: exactly 133 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(133);
|
||||
const names = ALL_ENTITIES.map((s) => s.meta.className);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
* `scripts/generate-entities.ts`'s `RULE1d_fixNaNNumericDefaults` and
|
||||
* `tests/unit/db/generate-entities.test.ts`'s `RULE1D-*` fixture tests for
|
||||
* the mechanism). This file pins the OUTPUT of that rule against the real,
|
||||
* committed entities (every one of the 132, not a sample) and reproduces
|
||||
* committed entities (every one of the 133, not a sample) and reproduces
|
||||
* the reviewer's own insert probe so a future regenerate/hand-edit that
|
||||
* reintroduces a `NaN` initialiser fails loudly here, not silently at a
|
||||
* caller's first `em.create()`.
|
||||
@@ -28,7 +28,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 446;
|
||||
|
||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 132 entities', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
|
||||
const failures: string[] = [];
|
||||
for (const schema of ALL_ENTITIES) {
|
||||
const Ctor = schema.class as new () => Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* `Migration20200101043200_via_ferrata_tiles_outlive_a_restart`: the table
|
||||
* that keeps the via ferrata grid cells exists only from this step on, keyed
|
||||
* by the cell, with every column required.
|
||||
*/
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const TARGET = 'Migration20200101043200_via_ferrata_tiles_outlive_a_restart';
|
||||
|
||||
async function ormBeforeTarget(): Promise<MikroORM> {
|
||||
const orm = await createMigrationOrm();
|
||||
const names = await pendingNames(orm);
|
||||
const index = names.indexOf(TARGET);
|
||||
expect(index).toBeGreaterThan(0);
|
||||
await migrateTo(orm, names[index - 1]);
|
||||
return orm;
|
||||
}
|
||||
|
||||
const tables = (orm: MikroORM) =>
|
||||
rawQuery<{ name: string }>(orm, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ferrata_tiles'");
|
||||
|
||||
describe('Via ferrata tiles migration', () => {
|
||||
it('FERTILEMIG-001: creates ferrata_tiles with a required key, payload and fetch time', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
expect(await tables(orm)).toEqual([]);
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await tables(orm)).toEqual([{ name: 'ferrata_tiles' }]);
|
||||
const columns = await rawQuery<{ name: string; type: string; notnull: number; pk: number }>(
|
||||
orm,
|
||||
'SELECT name, type, "notnull", pk FROM pragma_table_info(\'ferrata_tiles\') ORDER BY cid',
|
||||
);
|
||||
expect(columns).toEqual([
|
||||
{ name: 'tile_key', type: 'TEXT', notnull: 1, pk: 1 },
|
||||
{ name: 'payload', type: 'TEXT', notnull: 1, pk: 0 },
|
||||
{ name: 'fetched_at', type: 'INTEGER', notnull: 1, pk: 0 },
|
||||
]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('FERTILEMIG-002: holds one row per cell and refuses a row without a payload', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
await rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES ('942,224', '{}', 1)");
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES ('942,224', '{}', 2)"),
|
||||
).rejects.toThrow(/UNIQUE|PRIMARY KEY/);
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES ('942,225', NULL, 2)"),
|
||||
).rejects.toThrow(/NOT NULL/);
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES (NULL, '{}', 2)"),
|
||||
).rejects.toThrow(/NOT NULL/);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ describe('known migrations', () => {
|
||||
expect(known.has('Migration20200101042900_tour_planned_total_duration')).toBe(true);
|
||||
expect(known.has('Migration20200101043000_tour_break_additional_duration')).toBe(true);
|
||||
expect(known.has('Migration20200101043100_an_imported_tour_has_no_hiking_difficulty')).toBe(true);
|
||||
expect(known.has('Migration20200101043200_via_ferrata_tiles_outlive_a_restart')).toBe(true);
|
||||
expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101042900_tour_planned_total_duration',
|
||||
'Migration20200101043000_tour_break_additional_duration',
|
||||
'Migration20200101043100_an_imported_tour_has_no_hiking_difficulty',
|
||||
'Migration20200101043200_via_ferrata_tiles_outlive_a_restart',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* FerrataTilesRepository: the cells of the via ferrata grid that
|
||||
* FerratasService keeps between restarts, read by key and written as a batch.
|
||||
*/
|
||||
import { FerrataTiles } from '../../../../src/db/entities/FerrataTiles.entity';
|
||||
import type { FerrataTilesRepository } from '../../../../src/db/repositories/FerrataTiles.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { findRow, findRows, insertRows } from '../../../helpers/factories/rows';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let tiles: FerrataTilesRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
tiles = t.repo(FerrataTiles);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
const empty = '{"ferratas":[],"truncated":false}';
|
||||
|
||||
describe('FerrataTilesRepository.findByKeys', () => {
|
||||
it('FERTILEREPO-001: answers the stored cells among the keys and leaves the others out', async () => {
|
||||
await insertRows(t, FerrataTiles, [
|
||||
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
|
||||
{ tile_key: '942,225', payload: '{"ferratas":[],"truncated":true}', fetched_at: 200 },
|
||||
{ tile_key: '950,230', payload: empty, fetched_at: 300 },
|
||||
]);
|
||||
|
||||
const rows = await tiles.findByKeys(['942,224', '942,225', '943,224']);
|
||||
|
||||
expect(rows.sort((a, b) => a.tile_key.localeCompare(b.tile_key))).toEqual([
|
||||
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
|
||||
{ tile_key: '942,225', payload: '{"ferratas":[],"truncated":true}', fetched_at: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FERTILEREPO-002: no keys asks for nothing', async () => {
|
||||
await insertRows(t, FerrataTiles, [{ tile_key: '942,224', payload: empty, fetched_at: 100 }]);
|
||||
expect(await tiles.findByKeys([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FERTILEREPO-003: a cell on the other side of the globe keeps its negative key', async () => {
|
||||
await insertRows(t, FerrataTiles, [{ tile_key: '-1800,-3600', payload: empty, fetched_at: 5 }]);
|
||||
expect(await tiles.findByKeys(['-1800,-3600'])).toEqual([
|
||||
{ tile_key: '-1800,-3600', payload: empty, fetched_at: 5 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FerrataTilesRepository.upsertTiles', () => {
|
||||
it('FERTILEREPO-004: writes a row per new cell', async () => {
|
||||
await tiles.upsertTiles([
|
||||
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
|
||||
{ tile_key: '942,225', payload: empty, fetched_at: 100 },
|
||||
]);
|
||||
|
||||
const rows = await findRows(t, FerrataTiles, {}, { tile_key: 'asc' });
|
||||
expect(rows).toEqual([
|
||||
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
|
||||
{ tile_key: '942,225', payload: empty, fetched_at: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FERTILEREPO-005: a cell stored before gets the new payload and fetch time, still as one row', async () => {
|
||||
await insertRows(t, FerrataTiles, [{ tile_key: '942,224', payload: empty, fetched_at: 100 }]);
|
||||
const refreshed = '{"ferratas":[],"truncated":true}';
|
||||
|
||||
await tiles.upsertTiles([
|
||||
{ tile_key: '942,224', payload: refreshed, fetched_at: 900 },
|
||||
{ tile_key: '942,225', payload: empty, fetched_at: 900 },
|
||||
]);
|
||||
|
||||
expect(await findRows(t, FerrataTiles, { tile_key: '942,224' })).toHaveLength(1);
|
||||
expect(await findRow(t, FerrataTiles, { tile_key: '942,224' })).toEqual({
|
||||
tile_key: '942,224',
|
||||
payload: refreshed,
|
||||
fetched_at: 900,
|
||||
});
|
||||
expect(await findRow(t, FerrataTiles, { tile_key: '942,225' })).toMatchObject({ fetched_at: 900 });
|
||||
});
|
||||
|
||||
it('FERTILEREPO-006: an empty batch writes nothing', async () => {
|
||||
await tiles.upsertTiles([]);
|
||||
expect(await findRows(t, FerrataTiles)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
FERRATA_QUERY_TIMEOUT_S,
|
||||
buildFerrataQuery,
|
||||
ferrataCacheKey,
|
||||
ferrataTileBlocks,
|
||||
ferrataTileKeysFor,
|
||||
ferrataTilesBbox,
|
||||
overpassFerrataAnswerSchema,
|
||||
parseFerrataGrade,
|
||||
snapFerrataBbox,
|
||||
splitFerratasByTile,
|
||||
toFerratas,
|
||||
} from '../../../src/nest/ferratas/ferratas.helpers';
|
||||
import { FERRATAS_RESULT_CAP } from '@trek/shared';
|
||||
import { FERRATAS_RESULT_CAP, type Ferrata } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -48,7 +51,7 @@ describe('parseFerrataGrade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapFerrataBbox and ferrataCacheKey', () => {
|
||||
describe('snapFerrataBbox', () => {
|
||||
it('grows the box outwards to the 0.05 degree grid', () => {
|
||||
expect(snapFerrataBbox({ south: 47.12, west: 11.21, north: 47.38, east: 11.57 })).toEqual({
|
||||
south: 47.1,
|
||||
@@ -71,12 +74,119 @@ describe('snapFerrataBbox and ferrataCacheKey', () => {
|
||||
east: -179.9,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('gives two nearby requests the same key', () => {
|
||||
const a = ferrataCacheKey(snapFerrataBbox({ south: 47.11, west: 11.21, north: 47.39, east: 11.59 }));
|
||||
const b = ferrataCacheKey(snapFerrataBbox({ south: 47.12, west: 11.22, north: 47.38, east: 11.58 }));
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('47.1,11.2,47.4,11.6');
|
||||
describe('ferrataTileKeysFor', () => {
|
||||
it('names every whole cell the snapped box covers, south to north and west to east', () => {
|
||||
expect(ferrataTileKeysFor({ south: 47.12, west: 11.21, north: 47.18, east: 11.29 }, 200)).toEqual([
|
||||
'942,224',
|
||||
'942,225',
|
||||
'943,224',
|
||||
'943,225',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives two nearby requests the same cells', () => {
|
||||
const a = ferrataTileKeysFor({ south: 47.11, west: 11.21, north: 47.39, east: 11.59 }, 200);
|
||||
const b = ferrataTileKeysFor({ south: 47.12, west: 11.22, north: 47.38, east: 11.58 }, 200);
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toHaveLength(6 * 8);
|
||||
});
|
||||
|
||||
it('a box on the grid covers exactly its own cells', () => {
|
||||
expect(ferrataTileKeysFor({ south: 47.1, west: 11.2, north: 47.15, east: 11.25 }, 200)).toEqual(['942,224']);
|
||||
});
|
||||
|
||||
it('keys cells south of the equator and west of Greenwich by negative indices', () => {
|
||||
expect(ferrataTileKeysFor({ south: -0.04, west: -0.04, north: 0.04, east: 0.04 }, 200)).toEqual([
|
||||
'-1,-1',
|
||||
'-1,0',
|
||||
'0,-1',
|
||||
'0,0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers null for a box over the cell cap instead of walking it', () => {
|
||||
expect(ferrataTileKeysFor({ south: 47, west: 11, north: 48, east: 12 }, 200)).toBeNull();
|
||||
expect(ferrataTileKeysFor({ south: 47, west: 11, north: 47.5, east: 11.5 }, 100)).toHaveLength(100);
|
||||
expect(ferrataTileKeysFor({ south: 47, west: 11, north: 47.5, east: 11.5 }, 99)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataTileBlocks', () => {
|
||||
it('groups cells into the grid-aligned blocks of two by two that one query covers', () => {
|
||||
expect(ferrataTileBlocks(['942,224', '942,225', '943,224', '944,224', '942,226', '-1,-1', '-2,-1'])).toEqual([
|
||||
['942,224', '942,225', '943,224'],
|
||||
['944,224'],
|
||||
['942,226'],
|
||||
['-1,-1', '-2,-1'],
|
||||
]);
|
||||
expect(ferrataTileBlocks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a key that is not a cell', () => {
|
||||
expect(() => ferrataTileBlocks(['nope'])).toThrow(/tile key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataTilesBbox', () => {
|
||||
it('bounds one cell by its own edges', () => {
|
||||
expect(ferrataTilesBbox(['942,224'])).toEqual({ south: 47.1, west: 11.2, north: 47.15, east: 11.25 });
|
||||
});
|
||||
|
||||
it('bounds scattered cells by one box around all of them', () => {
|
||||
expect(ferrataTilesBbox(['942,230', '945,224', '-1,-1'])).toEqual({
|
||||
south: -0.05,
|
||||
west: -0.05,
|
||||
north: 47.3,
|
||||
east: 11.55,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses no cells and a key that is not a cell', () => {
|
||||
expect(() => ferrataTilesBbox([])).toThrow();
|
||||
expect(() => ferrataTilesBbox(['947.1,11'])).toThrow(/tile key/);
|
||||
expect(() => ferrataTilesBbox(['1,2,3'])).toThrow(/tile key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitFerratasByTile', () => {
|
||||
const ferrata = (id: string, geometry: [number, number][]): Ferrata => ({
|
||||
id,
|
||||
name: null,
|
||||
grade: { raw: null, level: null, plus: null },
|
||||
routeName: null,
|
||||
geometry,
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
});
|
||||
|
||||
it('hands a ferrata to every asked cell a point of its line lies in', () => {
|
||||
const crossing = ferrata('way/1', [
|
||||
[47.12, 11.21],
|
||||
[47.17, 11.21],
|
||||
]);
|
||||
const byTile = splitFerratasByTile([crossing], new Set(['942,224', '943,224', '942,225']));
|
||||
expect(byTile.get('942,224')).toEqual([crossing]);
|
||||
expect(byTile.get('943,224')).toEqual([crossing]);
|
||||
expect(byTile.get('942,225')).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a point on a cell edge for the cells on both sides', () => {
|
||||
const onCorner = ferrata('way/2', [
|
||||
[47.15, 11.25],
|
||||
[47.15, 11.25],
|
||||
]);
|
||||
const byTile = splitFerratasByTile([onCorner], new Set(['942,224', '942,225', '943,224', '943,225']));
|
||||
expect([...byTile.values()].every((list) => list.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives every asked cell a list, empty where nothing lies, and leaves cells it was not asked about out', () => {
|
||||
const elsewhere = ferrata('way/3', [
|
||||
[46.51, 12.06],
|
||||
[46.52, 12.07],
|
||||
]);
|
||||
const byTile = splitFerratasByTile([elsewhere], new Set(['942,224']));
|
||||
expect([...byTile.entries()]).toEqual([['942,224', []]]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,117 +1,454 @@
|
||||
/**
|
||||
* FerratasService over a real snapshot db: the box resolved into grid cells,
|
||||
* the cells served from `ferrata_tiles` (and the memory in front of it), and
|
||||
* Overpass, faked, asked once per 0.1 degree block for the cells never fetched.
|
||||
*/
|
||||
import { FerrataTiles } from '../../../src/db/entities/FerrataTiles.entity';
|
||||
import { FerrataTilesRepository } from '../../../src/db/repositories/FerrataTiles.repository';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { FerratasOverpassClient } from '../../../src/nest/ferratas/ferratas-overpass.client';
|
||||
import { FerratasModule } from '../../../src/nest/ferratas/ferratas.module';
|
||||
import {
|
||||
FERRATA_CACHE_MAX,
|
||||
FERRATA_CACHE_TTL_MS,
|
||||
FERRATA_TILE_STALE_MS,
|
||||
FERRATAS_UNAVAILABLE_MESSAGE,
|
||||
FerratasService,
|
||||
} from '../../../src/nest/ferratas/ferratas.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FERRATAS_ATTRIBUTION, FERRATAS_UNAVAILABLE_CODE, ferratasResponseSchema } from '@trek/shared';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { countRows, deleteRows, findRow, findRows, insertRows } from '../../helpers/factories/rows';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import {
|
||||
FERRATA_BBOX_TOO_LARGE_MESSAGE,
|
||||
FERRATAS_ATTRIBUTION,
|
||||
FERRATAS_RESULT_CAP,
|
||||
FERRATAS_UNAVAILABLE_CODE,
|
||||
ferratasResponseSchema,
|
||||
type Ferrata,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const bbox = { south: 47.12, west: 11.21, north: 47.38, east: 11.57 };
|
||||
const db = createSnapshotTestDb();
|
||||
const NOW = 1_800_000_000_000;
|
||||
/** What `fetched_at` holds for NOW: the table keeps seconds. */
|
||||
const NOW_S = NOW / 1000;
|
||||
const seconds = (ms: number) => Math.floor(ms / 1000);
|
||||
|
||||
/** 47.1 to 47.2 north, 11.2 to 11.3 east once snapped: 2 by 2 cells, one block of one query. */
|
||||
const bbox = { south: 47.12, west: 11.21, north: 47.18, east: 11.29 };
|
||||
/** 47.1 to 47.4 north, 11.2 to 11.6 east once snapped: 6 by 8 cells, 3 by 4 blocks. */
|
||||
const wide = { south: 47.12, west: 11.21, north: 47.38, east: 11.57 };
|
||||
const element = {
|
||||
type: 'way',
|
||||
id: 42,
|
||||
tags: { highway: 'via_ferrata', name: 'Klettersteig', via_ferrata_scale: '2+' },
|
||||
geometry: [
|
||||
{ lat: 47.2, lon: 11.3 },
|
||||
{ lat: 47.21, lon: 11.31 },
|
||||
{ lat: 47.15, lon: 11.25 },
|
||||
{ lat: 47.16, lon: 11.26 },
|
||||
],
|
||||
};
|
||||
|
||||
const stored = (id: string, geometry: [number, number][]): Ferrata => ({
|
||||
id,
|
||||
name: 'Stored',
|
||||
grade: { raw: null, level: null, plus: null },
|
||||
routeName: null,
|
||||
geometry,
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
});
|
||||
const payload = (ferratas: Ferrata[], truncated = false) => JSON.stringify({ ferratas, truncated });
|
||||
|
||||
const query = vi.fn<(q: string) => Promise<unknown[]>>();
|
||||
let t: TestModule | undefined;
|
||||
let service: FerratasService;
|
||||
const orm = () => {
|
||||
if (!t) throw new Error('No container booted');
|
||||
return t.get(MikroORM);
|
||||
};
|
||||
|
||||
/** A fresh container on the same db: an empty memory, what a restart looks like. */
|
||||
async function boot(): Promise<void> {
|
||||
await t?.close();
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [FerratasModule],
|
||||
overrides: [{ provide: FerratasOverpassClient, useValue: { query } }],
|
||||
});
|
||||
service = t.get(FerratasService);
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<unknown[]>; resolve: (v: unknown[]) => void; reject: (e: Error) => void } {
|
||||
let resolve: (v: unknown[]) => void = () => {};
|
||||
let reject: (e: Error) => void = () => {};
|
||||
const promise = new Promise<unknown[]>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(db);
|
||||
query.mockReset();
|
||||
query.mockResolvedValue([element]);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [FerratasService, { provide: FerratasOverpassClient, useValue: { query } }],
|
||||
}).compile();
|
||||
service = moduleRef.get(FerratasService);
|
||||
await boot();
|
||||
});
|
||||
|
||||
describe('FerratasService.list', () => {
|
||||
it('answers the ferratas with source and attribution, inside the response contract', async () => {
|
||||
const res = await service.list(bbox);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('FerratasService.list: cells fetched for the first time', () => {
|
||||
it('FERSVC-001: answers the ferratas with source and attribution, inside the response contract', async () => {
|
||||
const res = await service.list(bbox, NOW);
|
||||
expect(res).toMatchObject({ truncated: false, source: 'openstreetmap', attribution: FERRATAS_ATTRIBUTION });
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(ferratasResponseSchema.safeParse(res).success).toBe(true);
|
||||
});
|
||||
|
||||
it('answers only the ferratas that reach into the asked box, not the whole snapped box', async () => {
|
||||
it('FERSVC-002: asks Overpass once for a box of one block', async () => {
|
||||
await service.list(bbox, NOW);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(query.mock.calls[0]?.[0]).toContain('(47.1,11.2,47.2,11.3)');
|
||||
});
|
||||
|
||||
it('FERSVC-023: asks a wider box block by block, never more than 0.1 degree a side, at most two at once', async () => {
|
||||
const gates = Array.from({ length: 12 }, () => deferred());
|
||||
let next = 0;
|
||||
query.mockImplementation(() => (gates[next++] ?? deferred()).promise);
|
||||
|
||||
const answer = service.list(wide, NOW);
|
||||
await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(2));
|
||||
gates[0]?.resolve([element]);
|
||||
await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(3));
|
||||
for (const gate of gates) gate.resolve([element]);
|
||||
const res = await answer;
|
||||
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(query).toHaveBeenCalledTimes(12);
|
||||
for (const [text] of query.mock.calls) {
|
||||
const [south, west, north, east] = (/\(([\d.,-]+)\)/.exec(text)?.[1] ?? '').split(',').map(Number);
|
||||
expect((north ?? 0) - (south ?? 0)).toBeLessThanOrEqual(0.1 + 1e-9);
|
||||
expect((east ?? 0) - (west ?? 0)).toBeLessThanOrEqual(0.1 + 1e-9);
|
||||
}
|
||||
expect(await countRows(orm(), FerrataTiles)).toBe(6 * 8);
|
||||
});
|
||||
|
||||
it('FERSVC-003: stores every cell, each with the ferratas whose line touches it, at the time in seconds', async () => {
|
||||
await service.list(bbox, NOW);
|
||||
|
||||
expect(await countRows(orm(), FerrataTiles)).toBe(2 * 2);
|
||||
// 47.15 and 11.25 lie on cell edges, so way 42 belongs to the four cells around that corner.
|
||||
const holding = (await findRows(orm(), FerrataTiles, {}, { tile_key: 'asc' }))
|
||||
.filter((row) => row.payload.includes('way/42'))
|
||||
.map((row) => row.tile_key);
|
||||
expect(holding).toEqual(['942,224', '942,225', '943,224', '943,225']);
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '942,224' })).toMatchObject({ fetched_at: NOW_S });
|
||||
|
||||
// The next block north holds none of it: its cells are stored empty.
|
||||
await service.list({ south: 47.21, west: 11.21, north: 47.29, east: 11.29 }, NOW);
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '944,224' })).toEqual({
|
||||
tile_key: '944,224',
|
||||
payload: payload([]),
|
||||
fetched_at: NOW_S,
|
||||
});
|
||||
});
|
||||
|
||||
it('FERSVC-004: answers a ferrata crossing several cells once', async () => {
|
||||
const res = await service.list(bbox, NOW);
|
||||
expect(res.ferratas).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FERSVC-005: answers only the ferratas that reach into the asked box, not the whole cells', async () => {
|
||||
const outside = {
|
||||
...element,
|
||||
id: 43,
|
||||
geometry: [
|
||||
{ lat: 47.1, lon: 11.3 },
|
||||
{ lat: 47.11, lon: 11.31 },
|
||||
{ lat: 47.1, lon: 11.22 },
|
||||
{ lat: 47.11, lon: 11.23 },
|
||||
],
|
||||
};
|
||||
query.mockResolvedValue([element, outside]);
|
||||
|
||||
const res = await service.list(bbox);
|
||||
const res = await service.list(bbox, NOW);
|
||||
|
||||
expect(res.ferratas.map((ferrata) => ferrata.id)).toEqual(['way/42']);
|
||||
// The cell still keeps it, for the next box that does reach that far south.
|
||||
expect((await findRow(orm(), FerrataTiles, { tile_key: '942,224' }))?.payload).toContain('way/43');
|
||||
});
|
||||
|
||||
it('asks Overpass for the box snapped to the grid', async () => {
|
||||
await service.list(bbox);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(query.mock.calls[0]?.[0]).toContain('(47.1,11.2,47.4,11.6)');
|
||||
});
|
||||
|
||||
it('serves a nearby request from the cache inside the TTL', async () => {
|
||||
await service.list(bbox, 1_000);
|
||||
await service.list({ south: 47.11, west: 11.22, north: 47.39, east: 11.59 }, 1_000 + FERRATA_CACHE_TTL_MS - 1);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks again once the entry is older than the TTL', async () => {
|
||||
await service.list(bbox, 1_000);
|
||||
await service.list(bbox, 1_000 + FERRATA_CACHE_TTL_MS);
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares one Overpass call between concurrent identical requests', async () => {
|
||||
let release: (v: unknown[]) => void = () => {};
|
||||
query.mockReturnValueOnce(new Promise((resolve) => (release = resolve)));
|
||||
const both = Promise.all([service.list(bbox), service.list(bbox)]);
|
||||
release([element]);
|
||||
it('FERSVC-006: shares one Overpass call between concurrent identical requests', async () => {
|
||||
const pending = deferred();
|
||||
query.mockReturnValueOnce(pending.promise);
|
||||
const both = Promise.all([service.list(bbox, NOW), service.list(bbox, NOW)]);
|
||||
await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(1));
|
||||
pending.resolve([element]);
|
||||
const [a, b] = await both;
|
||||
expect(a).toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('turns an Overpass failure into a 502 with a code, and does not cache it', async () => {
|
||||
it('FERSVC-007: asks a cut block again cell by cell, and stores a cell still cut as old so a restart asks again', async () => {
|
||||
const many = Array.from({ length: FERRATAS_RESULT_CAP + 1 }, (_, i) => ({ ...element, id: 1000 + i }));
|
||||
query.mockResolvedValue(many);
|
||||
|
||||
const res = await service.list(bbox, NOW);
|
||||
|
||||
expect(res.truncated).toBe(true);
|
||||
expect(res.ferratas).toHaveLength(FERRATAS_RESULT_CAP);
|
||||
expect(ferratasResponseSchema.safeParse(res).success).toBe(true);
|
||||
// The block, then each of its four cells on its own.
|
||||
expect(query).toHaveBeenCalledTimes(1 + 4);
|
||||
expect(query.mock.calls[1]?.[0]).toContain('(47.1,11.2,47.15,11.25)');
|
||||
const row = await findRow(orm(), FerrataTiles, { tile_key: '942,224' });
|
||||
expect(row?.fetched_at).toBe(0);
|
||||
expect(row?.payload).toContain('"truncated":true');
|
||||
|
||||
// This process keeps the cut cells; a restart serves them and asks again in the background.
|
||||
expect((await service.list(bbox, NOW + 1)).truncated).toBe(true);
|
||||
expect(query).toHaveBeenCalledTimes(5);
|
||||
await boot();
|
||||
query.mockReset();
|
||||
query.mockResolvedValue([element]);
|
||||
expect((await service.list(bbox, NOW)).truncated).toBe(true);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '942,224' })).toMatchObject({ fetched_at: NOW_S }),
|
||||
);
|
||||
expect((await service.list(bbox, NOW + 1)).truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('FERSVC-024: a block cut at the cap whose cells each fit is stored whole, and not marked cut', async () => {
|
||||
const many = Array.from({ length: FERRATAS_RESULT_CAP + 1 }, (_, i) => ({ ...element, id: 1000 + i }));
|
||||
query.mockResolvedValueOnce(many).mockResolvedValue([element]);
|
||||
|
||||
const res = await service.list(bbox, NOW);
|
||||
|
||||
expect(res.truncated).toBe(false);
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(query).toHaveBeenCalledTimes(5);
|
||||
const rows = await findRows(orm(), FerrataTiles, {}, { tile_key: 'asc' });
|
||||
expect(rows.map((r) => [r.tile_key, r.fetched_at, r.payload.includes('"truncated":false')])).toEqual([
|
||||
['942,224', NOW_S, true],
|
||||
['942,225', NOW_S, true],
|
||||
['943,224', NOW_S, true],
|
||||
['943,225', NOW_S, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FERSVC-008: refuses a box over the cell cap before asking anyone', async () => {
|
||||
const err = await service.list({ south: 47, west: 11, north: 48, east: 12 }, NOW).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(DomainError);
|
||||
expect((err as DomainError).getStatus()).toBe(400);
|
||||
expect((err as DomainError).getResponse()).toEqual({ error: FERRATA_BBOX_TOO_LARGE_MESSAGE });
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FerratasService.list: cells already stored', () => {
|
||||
it('FERSVC-009: serves a nearby request from the cells, without Overpass', async () => {
|
||||
await service.list(bbox, NOW);
|
||||
const res = await service.list({ south: 47.11, west: 11.22, north: 47.19, east: 11.28 }, NOW + 1);
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FERSVC-010: serves the cells from the table after a restart', async () => {
|
||||
await service.list(bbox, NOW);
|
||||
await boot();
|
||||
|
||||
const res = await service.list(bbox, NOW + 1);
|
||||
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FERSVC-011: serves the cells from memory without reading the table again', async () => {
|
||||
await service.list(bbox, NOW);
|
||||
await deleteRows(orm(), FerrataTiles);
|
||||
|
||||
const res = await service.list(bbox, NOW + 1);
|
||||
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FERSVC-012: asks Overpass only for the cells not stored yet', async () => {
|
||||
await service.list({ south: 47.1, west: 11.2, north: 47.2, east: 11.3 }, NOW);
|
||||
query.mockClear();
|
||||
|
||||
await service.list({ south: 47.1, west: 11.2, north: 47.3, east: 11.3 }, NOW);
|
||||
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(query.mock.calls[0]?.[0]).toContain('(47.2,11.2,47.3,11.3)');
|
||||
expect(await countRows(orm(), FerrataTiles)).toBe(4 * 2);
|
||||
});
|
||||
|
||||
it('FERSVC-013: reads a stored cell that does not decode as never fetched, and fetches it again', async () => {
|
||||
await insertRows(orm(), FerrataTiles, [{ tile_key: '942,224', payload: '{"ferratas":"nope"}', fetched_at: NOW_S }]);
|
||||
query.mockResolvedValue([]);
|
||||
|
||||
await service.list({ south: 47.1, west: 11.2, north: 47.15, east: 11.25 }, NOW + 1000);
|
||||
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '942,224' })).toEqual({
|
||||
tile_key: '942,224',
|
||||
payload: payload([]),
|
||||
fetched_at: NOW_S + 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FerratasService.list: old cells', () => {
|
||||
const cell = { south: 47.1, west: 11.2, north: 47.15, east: 11.25 };
|
||||
const old = stored('way/7', [
|
||||
[47.11, 11.21],
|
||||
[47.12, 11.22],
|
||||
]);
|
||||
const seedOld = () =>
|
||||
insertRows(orm(), FerrataTiles, [
|
||||
{ tile_key: '942,224', payload: payload([old]), fetched_at: seconds(NOW - FERRATA_TILE_STALE_MS) },
|
||||
]);
|
||||
|
||||
it('FERSVC-014: serves an old cell at once and refreshes it in the background', async () => {
|
||||
await seedOld();
|
||||
const pending = deferred();
|
||||
query.mockReturnValueOnce(pending.promise);
|
||||
|
||||
const res = await service.list(cell, NOW);
|
||||
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/7']);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(query.mock.calls[0]?.[0]).toContain('(47.1,11.2,47.15,11.25)');
|
||||
|
||||
pending.resolve([]);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '942,224' })).toEqual({
|
||||
tile_key: '942,224',
|
||||
payload: payload([]),
|
||||
fetched_at: NOW_S,
|
||||
}),
|
||||
);
|
||||
expect((await service.list(cell, NOW + 1)).ferratas).toEqual([]);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FERSVC-015: a cell just under the age limit is not refreshed', async () => {
|
||||
await insertRows(orm(), FerrataTiles, [
|
||||
{ tile_key: '942,224', payload: payload([old]), fetched_at: seconds(NOW - FERRATA_TILE_STALE_MS) + 1 },
|
||||
]);
|
||||
await service.list(cell, NOW);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FERSVC-016: starts one refresh for an old cell, however many requests see it', async () => {
|
||||
await seedOld();
|
||||
const pending = deferred();
|
||||
query.mockReturnValueOnce(pending.promise);
|
||||
|
||||
await service.list(cell, NOW);
|
||||
await service.list(cell, NOW);
|
||||
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
pending.resolve([]);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '942,224' })).toMatchObject({ fetched_at: NOW_S }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FERSVC-017: a failed refresh keeps the old cell, never reaches the client, and is tried again later', async () => {
|
||||
await seedOld();
|
||||
query.mockRejectedValueOnce(new Error('Could not reach any Overpass endpoint'));
|
||||
const err = await service.list(bbox).catch((e: unknown) => e);
|
||||
|
||||
const res = await service.list(cell, NOW);
|
||||
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/7']);
|
||||
// Let the rejected refresh settle; an unhandled rejection would fail the run.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '942,224' })).toMatchObject({
|
||||
fetched_at: seconds(NOW - FERRATA_TILE_STALE_MS),
|
||||
});
|
||||
|
||||
query.mockResolvedValueOnce([]);
|
||||
expect((await service.list(cell, NOW + 1000)).ferratas.map((f) => f.id)).toEqual(['way/7']);
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await findRow(orm(), FerrataTiles, { tile_key: '942,224' })).toMatchObject({ fetched_at: NOW_S + 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FERSVC-018: an old cell is refreshed in a query of its own, so its failure cannot fail the new cells', async () => {
|
||||
await seedOld();
|
||||
query.mockImplementation((text) =>
|
||||
text.includes('(47.1,11.2,47.15,11.25)') ? Promise.reject(new Error('timeout')) : Promise.resolve([]),
|
||||
);
|
||||
|
||||
const res = await service.list({ south: 47.1, west: 11.2, north: 47.2, east: 11.25 }, NOW);
|
||||
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
expect(query.mock.calls.map(([text]) => text.match(/\(([\d.,]+)\)/)?.[1]).sort()).toEqual([
|
||||
'47.1,11.2,47.15,11.25',
|
||||
'47.15,11.2,47.2,11.25',
|
||||
]);
|
||||
// The old cell is served as it was; the new one came back on its own.
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/7']);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(await findRows(orm(), FerrataTiles, {}, { tile_key: 'asc' })).toEqual([
|
||||
{ tile_key: '942,224', payload: payload([old]), fetched_at: seconds(NOW - FERRATA_TILE_STALE_MS) },
|
||||
{ tile_key: '943,224', payload: payload([]), fetched_at: NOW_S },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FerratasService.list: Overpass failing', () => {
|
||||
it('FERSVC-019: turns a failure for a cell never fetched into a 502 with a code, and stores nothing', async () => {
|
||||
query.mockRejectedValueOnce(new Error('Could not reach any Overpass endpoint'));
|
||||
const err = await service.list(bbox, NOW).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(DomainError);
|
||||
const domain = err as DomainError;
|
||||
expect(domain.getStatus()).toBe(502);
|
||||
expect(domain.code).toBe(FERRATAS_UNAVAILABLE_CODE);
|
||||
expect(domain.getResponse()).toEqual({ error: FERRATAS_UNAVAILABLE_MESSAGE, code: FERRATAS_UNAVAILABLE_CODE });
|
||||
expect(await countRows(orm(), FerrataTiles)).toBe(0);
|
||||
|
||||
const retry = await service.list(bbox);
|
||||
const retry = await service.list(bbox, NOW);
|
||||
expect(retry.ferratas).toHaveLength(1);
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('evicts the oldest box once the cache is full', async () => {
|
||||
const boxAt = (i: number) => ({ south: 10 + i * 0.1, west: 10, north: 10.04 + i * 0.1, east: 10.04 });
|
||||
// One after another, so the insertion order is the call order.
|
||||
await Array.from({ length: FERRATA_CACHE_MAX + 1 }, (_, i) => i).reduce<Promise<unknown>>(async (previous, i) => {
|
||||
await previous;
|
||||
return service.list(boxAt(i), 1_000);
|
||||
}, Promise.resolve());
|
||||
expect(query).toHaveBeenCalledTimes(FERRATA_CACHE_MAX + 1);
|
||||
it('FERSVC-020: fails every request that joined the failed batch', async () => {
|
||||
const pending = deferred();
|
||||
query.mockReturnValueOnce(pending.promise);
|
||||
const both = Promise.allSettled([service.list(bbox, NOW), service.list(bbox, NOW)]);
|
||||
await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(1));
|
||||
pending.reject(new Error('down'));
|
||||
const results = await both;
|
||||
expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await service.list(boxAt(FERRATA_CACHE_MAX), 1_000);
|
||||
expect(query).toHaveBeenCalledTimes(FERRATA_CACHE_MAX + 1);
|
||||
await service.list(boxAt(0), 1_000);
|
||||
expect(query).toHaveBeenCalledTimes(FERRATA_CACHE_MAX + 2);
|
||||
it('FERSVC-021: needs no Overpass at all while every cell is stored and fresh', async () => {
|
||||
await service.list(bbox, NOW);
|
||||
query.mockRejectedValue(new Error('down'));
|
||||
const res = await service.list(bbox, NOW + 1);
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FERSVC-022: an answer the table refuses to store is still served, and kept in memory', async () => {
|
||||
vi.spyOn(FerrataTilesRepository.prototype, 'upsertTiles').mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
const res = await service.list(bbox, NOW);
|
||||
|
||||
expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']);
|
||||
expect(await countRows(orm(), FerrataTiles)).toBe(0);
|
||||
await service.list(bbox, NOW + 1);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user