Files
TREK/server/tests/unit/nest/roadtrip.service.test.ts
T

501 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Days } from '../../../src/db/entities/Days.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { RoadtripDayTracks } from '../../../src/db/entities/RoadtripDayTracks.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
import type { TripWriter } from '../../../src/nest/common/trip-writer';
import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
import type { User } from '../../../src/types';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createDay, createPlace, createTrip, createUser } from '../../helpers/factories';
import { updateRows } from '../../helpers/factories/rows';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import type { RoadtripVia } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Via points and tracks, against real rows through
* `RoadtripViasRepository`/`RoadtripDayTracksRepository`/`DaysRepository`/
* `PlacesRepository` (Plan 3d Task 1) — moved off the hand-DDL, raw
* `DatabaseService`-shaped facade (§15a/§15c test risk 2) onto real schema.
*/
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => {
t = await createTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
async function makeService(realtime: { broadcast: (...args: unknown[]) => void } = { broadcast: () => {} }) {
return new RoadtripService(
realtime as never,
await createTestUnitOfWork(testDb),
t.repo(Days),
t.repo(Places),
t.repo(RoadtripVias),
t.repo(RoadtripDayTracks),
);
}
async function fixture() {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id);
const tripB = createTrip(testDb, user.id);
const dayA1 = createDay(testDb, tripA.id);
const dayA2 = createDay(testDb, tripA.id);
const dayB1 = createDay(testDb, tripB.id);
const track = createPlace(testDb, tripA.id, { name: 'Atlantic Road' });
const hotel = createPlace(testDb, tripA.id, { name: 'A hotel' });
const otherTrack = createPlace(testDb, tripB.id, { name: 'Somebody else’s track' });
await updateRows(t, Places, { id: track.id }, { route_geometry: '[[52,13],[52,14]]' });
await updateRows(t, Places, { id: otherTrack.id }, { route_geometry: '[[40,2],[40,3]]' });
return { tripA, tripB, dayA1, dayA2, dayB1, track, hotel, otherTrack };
}
describe('RoadtripService', () => {
let service: RoadtripService;
let f: Awaited<ReturnType<typeof fixture>>;
beforeEach(async () => {
service = await makeService();
f = await fixture();
});
it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', async () => {
expect(await service.dayExists(f.dayA1.id, f.tripA.id)).toBe(true);
expect(await service.dayExists(f.dayB1.id, f.tripA.id)).toBe(false);
});
it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', async () => {
const first = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
const second = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53.5, lng: 10.5 });
expect(first.sequence).toBe(0);
expect(second.sequence).toBe(1);
expect((await service.listForDay(f.dayA1.id)).map((v) => v.id)).toEqual([first.id, second.id]);
});
it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', async () => {
const late = await service.create(f.dayA1.id, { after_order_index: 2, lat: 51, lng: 12 });
const early = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
expect((await service.listForDay(f.dayA1.id)).map((v) => v.id)).toEqual([early.id, late.id]);
});
it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', async () => {
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
await service.create(f.dayA2.id, { after_order_index: 0, lat: 52, lng: 11 });
await service.create(f.dayB1.id, { after_order_index: 0, lat: 50, lng: 14 });
// dayB1 belongs to tripB and must not appear in tripA's listing.
expect((await service.listForTrip(f.tripA.id)).map((v) => v.day_id).sort((a, b) => a - b)).toEqual(
[f.dayA1.id, f.dayA2.id].sort((a, b) => a - b),
);
});
it('ROADTRIP-SVC-005: a move with no new anchor leaves the chain alone', async () => {
const via = await service.create(f.dayA1.id, { after_order_index: 1, lat: 53, lng: 10 });
const moved = await service.move(via.id, f.dayA1.id, 52.5, 11.5);
expect(moved).toMatchObject({ lat: 52.5, lng: 11.5, after_order_index: 1, sequence: via.sequence });
});
it('ROADTRIP-SVC-007: a via dragged past a stop is re-pinned to the leg it landed on', async () => {
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53.87, lng: 10.7 });
const moved = await service.move(via.id, f.dayA1.id, 53.87, 11.53, 1);
expect(moved).toMatchObject({ lat: 53.87, lng: 11.53, after_order_index: 1 });
});
it('ROADTRIP-SVC-008: a re-pin through the wrong day changes nothing at all', async () => {
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
expect(await service.move(via.id, f.dayA2.id, 51, 12, 3)).toBeNull();
expect((await service.listForDay(f.dayA1.id))[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 });
});
it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', async () => {
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
expect(await service.move(via.id, f.dayA2.id, 0, 0)).toBeNull();
expect(await service.remove(via.id, f.dayA2.id)).toBe(false);
expect(await service.listForDay(f.dayA1.id)).toHaveLength(1);
});
it('ROADTRIP-SVC-008b: re-anchoring moves the vias it names and leaves the rest alone', async () => {
const a = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
const b = await service.create(f.dayA1.id, { after_order_index: 1, lat: 54, lng: 11 });
const c = await service.create(f.dayA1.id, { after_order_index: 2, lat: 55, lng: 12 });
const after = await service.reanchor(f.dayA1.id, {
vias: [
{ id: b.id, after_order_index: 2 },
{ id: c.id, after_order_index: 3 },
],
});
const byId = new Map(after.map((v) => [v.id, v.after_order_index]));
expect(byId.get(a.id)).toBe(0);
expect(byId.get(b.id)).toBe(2);
expect(byId.get(c.id)).toBe(3);
expect(after.find((v) => v.id === b.id)?.lat).toBe(54);
});
it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', async () => {
const gone = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
const kept = await service.create(f.dayA1.id, { after_order_index: 2, lat: 54, lng: 11 });
const after = await service.reanchor(f.dayA1.id, {
vias: [{ id: kept.id, after_order_index: 1 }],
remove: [gone.id],
});
expect(after.map((v) => v.id)).toEqual([kept.id]);
expect(after[0]!.after_order_index).toBe(1);
});
it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', async () => {
const other = await service.create(f.dayA2.id, { after_order_index: 0, lat: 53, lng: 10 });
await service.reanchor(f.dayA1.id, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] });
const untouched = await service.listForDay(f.dayA2.id);
expect(untouched.map((v) => v.id)).toEqual([other.id]);
expect(untouched[0]!.after_order_index).toBe(0);
});
it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', async () => {
const kept = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
const after = await service.reanchor(f.dayA1.id, {
vias: [
{ id: 9999, after_order_index: 4 },
{ id: kept.id, after_order_index: 1 },
],
});
expect(after.map((v) => v.after_order_index)).toEqual([1]);
});
it('ROADTRIP-SVC-016: merging two populated legs keeps the drive going one way', async () => {
const a0 = await service.create(f.dayA1.id, { after_order_index: 1, lat: 50.0, lng: 10 });
const a1 = await service.create(f.dayA1.id, { after_order_index: 1, lat: 50.1, lng: 10 });
const b0 = await service.create(f.dayA1.id, { after_order_index: 2, lat: 50.2, lng: 10 });
const b1 = await service.create(f.dayA1.id, { after_order_index: 2, lat: 50.3, lng: 10 });
expect([a0.sequence, a1.sequence, b0.sequence, b1.sequence]).toEqual([0, 1, 0, 1]);
const after = await service.reanchor(f.dayA1.id, {
vias: [
{ id: b0.id, after_order_index: 1 },
{ id: b1.id, after_order_index: 1 },
],
});
const onLeg = after.filter((v) => v.after_order_index === 1).sort((x, y) => x.sequence - y.sequence);
expect(onLeg.map((v) => v.id)).toEqual([a0.id, a1.id, b0.id, b1.id]);
expect(onLeg.map((v) => v.sequence)).toEqual([0, 1, 2, 3]);
expect(onLeg.map((v) => v.lat)).toEqual([50.0, 50.1, 50.2, 50.3]);
});
it('ROADTRIP-SVC-017: a leg nothing merged into keeps the order it had', async () => {
const first = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
const second = await service.create(f.dayA1.id, { after_order_index: 0, lat: 54, lng: 11 });
const elsewhere = await service.create(f.dayA1.id, { after_order_index: 3, lat: 55, lng: 12 });
const after = await service.reanchor(f.dayA1.id, { vias: [{ id: elsewhere.id, after_order_index: 2 }] });
const leg0 = after.filter((v) => v.after_order_index === 0).sort((x, y) => x.sequence - y.sequence);
expect(leg0.map((v) => v.id)).toEqual([first.id, second.id]);
expect(leg0.map((v) => v.sequence)).toEqual([0, 1]);
expect(after.find((v) => v.id === elsewhere.id)?.sequence).toBe(0);
});
it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', async () => {
const vias = await service.createMany(f.dayA1.id, {
vias: [
{ after_order_index: 0, lat: 53.0, lng: 10.0 },
{ after_order_index: 0, lat: 53.1, lng: 10.1 },
{ after_order_index: 1, lat: 53.2, lng: 10.2 },
],
});
expect(vias.map((v) => [v.after_order_index, v.sequence])).toEqual([
[0, 0],
[0, 1],
[1, 0],
]);
expect(vias.map((v) => v.lat)).toEqual([53.0, 53.1, 53.2]);
});
it('ROADTRIP-SVC-013: a chain appends to what a leg already has', async () => {
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
const vias = await service.createMany(f.dayA1.id, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] });
expect(vias.map((v) => v.sequence)).toEqual([0, 1]);
});
it('ROADTRIP-SVC-014: replace_legs clears only the legs it names', async () => {
const keep = await service.create(f.dayA1.id, { after_order_index: 5, lat: 50, lng: 8 });
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
const vias = await service.createMany(f.dayA1.id, {
vias: [{ after_order_index: 0, lat: 54, lng: 11 }],
replace_legs: [0],
});
expect(vias.map((v) => v.id)).toEqual([vias[0]!.id, keep.id]);
expect(vias.find((v) => v.after_order_index === 0)?.lat).toBe(54);
});
it('ROADTRIP-SVC-015: clearing a leg without adding anything is a legal batch', async () => {
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
expect(await service.createMany(f.dayA1.id, { vias: [], replace_legs: [0] })).toEqual([]);
});
it('ROADTRIP-SVC-016b: a chain cannot be laid on a day it was not addressed to', async () => {
await service.createMany(f.dayA2.id, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
expect(await service.listForDay(f.dayA1.id)).toEqual([]);
expect(await service.listForDay(f.dayA2.id)).toHaveLength(1);
});
it('ROADTRIP-SVC-017b: the chain and the track it came from land in one write', async () => {
await service.createMany(f.dayA1.id, {
vias: [{ after_order_index: 0, lat: 53, lng: 10 }],
track: { place_id: f.track.id, stray_km: 0.8 },
});
expect(await service.tracksForTrip(f.tripA.id)).toEqual([
{ day_id: f.dayA1.id, place_id: f.track.id, stray_km: 0.8 },
]);
});
it('ROADTRIP-SVC-018: laying a second track on a day replaces the first, never doubles it', async () => {
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.track.id, stray_km: 2 } });
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.hotel.id, stray_km: null } });
expect(await service.tracksForTrip(f.tripA.id)).toEqual([
{ day_id: f.dayA1.id, place_id: f.hotel.id, stray_km: null },
]);
});
it('ROADTRIP-SVC-019: three states, not two — absent keeps, null clears', async () => {
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.track.id, stray_km: 1 } });
await service.createMany(f.dayA1.id, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
expect(await service.tracksForTrip(f.tripA.id)).toHaveLength(1);
await service.createMany(f.dayA1.id, { vias: [], replace_legs: [0], track: null });
expect(await service.tracksForTrip(f.tripA.id)).toEqual([]);
});
it('ROADTRIP-SVC-020: the tracks of one trip are not the tracks of another', async () => {
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.track.id, stray_km: null } });
await service.createMany(f.dayB1.id, { vias: [], track: { place_id: f.otherTrack.id, stray_km: null } });
expect((await service.tracksForTrip(f.tripA.id)).map((tr) => tr.day_id)).toEqual([f.dayA1.id]);
expect((await service.tracksForTrip(f.tripB.id)).map((tr) => tr.day_id)).toEqual([f.dayB1.id]);
});
it('ROADTRIP-SVC-021: only a track of this trip can become a day’s label', async () => {
expect(await service.trackExists(f.track.id, f.tripA.id)).toBe(true);
expect(await service.trackExists(f.hotel.id, f.tripA.id)).toBe(false);
expect(await service.trackExists(f.otherTrack.id, f.tripA.id)).toBe(false);
expect(await service.trackExists(999999, f.tripA.id)).toBe(false);
});
// L1 (Plan 3d Task 7 whole-plan review): `listForDay`/`listForTrip`/
// `tracksForTrip`/`trackExists` used to bind `Number(id)` directly — a
// hex-spelled id coerces to a real row under `Number()`, where the legacy
// raw-bind statement's affinity never converts a hex string, so it always
// matched nothing. `toRowId` now answers that legacy empty/false shape.
it("L1: a hex-spelled day/trip id answers the legacy empty shape on every read path, not the real trip's rows", async () => {
await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
await service.createMany(f.dayA1.id, { vias: [], track: { place_id: f.track.id, stray_km: null } });
const hexDayId = '0x' + f.dayA1.id.toString(16);
const hexTripId = '0x' + f.tripA.id.toString(16);
expect(await service.listForDay(hexDayId)).toEqual([]);
expect(await service.listForTrip(hexTripId)).toEqual([]);
expect(await service.tracksForTrip(hexTripId)).toEqual([]);
expect(await service.trackExists(f.track.id, hexTripId)).toBe(false);
// Sanity: the canonical id still sees the real rows.
expect(await service.listForDay(f.dayA1.id)).toHaveLength(1);
expect(await service.tracksForTrip(f.tripA.id)).toHaveLength(1);
});
it('ROADTRIP-SVC-007b: removing one reports whether there was anything to remove', async () => {
const via = await service.create(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 });
expect(await service.remove(via.id, f.dayA1.id)).toBe(true);
expect(await service.remove(via.id, f.dayA1.id)).toBe(false);
expect(await service.listForDay(f.dayA1.id)).toEqual([]);
});
it('RT8-GUARD-001: a non-canonical dayId is refused before any write — `createMany` never reaches the track upsert with an invalid day', async () => {
// The Task 0 review carry (RT8): `RoadtripDayTracksRepository.upsertTrack`'s
// `day_id` is that table's PRIMARY KEY column, and SQLite silently accepts
// `INSERT … VALUES (NULL, …)` on an INTEGER PK by auto-assigning a rowid —
// possibly colliding with a real, unrelated day. `requireDayId` (the
// service's own gate, `toRowId`-based) refuses `'abc'`/`'3 '`/`'0x10'`
// BEFORE the transaction opens, so `upsertTrack` can never be reached with
// anything but a real, parsed integer.
for (const badDayId of ['abc', '3 ', '0x10', '', '1.5']) {
await expect(
service.createMany(badDayId, { vias: [], track: { place_id: f.track.id, stray_km: null } }),
).rejects.toMatchObject({ response: { error: 'Day not found' }, status: 404 });
}
expect(await service.tracksForTrip(f.tripA.id)).toEqual([]);
});
// R7: `create`'s `nextSequence` (MAX(sequence)+1) read and its `insertVia`
// write run in one transaction, which holds the connection, so two concurrent
// creates (`Promise.allSettled`, no mocks) take one sequence each. Before,
// both landed on sequence 0.
it('R7: two concurrent creates on the same leg both succeed and take consecutive sequences', async () => {
const results = await Promise.allSettled([
service.create(f.dayA1.id, { after_order_index: 0, lat: 1, lng: 1 }),
service.create(f.dayA1.id, { after_order_index: 0, lat: 2, lng: 2 }),
]);
expect(results.every((r) => r.status === 'fulfilled')).toBe(true);
const rows = await service.listForDay(f.dayA1.id);
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.after_order_index)).toEqual([0, 0]);
expect(rows.map((r) => r.sequence).sort((a, b) => a - b)).toEqual([0, 1]);
});
});
interface Sent {
event: string;
payload: { dayId?: unknown; vias?: RoadtripVia[]; track?: unknown };
all: boolean;
}
/** A writer that keeps what a use case sends, in order, as a TripEventSink receives it. */
function recordingWriter(): { sent: Sent[]; writer: TripWriter } {
const sent: Sent[] = [];
const keep = (event: string, payload: unknown, all: boolean) => {
sent.push({ event, payload: payload as Sent['payload'], all });
};
const writer: TripWriter = {
userId: 1,
role: 'user',
surface: 'mcp',
events: {
emit: (event, payload) => keep(event, payload, false),
emitAll: (event, payload) => keep(event, payload, true),
},
};
return { sent, writer };
}
/**
* Two people planning one road trip look at the same line on the same map, and a reshaped
* drive moves every arrival time after it. Each via write is one use case for the routes
* and the tools alike, and tells the trip what the day runs through now once it is written.
*/
describe('RoadtripService: telling the trip', () => {
let service: RoadtripService;
let f: Awaited<ReturnType<typeof fixture>>;
beforeEach(async () => {
service = await makeService();
f = await fixture();
});
it('SRV-ROADTRIP-021: every via write sends the day read back after it, with the day id as text', async () => {
const { sent, writer } = recordingWriter();
const first = await service.addVia(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 }, writer);
const second = await service.addVia(String(f.dayA1.id), { after_order_index: 1, lat: 54, lng: 11 }, writer);
const moved = await service.moveVia(f.dayA1.id, first.id, { lat: 53.5, lng: 10.5 }, writer);
// The reanchor answers nothing it was asked to move: what goes out is the day as it is now.
await service.reanchorVias(f.dayA1.id, { vias: [], remove: [second.id] }, writer);
await service.removeVia(f.dayA1.id, first.id, writer);
expect(sent.map((s) => s.event)).toEqual(Array(5).fill('roadtripVia:changed'));
expect(sent.some((s) => s.all)).toBe(false);
// G8: the routes always sent the day id as the path spelled it; a tool sends it the same way.
expect(sent.map((s) => s.payload.dayId)).toEqual(Array(5).fill(String(f.dayA1.id)));
expect(sent.map((s) => s.payload.vias?.map((v) => v.id))).toEqual([
[first.id],
[first.id, second.id],
[first.id, second.id],
[first.id],
[],
]);
expect(sent[2]?.payload.vias?.[0]).toEqual(moved);
});
it('SRV-ROADTRIP-022: a chain says which track the day follows now, after its vias', async () => {
const { sent, writer } = recordingWriter();
const chain = { vias: [{ after_order_index: 0, lat: 53, lng: 10 }], track: { place_id: f.track.id, stray_km: 2 } };
const made = await service.addVias(f.tripA.id, f.dayA1.id, chain, writer);
// Left out, the track stays; the event still says which one the day follows.
await service.addVias(f.tripA.id, f.dayA1.id, { vias: [] }, writer);
await service.addVias(f.tripA.id, f.dayA1.id, { vias: [], track: null }, writer);
const followed = { day_id: f.dayA1.id, place_id: f.track.id, stray_km: 2 };
expect(sent.map((s) => [s.event, s.payload])).toEqual([
['roadtripVia:changed', { dayId: String(f.dayA1.id), vias: made }],
['roadtripTrack:changed', { dayId: String(f.dayA1.id), track: followed }],
['roadtripVia:changed', { dayId: String(f.dayA1.id), vias: made }],
['roadtripTrack:changed', { dayId: String(f.dayA1.id), track: followed }],
['roadtripVia:changed', { dayId: String(f.dayA1.id), vias: made }],
['roadtripTrack:changed', { dayId: String(f.dayA1.id), track: null }],
]);
});
it('SRV-ROADTRIP-023: a via that is not on the day changes nothing and tells nobody', async () => {
const { sent, writer } = recordingWriter();
const other = await service.create(f.dayA2.id, { after_order_index: 0, lat: 53, lng: 10 });
expect(await service.moveVia(f.dayA1.id, other.id, { lat: 1, lng: 2 }, writer)).toBeNull();
expect(await service.removeVia(f.dayA1.id, other.id, writer)).toBe(false);
expect(sent).toEqual([]);
expect(await service.listForDay(f.dayA2.id)).toEqual([other]);
});
it('SRV-ROADTRIP-024: the REST writer sends to the trip and leaves out the socket that made the change', async () => {
const calls: unknown[][] = [];
const rest = await makeService({ broadcast: (...args) => calls.push(args) });
const user = { id: 1, role: 'user' } as User;
const tripId = String(f.tripA.id);
const dayId = String(f.dayA1.id);
const via = await rest.addVia(
dayId,
{ after_order_index: 0, lat: 53, lng: 10 },
rest.restWriter(tripId, user, 'sock'),
);
expect(calls).toEqual([[tripId, 'roadtripVia:changed', { dayId, vias: [via] }, 'sock']]);
});
it('SRV-ROADTRIP-025: a read that fails after the commit is logged, and the write still answers', async () => {
const { sent, writer } = recordingWriter();
vi.spyOn(service, 'listForDay').mockRejectedValueOnce(new Error('read failed'));
const via = await service.addVia(f.dayA1.id, { after_order_index: 0, lat: 53, lng: 10 }, writer);
expect(sent).toEqual([]);
// The chain's track read fails on its own: the via event has gone, the track event is skipped.
vi.spyOn(service, 'tracksForTrip').mockRejectedValueOnce(new Error('read failed'));
const vias = await service.addVias(f.tripA.id, f.dayA1.id, { vias: [] }, writer);
expect(sent.map((s) => s.event)).toEqual(['roadtripVia:changed']);
expect(vias).toEqual([via]);
expect(await service.listForDay(f.dayA1.id)).toEqual([via]);
});
});