mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
563 lines
23 KiB
TypeScript
563 lines
23 KiB
TypeScript
/**
|
|
* PAD-06 and PAD-09 on the server, from the container over a snapshot db: a
|
|
* paddle tour's checklist in its detail and its check bench in the tours
|
|
* list, the cached reading of the obstacle data, the checklist while the data
|
|
* cannot be read, and saving decisions bound to the basis the user saw.
|
|
*/
|
|
import { PaddleFeatures } from '../../../src/db/entities/PaddleFeatures.entity';
|
|
import { PaddleRegions } from '../../../src/db/entities/PaddleRegions.entity';
|
|
import { TourObstacleReviews } from '../../../src/db/entities/TourObstacleReviews.entity';
|
|
import { DomainError } from '../../../src/nest/common/domain-error';
|
|
import { PaddleObstaclesService } from '../../../src/nest/paddle-obstacles/paddle-obstacles.service';
|
|
import {
|
|
NOT_A_PADDLE_TOUR_MESSAGE,
|
|
PADDLE_DATA_UNAVAILABLE_MESSAGE,
|
|
TourObstacleReviewsService,
|
|
} from '../../../src/nest/tours/tour-obstacle-reviews.service';
|
|
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
|
import { ToursService } from '../../../src/nest/tours/tours.service';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { makePlace } from '../../helpers/factories/places';
|
|
import { deleteRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
|
|
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
|
import { makeTrip } from '../../helpers/factories/trips';
|
|
import { makeUser } from '../../helpers/factories/users';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import {
|
|
PADDLE_CHECK_MAX_ENTRIES,
|
|
tourDetailResponseSchema,
|
|
tourListItemSchema,
|
|
type ObstacleDecision,
|
|
type ObstacleReviewOp,
|
|
type TourDetailResponse,
|
|
type TourObstacleEntry,
|
|
} from '@trek/shared';
|
|
|
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() }));
|
|
vi.mock('../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../../src/nest/audit/audit-log.logger')>()),
|
|
...log,
|
|
}));
|
|
|
|
const db = createSnapshotTestDb();
|
|
let t: TestModule | undefined;
|
|
let tours: ToursService;
|
|
let reviews: TourObstacleReviewsService;
|
|
let obstacles: PaddleObstaclesService;
|
|
const orm = () => {
|
|
if (!t) throw new Error('No container booted');
|
|
return t.get(MikroORM);
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
resetTestDb(db);
|
|
await t?.close();
|
|
// A container per test: the readings it caches belong to the data of one test.
|
|
t = await createTestModule({ db, imports: [ToursModule] });
|
|
tours = t.get(ToursService);
|
|
reviews = t.get(TourObstacleReviewsService);
|
|
obstacles = t.get(PaddleObstaclesService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await t?.close();
|
|
db.close();
|
|
});
|
|
|
|
async function region(id = 'oberpfalz') {
|
|
await insertRow(orm(), PaddleRegions, {
|
|
id,
|
|
name: 'Oberpfalz',
|
|
boundary: null,
|
|
min_lat: 48.9,
|
|
min_lng: 11.5,
|
|
max_lat: 50,
|
|
max_lng: 13,
|
|
status: 'ready',
|
|
feature_count: 1,
|
|
source_timestamp: '2026-10-01T00:00:00.000Z',
|
|
imported_at: '2026-10-02 04:20:00',
|
|
});
|
|
}
|
|
|
|
function weir(ref: string, lat: number, lng: number, tags: Record<string, string> = {}) {
|
|
return {
|
|
region: 'oberpfalz',
|
|
osm_ref: ref,
|
|
kind: 'weir',
|
|
group_key: null,
|
|
name: 'Wehr Regendorf',
|
|
geometry: JSON.stringify({ type: 'Point', coordinates: [lng, lat] }),
|
|
tags: JSON.stringify({ waterway: 'weir', name: 'Wehr Regendorf', ...tags }),
|
|
tag_hash: `hash-${ref}`,
|
|
min_lat: lat,
|
|
min_lng: lng,
|
|
max_lat: lat,
|
|
max_lng: lng,
|
|
};
|
|
}
|
|
|
|
/** A trip with a paddle tour east along 49.2 north, a weir in its middle, and a hike. */
|
|
async function fixture(options: { boat?: 'kayak' | 'sup' } = {}) {
|
|
const { user } = await makeUser(orm());
|
|
const trip = await makeTrip(orm(), user.id);
|
|
const paddle = await makePlace(orm(), trip.id, {
|
|
name: 'Regen',
|
|
lat: 49.2,
|
|
lng: 12,
|
|
route_geometry: JSON.stringify([
|
|
[49.2, 12.0],
|
|
[49.2, 12.01],
|
|
]),
|
|
});
|
|
await makeTour(orm(), paddle.id, { tourTypeRef: 'kayak', boat_type: options.boat ?? 'kayak' });
|
|
await addTourWaypoints(orm(), paddle.id, [
|
|
{ lat: 49.2, lng: 12.0 },
|
|
{ lat: 49.2, lng: 12.01 },
|
|
]);
|
|
const hike = await makePlace(orm(), trip.id, { name: 'Ridge', lat: 47, lng: 11 });
|
|
await makeTour(orm(), hike.id);
|
|
await addTourWaypoints(orm(), hike.id, [
|
|
{ lat: 47, lng: 11 },
|
|
{ lat: 47.01, lng: 11.01 },
|
|
]);
|
|
return { user, trip, paddle, hike };
|
|
}
|
|
|
|
async function detail(tripId: number, placeId: number): Promise<TourDetailResponse> {
|
|
const read = await tours.getTour(String(tripId), String(placeId));
|
|
expect(tourDetailResponseSchema.safeParse(read).success).toBe(true);
|
|
return read;
|
|
}
|
|
|
|
function decide(entry: Pick<TourObstacleEntry, 'key' | 'basis'>, decision: ObstacleDecision): ObstacleReviewOp {
|
|
return { key: entry.key, op: 'decide', decision, note: null, basis: entry.basis ?? null };
|
|
}
|
|
|
|
describe('TourPaddleCheckService: the checklist and the bench', () => {
|
|
it('TPC-001: a paddle tour past a weir lists it as open, with the basis a decision would be bound to', async () => {
|
|
const { trip, paddle } = await fixture();
|
|
await region();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
|
|
|
const read = await detail(trip.id, paddle.id);
|
|
expect(read.tour.paddle_check).toMatchObject({
|
|
data: 'covered',
|
|
data_as_of: '2026-10-01T00:00:00.000Z',
|
|
entries: 1,
|
|
open: 1,
|
|
review: 0,
|
|
decided: 0,
|
|
bans: 0,
|
|
own: 0,
|
|
});
|
|
expect(read.obstacle_check?.entries).toEqual([
|
|
expect.objectContaining({
|
|
key: 'node/1',
|
|
kind: 'weir',
|
|
name: 'Wehr Regendorf',
|
|
relation: 'on_route',
|
|
status: 'open',
|
|
decision: null,
|
|
basis: { route: expect.stringMatching(/^p1:o:/), source: expect.stringMatching(/^s1:/) },
|
|
}),
|
|
]);
|
|
expect(read.obstacle_check?.revision).toBe(read.tour.paddle_check?.revision);
|
|
});
|
|
|
|
it('TPC-002: the list carries the bench of every paddle tour and leaves hikes and lineless tours alone', async () => {
|
|
const { trip, paddle, hike } = await fixture();
|
|
await region();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
|
const lineless = await makePlace(orm(), trip.id, { name: 'No line' });
|
|
await makeTour(orm(), lineless.id, { tourTypeRef: 'kayak' });
|
|
|
|
const list = await tours.listTours(String(trip.id));
|
|
for (const item of list) expect(tourListItemSchema.safeParse(item).success).toBe(true);
|
|
const byId = new Map(list.map((item) => [item.place_id, item]));
|
|
expect(byId.get(paddle.id)?.paddle_check).toMatchObject({ data: 'covered', open: 1 });
|
|
expect(byId.get(hike.id)).not.toHaveProperty('paddle_check');
|
|
expect(byId.get(lineless.id)?.paddle_check).toBeNull();
|
|
});
|
|
|
|
it('TPC-003: a trip without paddle tours reads no obstacle data at all', async () => {
|
|
const { user } = await makeUser(orm());
|
|
const trip = await makeTrip(orm(), user.id);
|
|
const hike = await makePlace(orm(), trip.id, { name: 'Ridge' });
|
|
await makeTour(orm(), hike.id);
|
|
const along = vi.spyOn(obstacles, 'readAlong');
|
|
const version = vi.spyOn(obstacles, 'dataVersion');
|
|
|
|
expect(await tours.listTours(String(trip.id))).toHaveLength(1);
|
|
expect(along).not.toHaveBeenCalled();
|
|
expect(version).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TPC-004: reads the data along a line once, and again once the data changed', async () => {
|
|
const { trip, paddle } = await fixture();
|
|
await region();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
|
const along = vi.spyOn(obstacles, 'readAlong');
|
|
|
|
await detail(trip.id, paddle.id);
|
|
await tours.listTours(String(trip.id));
|
|
expect(along).toHaveBeenCalledTimes(1);
|
|
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.2, 12.008)]);
|
|
const read = await detail(trip.id, paddle.id);
|
|
expect(along).toHaveBeenCalledTimes(2);
|
|
expect(read.tour.paddle_check?.open).toBe(2);
|
|
});
|
|
|
|
it('TPC-005: a reading during which the data changed is not kept', async () => {
|
|
const { trip, paddle } = await fixture();
|
|
await region();
|
|
const along = vi.spyOn(obstacles, 'readAlong');
|
|
vi.spyOn(obstacles, 'dataVersion').mockResolvedValueOnce('before').mockResolvedValue('after');
|
|
|
|
await detail(trip.id, paddle.id);
|
|
await detail(trip.id, paddle.id);
|
|
expect(along).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('TPC-006: data the line leaves, or no data at all, reads as partly covered or not covered', async () => {
|
|
const { trip, paddle } = await fixture();
|
|
expect((await detail(trip.id, paddle.id)).tour.paddle_check).toMatchObject({ data: 'none', entries: 0 });
|
|
|
|
await insertRow(orm(), PaddleRegions, {
|
|
id: 'west',
|
|
name: 'West',
|
|
boundary: null,
|
|
min_lat: 49,
|
|
min_lng: 11.9,
|
|
max_lat: 49.5,
|
|
max_lng: 12.005,
|
|
status: 'ready',
|
|
feature_count: 0,
|
|
source_timestamp: '2026-09-01T00:00:00.000Z',
|
|
imported_at: '2026-09-02 04:20:00',
|
|
});
|
|
expect((await detail(trip.id, paddle.id)).tour.paddle_check).toMatchObject({
|
|
data: 'partial',
|
|
data_as_of: '2026-09-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('TPC-007: while the data cannot be read the checklist says so, and a stored decision stays as one it cannot compare', async () => {
|
|
const { user, trip, paddle } = await fixture();
|
|
await region();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
|
const [entry] = (await detail(trip.id, paddle.id)).obstacle_check?.entries ?? [];
|
|
if (!entry) throw new Error('no entry');
|
|
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
|
|
vi.spyOn(obstacles, 'readAlong').mockRejectedValue(new Error('disk gone'));
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.2, 12.008)]);
|
|
const read = await detail(trip.id, paddle.id);
|
|
expect(read.tour.paddle_check).toMatchObject({ data: 'unavailable', data_as_of: null, read_at: null, entries: 0 });
|
|
expect(read.obstacle_check?.entries).toEqual([
|
|
expect.objectContaining({ key: 'node/1', status: 'unknown', relation: null, basis: null }),
|
|
]);
|
|
expect(log.logWarn).toHaveBeenCalledWith(expect.stringContaining('checklist without data'));
|
|
});
|
|
|
|
it('TPC-008: a stored decision that does not read is left out and said so', async () => {
|
|
const { user, trip, paddle } = await fixture();
|
|
await region();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
|
await insertRow(orm(), TourObstacleReviews, {
|
|
place: paddle.id,
|
|
obstacle_key: 'node/1',
|
|
decision: 'pass',
|
|
note: null,
|
|
route_fingerprint: 'p1:o:_',
|
|
source_fingerprint: 'gone',
|
|
obstacle_snapshot: '{"broken":',
|
|
decided_at: '2026-10-03 10:00:00',
|
|
decided_by: user.id,
|
|
});
|
|
|
|
const read = await detail(trip.id, paddle.id);
|
|
expect(read.obstacle_check?.entries).toEqual([expect.objectContaining({ key: 'node/1', status: 'open' })]);
|
|
expect(log.logWarn).toHaveBeenCalledWith(expect.stringContaining(`${paddle.id}/node/1 does not read`));
|
|
});
|
|
|
|
it('TPC-009: a hike carries no checklist', async () => {
|
|
const { trip, hike } = await fixture();
|
|
const read = await detail(trip.id, hike.id);
|
|
expect(read).not.toHaveProperty('obstacle_check');
|
|
expect(read.tour).not.toHaveProperty('paddle_check');
|
|
});
|
|
|
|
it('TPC-010: an area holding more obstacles than a checklist carries reads as cut short', async () => {
|
|
const { trip, paddle } = await fixture();
|
|
await region();
|
|
await insertRows(
|
|
orm(),
|
|
PaddleFeatures,
|
|
Array.from({ length: PADDLE_CHECK_MAX_ENTRIES + 1 }, (_, i) =>
|
|
weir(`node/${i + 1}`, 49.2, 12.0001 + i * 0.00003),
|
|
),
|
|
);
|
|
const read = await detail(trip.id, paddle.id);
|
|
expect(read.tour.paddle_check).toMatchObject({ data: 'limited', entries: PADDLE_CHECK_MAX_ENTRIES });
|
|
expect(read.obstacle_check?.entries).toHaveLength(PADDLE_CHECK_MAX_ENTRIES);
|
|
});
|
|
|
|
it('TPC-011: a checklist cut short by an earlier decision past its cap says so too', async () => {
|
|
const { user, trip, paddle } = await fixture();
|
|
await region();
|
|
await insertRows(
|
|
orm(),
|
|
PaddleFeatures,
|
|
Array.from({ length: PADDLE_CHECK_MAX_ENTRIES }, (_, i) => weir(`node/${i + 1}`, 49.2, 12.0001 + i * 0.00003)),
|
|
);
|
|
const snapshot = {
|
|
v: 1,
|
|
kind: 'weir',
|
|
name: null,
|
|
refs: ['node/999'],
|
|
at: [49.2, 12.009],
|
|
bounds: [
|
|
[49.2, 12.009],
|
|
[49.2, 12.009],
|
|
],
|
|
relation: 'on_route',
|
|
along_m: 650,
|
|
region_id: 'oberpfalz',
|
|
data_as_of: null,
|
|
};
|
|
await insertRow(orm(), TourObstacleReviews, {
|
|
place: paddle.id,
|
|
obstacle_key: 'node/999',
|
|
decision: 'pass',
|
|
note: null,
|
|
route_fingerprint: 'p1:o:_',
|
|
source_fingerprint: 's1:0123456789abcdef:4920000,1200900',
|
|
obstacle_snapshot: JSON.stringify(snapshot),
|
|
decided_at: '2026-10-03 10:00:00',
|
|
decided_by: user.id,
|
|
});
|
|
const read = await detail(trip.id, paddle.id);
|
|
expect(read.tour.paddle_check).toMatchObject({ data: 'limited' });
|
|
expect(read.obstacle_check?.entries).toHaveLength(PADDLE_CHECK_MAX_ENTRIES);
|
|
});
|
|
});
|
|
|
|
describe('TourObstacleReviewsService.save', () => {
|
|
async function decidedFixture() {
|
|
const setup = await fixture();
|
|
await region();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/1', 49.2, 12.005)]);
|
|
const [entry] = (await detail(setup.trip.id, setup.paddle.id)).obstacle_check?.entries ?? [];
|
|
if (!entry) throw new Error('no entry');
|
|
return { ...setup, entry };
|
|
}
|
|
|
|
const stored = (placeId: number) => findRow(orm(), TourObstacleReviews, { place: placeId, obstacle_key: 'node/1' });
|
|
|
|
it('TOR-001: stores a decision with its basis and what it was about, and answers the detail with it', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
const read = await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [decide(entry, 'pass')] },
|
|
user.id,
|
|
'sock',
|
|
);
|
|
|
|
expect(read.obstacle_check?.entries[0]).toMatchObject({
|
|
key: 'node/1',
|
|
status: 'decided',
|
|
decision: { decision: 'pass', note: null, decided_by: user.id, decided_at: expect.stringMatching(/Z$/) },
|
|
});
|
|
expect(read.tour.paddle_check).toMatchObject({ open: 0, decided: 1 });
|
|
const row = await stored(paddle.id);
|
|
expect(row).toMatchObject({ route_fingerprint: entry.basis?.route, source_fingerprint: entry.basis?.source });
|
|
expect(JSON.parse(String(row?.obstacle_snapshot))).toMatchObject({ v: 1, kind: 'weir', refs: ['node/1'] });
|
|
expect(t?.realtime.broadcastMock).toHaveBeenCalledWith(
|
|
String(trip.id),
|
|
'tours:changed',
|
|
{ placeIds: [paddle.id] },
|
|
'sock',
|
|
);
|
|
});
|
|
|
|
it('TOR-002: a portage decided without a portage drawn stays to do, a reroute while the route still passes too', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
const portage = await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [decide(entry, 'portage')] },
|
|
user.id,
|
|
);
|
|
expect(portage.obstacle_check?.entries[0]?.status).toBe('todo');
|
|
const reroute = await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [decide(entry, 'reroute')] },
|
|
user.id,
|
|
);
|
|
expect(reroute.obstacle_check?.entries[0]?.status).toBe('todo');
|
|
expect(reroute.tour.paddle_check?.open).toBe(1);
|
|
});
|
|
|
|
it('TOR-003: the same decision again changes nothing, a note changes only the note, clear removes it', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
await updateRows(orm(), TourObstacleReviews, { place: paddle.id }, { decided_at: '2026-10-03 10:00:00' });
|
|
|
|
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
expect((await stored(paddle.id))?.decided_at).toBe('2026-10-03 10:00:00');
|
|
|
|
await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [{ key: 'node/1', op: 'note', decision: null, note: 'Rechts umtragen', basis: null }] },
|
|
user.id,
|
|
);
|
|
expect(await stored(paddle.id)).toMatchObject({ note: 'Rechts umtragen', decided_at: '2026-10-03 10:00:00' });
|
|
|
|
const cleared = await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [{ key: 'node/1', op: 'clear', decision: null, note: null, basis: null }] },
|
|
user.id,
|
|
);
|
|
expect(await stored(paddle.id)).toBeNull();
|
|
expect(cleared.obstacle_check?.entries[0]?.status).toBe('open');
|
|
});
|
|
|
|
it('TOR-004: a decision made on another route near the obstacle reads as one to check again', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
const stale = { route: entry.basis?.route.replace(/^p1:o:/, 'p1:n:') ?? '', source: entry.basis?.source ?? '' };
|
|
const read = await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [{ key: 'node/1', op: 'decide', decision: 'pass', note: null, basis: stale }] },
|
|
user.id,
|
|
);
|
|
expect(read.obstacle_check?.entries[0]).toMatchObject({ status: 'review', changed: ['route'] });
|
|
expect(read.tour.paddle_check?.review).toBe(1);
|
|
});
|
|
|
|
it('TOR-005: a decision about an object not on the checklist now is skipped, and said so', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [{ ...decide(entry, 'pass'), key: 'node/99' }] },
|
|
user.id,
|
|
);
|
|
expect(await findRows(orm(), TourObstacleReviews, { place: paddle.id })).toEqual([]);
|
|
expect(log.logWarn).toHaveBeenCalledWith(expect.stringContaining('node/99 skipped'));
|
|
});
|
|
|
|
it('TOR-006: an object the complete data no longer holds reads as gone, and kept once the decision is kept', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
// The region read again: the weir has moved out of reach of the route.
|
|
await deleteRows(orm(), PaddleFeatures, { osm_ref: 'node/1' });
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.6, 12.005)]);
|
|
await updateRows(orm(), PaddleRegions, { id: 'oberpfalz' }, { imported_at: '2026-10-05 04:20:00' });
|
|
|
|
const gone = await detail(trip.id, paddle.id);
|
|
expect(gone.obstacle_check?.entries).toEqual([expect.objectContaining({ key: 'node/1', status: 'gone' })]);
|
|
expect(gone.tour.paddle_check).toMatchObject({ review: 1, entries: 0 });
|
|
|
|
const kept = await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [{ key: 'node/1', op: 'keep', decision: null, note: null, basis: null }] },
|
|
user.id,
|
|
);
|
|
expect(kept.obstacle_check?.entries).toEqual([expect.objectContaining({ key: 'node/1', status: 'kept' })]);
|
|
expect((await stored(paddle.id))?.source_fingerprint).toBe('gone');
|
|
});
|
|
|
|
it('TOR-007: keeping a decision about an object still there changes nothing', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
await reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{ ops: [{ key: 'node/1', op: 'keep', decision: null, note: null, basis: null }] },
|
|
user.id,
|
|
);
|
|
expect((await stored(paddle.id))?.source_fingerprint).toBe(entry.basis?.source);
|
|
});
|
|
|
|
it('TOR-008: a hike has no checklist to save to, an unknown tour is not found', async () => {
|
|
const { user, trip, hike, entry } = await decidedFixture();
|
|
const refusal = reviews.save(String(trip.id), String(hike.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
await expect(refusal).rejects.toBeInstanceOf(DomainError);
|
|
await expect(refusal).rejects.toMatchObject({ status: 400, message: NOT_A_PADDLE_TOUR_MESSAGE });
|
|
await expect(reviews.save(String(trip.id), '999999', { ops: [decide(entry, 'pass')] }, user.id)).rejects.toThrow(
|
|
'Tour not found',
|
|
);
|
|
await expect(reviews.save('x', String(hike.id), { ops: [decide(entry, 'pass')] }, user.id)).rejects.toThrow(
|
|
'Tour not found',
|
|
);
|
|
});
|
|
|
|
it('TOR-009: while the obstacle data cannot be read a decision waits with a 503 and nothing is stored', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.2, 12.008)]);
|
|
vi.spyOn(obstacles, 'readAlong').mockRejectedValue(new Error('disk gone'));
|
|
await expect(
|
|
reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id),
|
|
).rejects.toMatchObject({ status: 503, message: PADDLE_DATA_UNAVAILABLE_MESSAGE });
|
|
expect(await stored(paddle.id)).toBeNull();
|
|
});
|
|
|
|
it('TOR-010: a paddle tour without a line skips a decision, it has nothing to decide about', async () => {
|
|
const { user, trip, entry } = await decidedFixture();
|
|
const lineless = await makePlace(orm(), trip.id, { name: 'No line' });
|
|
await makeTour(orm(), lineless.id, { tourTypeRef: 'kayak' });
|
|
const read = await reviews.save(String(trip.id), String(lineless.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
expect(read.tour.paddle_check).toBeNull();
|
|
expect(await findRows(orm(), TourObstacleReviews, { place: lineless.id })).toEqual([]);
|
|
});
|
|
|
|
it('TOR-012: a save failing part way stores none of its changes', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
await insertRows(orm(), PaddleFeatures, [weir('node/2', 49.2, 12.008)]);
|
|
const read = await detail(trip.id, paddle.id);
|
|
const second = read.obstacle_check?.entries.find((candidate) => candidate.key === 'node/2');
|
|
if (!second) throw new Error('no second entry');
|
|
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(second, 'pass')] }, user.id);
|
|
const repo = orm().em.getRepository(TourObstacleReviews) as unknown as { deleteKeys: () => Promise<number> };
|
|
vi.spyOn(repo, 'deleteKeys').mockRejectedValueOnce(new Error('disk full'));
|
|
|
|
await expect(
|
|
reviews.save(
|
|
String(trip.id),
|
|
String(paddle.id),
|
|
{
|
|
ops: [decide(entry, 'portage'), { key: 'node/2', op: 'clear', decision: null, note: null, basis: null }],
|
|
},
|
|
user.id,
|
|
),
|
|
).rejects.toThrow();
|
|
expect(await stored(paddle.id)).toBeNull();
|
|
expect(await findRows(orm(), TourObstacleReviews, { place: paddle.id })).toHaveLength(1);
|
|
});
|
|
|
|
it('TOR-011: a copied tour keeps its decisions, bound to what they were decided on', async () => {
|
|
const { user, trip, paddle, entry } = await decidedFixture();
|
|
await reviews.save(String(trip.id), String(paddle.id), { ops: [decide(entry, 'pass')] }, user.id);
|
|
const copy = await tours.copyTour(String(trip.id), String(paddle.id), 'Regen again');
|
|
expect(copy.obstacle_check?.entries[0]).toMatchObject({ key: 'node/1', status: 'decided' });
|
|
expect(await findRows(orm(), TourObstacleReviews, { place: copy.tour.place_id })).toHaveLength(1);
|
|
});
|
|
});
|