mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c40d00b830
PAD-06 and PAD-09. The tours list carries the check bench of every paddle tour and the detail its whole checklist, worked out of the obstacle data along the tour's line and the decisions stored for it. The obstacle data is read along the line run by run and kept in memory under the line, the boat and the data version, so a list of a trip reads it once; a hike costs nothing. While the data cannot be read the checklist says so and keeps every decision visible. PUT /api/trips/:tripId/tours/:placeId/obstacle-reviews stores what the paddlers decided, each against the basis the user saw, and answers the detail. It needs place_edit, refuses a tour that is no paddle tour with a 400, waits with a 503 while the data cannot be read, and skips an obstacle no longer on the checklist instead of failing. A copied tour and a copied trip take their decisions along. The MCP read tools describe the new fields; there is no write tool, since a decision is a statement of the paddlers. The obstacle data keeps the dates of checks on site, covers a line by sampling it, and tells its version by its rows and regions.
484 lines
20 KiB
TypeScript
484 lines
20 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 {
|
|
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');
|
|
});
|
|
});
|
|
|
|
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-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);
|
|
});
|
|
});
|