Merge dev into fix/prettier

Brings in the Tours addon and the MikroORM follow-ups. Conflicts were
resolved to dev's content, then every file dev brought in was run through
prettier and eslint --fix so the branch stays uniformly formatted.
This commit is contained in:
Maurice
2026-10-07 15:43:13 +02:00
242 changed files with 22952 additions and 731 deletions
@@ -1,6 +1,7 @@
import {
absDifference,
caseWhenEquals,
caseWhenNotNull,
castInteger,
castIntegerKysely,
coalesce,
@@ -704,6 +705,32 @@ describe('sql-functions (sqlite)', () => {
);
});
// Tours (#2586): the assignment projection's `tour_route_geometry` column.
it('SQLF-098: caseWhenNotNull yields the THEN column only where the test column is not null', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Shown', a.id);
testDb.prepare('UPDATE users SET display_name = NULL WHERE id = ?').run(b.id);
const platform = t.em.getPlatform();
const rows = await t.em
.createQueryBuilder(Users, 'u')
.select(['u.id', caseWhenNotNull(platform, 'u.display_name', 'u.username').as('picked')])
.where({ id: { $in: [a.id, b.id] } })
.orderBy({ id: 'asc' })
.execute('all', false);
expect(rows).toEqual([
{ id: a.id, picked: a.username },
{ id: b.id, picked: null },
]);
});
it('SQLF-099: an unknown platform fails closed for caseWhenNotNull', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => caseWhenNotNull(foreign, 'u.a', 'u.b')).toThrow(/no implementation for platform FakePlatform/);
});
// Task 1 fix review M1 (`PlaceShadowPicksRepository.countBySource`, PS5):
// `countAll()`'s alias cannot be ordered by (`.orderBy({ count: 'desc' })`
// throws "not existing property" — verified in SQLF-024's comment above);
+5 -4
View File
@@ -30,16 +30,17 @@ describe('ALL_ENTITIES', () => {
});
/**
* Plan 2 Task 3: every one of the 127 entities the migrated schema
* Plan 2 Task 3: every one of the 130 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)
* + push_subscriptions and google_api_usage from legacy steps 245 and 254,
* + tour_types, tours and tour_waypoints from the Tours migration)
* 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 127 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(127);
it('ENT-003: exactly 130 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(130);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -15,17 +15,18 @@ 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 127, not a sample — and reproduces
* committed entities — every one of the 130, 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()`.
*/
/** NOT NULL scalars with neither a column default nor a class-field initialiser (legitimate: the caller must supply them). */
// 424 + push_subscriptions (legacy step 245): id, user_id, endpoint, p256dh, auth, vapid_public_key.
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 430;
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 440;
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 127 entities', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 130 entities', () => {
const failures: string[] = [];
for (const schema of ALL_ENTITIES) {
const Ctor = schema.class as new () => Record<string, unknown>;
+5 -1
View File
@@ -28,7 +28,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const MIGRATIONS = path.join(__dirname, '../../../src/db/migrations');
const BASELINE = 'Migration20200101000000_baseline_schema';
/** The migrations that carry no legacy step: the baseline, and the post-legacy ones every upgrade runs. */
const UNNUMBERED = [BASELINE, 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null'];
const UNNUMBERED = [
BASELINE,
'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null',
'Migration20200101042000_tours',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
const LEGACY_FINAL_STEP = 258;
@@ -19,6 +19,7 @@ import {
} from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -52,9 +53,11 @@ function legacyProjectionRow(id: number): unknown {
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN tours t ON t.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.id = ?
`,
@@ -116,6 +119,36 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
expect(await assignments.findWithPlaceAndCategory(999999)).toBeNull();
});
it('ASSIGNPLACEREPO-009: the Tour facet columns are set for a Tour and null for a plain place, even one with a route', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const tourPlace = createPlace(testDb, trip.id, { name: 'Ridge walk' });
const trackPlace = createPlace(testDb, trip.id, { name: 'Imported track' });
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[47,11],[47.1,11.1]]', tourPlace.id);
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', trackPlace.id);
createTour(testDb, tourPlace.id);
const tour = createDayAssignment(testDb, day.id, tourPlace.id, { order_index: 0 });
const track = createDayAssignment(testDb, day.id, trackPlace.id, { order_index: 1 });
const one = await assignments.findWithPlaceAndCategory(tour.id);
expect(one).toStrictEqual(legacyProjectionRow(tour.id));
expect(one).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.1,11.1]]' });
expect(await assignments.findWithPlaceAndCategory(track.id)).toMatchObject({
tour_place_id: null,
tour_route_geometry: null,
});
const facets = (rows: { tour_place_id: number | null; tour_route_geometry: string | null }[]) =>
rows.map((r) => [r.tour_place_id, r.tour_route_geometry]);
const expected = [
[tourPlace.id, '[[47,11],[47.1,11.1]]'],
[null, null],
];
expect(facets(await assignments.listForDay(day.id))).toEqual(expected);
expect(facets(await assignments.listWithPlaceAndCategory([day.id]))).toEqual(expected);
});
it("ASSIGNPLACEREPO-004: place_time/end_time COALESCE — the assignment's own time wins, the place's time is the fallback", async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -426,6 +459,16 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
expect(await assignments.getDayId(999999)).toBeUndefined();
});
it('ASSIGNREPO-042 (getPlaceId): the place_id of the assignment, undefined for a missing id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await assignments.getPlaceId(a.id)).toBe(place.id);
expect(await assignments.getPlaceId(999999)).toBeUndefined();
});
it('ASSIGNREPO-011 (AS14, moveToDay): writes day_id and order_index together', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -20,6 +20,7 @@ import {
} from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { TRACK_COLORS } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -835,9 +836,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
},
): unknown[] {
let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon,
SELECT DISTINCT p.*, t.place_id AS tour_place_id, c.name as category_name, c.color as category_color, c.icon as category_icon,
pr.country_code as country_code, pr.region_name as region_name
FROM places p
LEFT JOIN tours t ON t.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN place_regions pr ON pr.place_id = p.id
WHERE p.trip_id = ?
@@ -887,8 +889,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
const p3 = createPlace(testDb, trip.id, { name: 'Louvre' });
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(p3.id, tag.id);
createDayAssignment(testDb, day.id, p3.id);
// p4: no category, no tag, unassigned, does NOT match the search term.
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
// p4: no category, no tag, unassigned, does NOT match the search term,
// and a Tour, so tour_place_id is non-null on exactly one row.
const p4 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
createTour(testDb, p4.id);
// Another trip's place, named to collide with the search term — must
// never appear in either statement's output (both scope by trip_id).
createPlace(testDb, otherTrip.id, { name: 'Central Perk' });
@@ -1860,3 +1864,77 @@ describe('PlacesRepository — share.service.ts SH12 read', () => {
expect(await places.listPublicForShare(trip.id)).toEqual([]);
});
});
describe('PlacesRepository.insertTourPlace (TO10) / updateTourRoute (TO11)', () => {
it('PLACEREPO-TOUR-001: insertTourPlace writes the route place as a walking place and leaves the rest at its defaults', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const id = await places.insertTourPlace({
trip_id: trip.id,
name: 'Ridge walk',
lat: 47,
lng: 11,
route_geometry: '[[47,11],[47.1,11.1]]',
});
expect(await places.findInTrip(id, trip.id)).toMatchObject({
trip_id: trip.id,
name: 'Ridge walk',
lat: 47,
lng: 11,
transport_mode: 'walking',
route_geometry: '[[47,11],[47.1,11.1]]',
duration_minutes: 60,
reservation_status: 'none',
category_id: null,
});
});
it('PLACEREPO-TOUR-002: updateTourRoute rewrites the route of a place of that trip, stamps updated_at, and refuses another trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 1, lng: 1 });
testDb.prepare("UPDATE places SET transport_mode = 'driving', updated_at = NULL WHERE id = ?").run(place.id);
const route = { name: 'New', lat: 47, lng: 11, route_geometry: '[[47,11],[47.2,11.2]]' };
expect(await places.updateTourRoute(place.id, other.id, route)).toBe(false);
expect(await places.findInTrip(place.id, trip.id)).toMatchObject({
name: 'Old',
transport_mode: 'driving',
updated_at: null,
});
expect(await places.updateTourRoute(place.id, trip.id, route)).toBe(true);
const row = await places.findInTrip(place.id, trip.id);
expect(row).toMatchObject({ ...route, transport_mode: 'walking' });
expect(row?.updated_at).not.toBeNull();
});
});
describe('PlacesRepository.isTour / listForTrip tour_place_id', () => {
it('PLACEREPO-TOUR-003: isTour is true only for a place with a tours row', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
createTour(testDb, tour.id);
expect(await places.isTour(tour.id)).toBe(true);
expect(await places.isTour(plain.id)).toBe(false);
expect(await places.isTour(999_999)).toBe(false);
});
it('PLACEREPO-TOUR-004: listForTrip carries the place id as tour_place_id for Tours and null otherwise', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
createTour(testDb, tour.id);
const rows = await places.listForTrip(String(trip.id), {});
const byId = new Map(rows.map((r) => [r.id, r.tour_place_id]));
expect(byId.get(tour.id)).toBe(tour.id);
expect(byId.get(plain.id)).toBeNull();
});
});
@@ -0,0 +1,92 @@
/**
* `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3
* (`insertForPlace`) and TW4 (`deleteForPlace`).
*/
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
import type {
TourWaypointRow,
TourWaypointsRepository,
} from '../../../../src/db/repositories/TourWaypoints.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createPlace, createTrip, createUser } from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourWaypointsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourWaypoints);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
const ROUTE: TourWaypointRow[] = [
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 2 },
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.05, lng: 11.05, role: 'via', sequence: 1 },
];
function tourInTrip() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
createTour(testDb, place.id);
return { trip, place };
}
describe('TourWaypointsRepository', () => {
it('TWREPO-001: insertForPlace writes every point and listForPlace reads them back in route order', async () => {
const { place } = tourInTrip();
await repo.insertForPlace(place.id, ROUTE);
expect(await repo.listForPlace(place.id)).toEqual([...ROUTE].sort((a, b) => a.sequence - b.sequence));
});
it('TWREPO-002: insertForPlace with no points writes nothing', async () => {
const { place } = tourInTrip();
await repo.insertForPlace(place.id, []);
expect(await repo.listForPlace(place.id)).toEqual([]);
});
it('TWREPO-003: deleteForPlace clears one tour and leaves another alone', async () => {
const first = tourInTrip();
const second = tourInTrip();
await repo.insertForPlace(first.place.id, ROUTE);
await repo.insertForPlace(second.place.id, ROUTE);
await repo.deleteForPlace(first.place.id);
expect(await repo.listForPlace(first.place.id)).toEqual([]);
expect(await repo.listForPlace(second.place.id)).toHaveLength(3);
});
it('TWREPO-004: listForTrip returns every waypoint of the trip with its place, ordered by place and sequence', async () => {
const { trip, place } = tourInTrip();
const other = tourInTrip();
const second = createPlace(testDb, trip.id);
createTour(testDb, second.id);
await repo.insertForPlace(second.id, ROUTE.slice(0, 2));
await repo.insertForPlace(place.id, ROUTE);
await repo.insertForPlace(other.place.id, ROUTE);
const rows = await repo.listForTrip(trip.id);
expect(rows.map((r) => [r.place_id, r.sequence])).toEqual([
[place.id, 0],
[place.id, 1],
[place.id, 2],
[second.id, 0],
[second.id, 2],
]);
expect(rows[0]).toEqual({ place_id: place.id, lat: 47, lng: 11, role: 'start', sequence: 0 });
});
});
@@ -0,0 +1,222 @@
/**
* `ToursRepository`: the list/detail projection (TO1/TO2), the facet probes
* places and assignments ask (TO3/TO4/TO5), the trip copy read and write
* (TO6/TO8), and the create/edit writes (TO7/TO9).
*/
import { Tours } from '../../../../src/db/entities/Tours.entity';
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: ToursRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(Tours);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
function fixture() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
return { trip, other };
}
function addWaypoint(placeId: number, sequence: number): void {
testDb
.prepare("INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 47, 11, 'via', ?)")
.run(placeId, sequence);
}
const METRICS = {
tour_type: 'hike',
distance: 8.25,
elevation_gain: 420,
elevation_loss: 380,
duration: 150,
match_confidence: 1,
max_hiking_difficulty: 3,
};
describe('ToursRepository reads', () => {
it('TOURREPO-001: listForTrip projects the facet with its place name and both flags, newest first, never another trip', async () => {
const { trip, other } = fixture();
const older = createPlace(testDb, trip.id, { name: 'Older' });
const newer = createPlace(testDb, trip.id, { name: 'Newer' });
const plain = createPlace(testDb, trip.id, { name: 'Not a tour' });
const foreign = createPlace(testDb, other.id, { name: 'Foreign' });
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', distance: 5, match_confidence: 0.3 });
createTour(testDb, newer.id, { created_at: '2026-01-02 10:00:00', max_hiking_difficulty: 5 });
createTour(testDb, foreign.id);
createDayAssignment(testDb, createDay(testDb, trip.id).id, older.id);
addWaypoint(newer.id, 0);
expect(await repo.listForTrip(trip.id)).toEqual([
{
place_id: newer.id,
name: 'Newer',
tour_type: 'hike',
distance: null,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 5,
planned: 0,
has_waypoints: 1,
},
{
place_id: older.id,
name: 'Older',
tour_type: 'hike',
distance: 5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 0.3,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: 1,
has_waypoints: 0,
},
]);
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
});
it('TOURREPO-002: listForTrip breaks a created_at tie by the newer place', async () => {
const { trip } = fixture();
const a = createPlace(testDb, trip.id);
const b = createPlace(testDb, trip.id);
createTour(testDb, a.id, { created_at: '2026-01-01 10:00:00' });
createTour(testDb, b.id, { created_at: '2026-01-01 10:00:00' });
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).toEqual([b.id, a.id]);
});
it('TOURREPO-003: findInTrip returns the row for its own trip and undefined across trips or for a plain place', async () => {
const { trip, other } = fixture();
const tour = createPlace(testDb, trip.id, { name: 'Ridge' });
const plain = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
expect(await repo.findInTrip(trip.id, tour.id)).toMatchObject({
place_id: tour.id,
name: 'Ridge',
planned: 0,
has_waypoints: 0,
});
expect(await repo.findInTrip(other.id, tour.id)).toBeUndefined();
expect(await repo.findInTrip(trip.id, plain.id)).toBeUndefined();
});
it('TOURREPO-004: existsForPlace and listTourPlaceIds tell tours from plain places', async () => {
const { trip } = fixture();
const tour = createPlace(testDb, trip.id);
const plain = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
expect(await repo.existsForPlace(tour.id)).toBe(true);
expect(await repo.existsForPlace(plain.id)).toBe(false);
expect(await repo.listTourPlaceIds([plain.id, tour.id])).toEqual([tour.id]);
expect(await repo.listTourPlaceIds([])).toEqual([]);
});
it('TOURREPO-005: isOnDay is true only for a tour already on that day, and ignores the assignment being moved', async () => {
const { trip } = fixture();
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const tour = createPlace(testDb, trip.id);
const plain = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
const onDay1 = createDayAssignment(testDb, day1.id, tour.id);
createDayAssignment(testDb, day1.id, plain.id);
expect(await repo.isOnDay(day1.id, tour.id)).toBe(true);
expect(await repo.isOnDay(day2.id, tour.id)).toBe(false);
expect(await repo.isOnDay(day1.id, plain.id)).toBe(false);
expect(await repo.isOnDay(day1.id, tour.id, onDay1.id)).toBe(false);
});
});
describe('ToursRepository writes', () => {
it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => {
const { trip } = fixture();
const place = createPlace(testDb, trip.id, { name: 'Drawn' });
await repo.insertTour({ place_id: place.id, ...METRICS });
expect(await repo.findInTrip(trip.id, place.id)).toEqual({
place_id: place.id,
name: 'Drawn',
...METRICS,
difficulty: null,
wanderer_ref: null,
tour_group_id: null,
planned: 0,
has_waypoints: 0,
});
});
it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => {
const { trip, other } = fixture();
const place = createPlace(testDb, trip.id);
createTour(testDb, place.id);
expect(await repo.updateInTrip(other.id, place.id, METRICS)).toBe(false);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ distance: null, max_hiking_difficulty: 2 });
expect(await repo.updateInTrip(trip.id, place.id, METRICS)).toBe(true);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject(METRICS);
});
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
const { trip, other } = fixture();
const source = createPlace(testDb, trip.id);
createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 });
testDb
.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 9 WHERE place_id = ?")
.run(source.id);
const rows = await repo.listRowsForTrip(trip.id);
expect(rows).toEqual([
{
place_id: source.id,
tour_type: 'hike',
distance: 3.5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: 'T3',
wanderer_ref: 'w-1',
match_confidence: null,
tour_group_id: 9,
created_at: '2026-03-04 05:06:07',
max_hiking_difficulty: 4,
},
]);
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
const copy = createPlace(testDb, other.id);
const { place_id: _sourceId, ...columns } = rows[0];
await repo.insertCopy(copy.id, columns);
expect(await repo.listRowsForTrip(other.id)).toEqual([{ ...rows[0], place_id: copy.id }]);
});
});
@@ -0,0 +1,198 @@
/**
* `Migration20200101042000_tours`: the Tours tables on a fresh install, and on
* a test instance that already carries them from the pre-ORM Tours branch.
*
* Migrate to the step immediately before it, optionally lay down the
* branch's own tables with rows in them, apply just this migration, assert.
*/
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 = 'Migration20200101042000_tours';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
async function indexNames(orm: MikroORM, table: string): Promise<string[]> {
const rows = await rawQuery<{ name: string }>(orm, `SELECT name FROM pragma_index_list('${table}') ORDER BY name`);
return rows.map((r) => r.name);
}
async function seedTripWithPlace(orm: MikroORM): Promise<void> {
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')");
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk')");
}
describe('Tours migration', () => {
it('TOURMIG-001: a fresh install gets the three tables, the hike type and no redundant waypoint index', async () => {
const orm = await ormBeforeTarget();
try {
await migrateTo(orm, TARGET);
expect(
await rawQuery(
orm,
'SELECT key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order FROM tour_types',
),
).toEqual([
{
key: 'hike',
label_key: 'tourTypes.hike',
icon: 'Mountain',
color: '#16a34a',
routing_profile: 'pedestrian',
is_sport: 1,
enabled: 1,
sort_order: 0,
},
]);
const tourColumns = await rawQuery<{ name: string; notnull: number; dflt_value: string | null; pk: number }>(
orm,
'SELECT name, "notnull", dflt_value, pk FROM pragma_table_info(\'tours\') ORDER BY cid',
);
expect(tourColumns.map((c) => c.name)).toEqual([
'place_id',
'tour_type',
'distance',
'elevation_gain',
'elevation_loss',
'duration',
'difficulty',
'wanderer_ref',
'match_confidence',
'tour_group_id',
'created_at',
'max_hiking_difficulty',
]);
expect(tourColumns.find((c) => c.name === 'max_hiking_difficulty')).toMatchObject({
notnull: 1,
dflt_value: '2',
});
expect(tourColumns.find((c) => c.name === 'place_id')).toMatchObject({ pk: 1 });
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
const fks = await rawQuery<{ table: string; from: string; to: string; on_delete: string }>(
orm,
'SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tours\') UNION ALL SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tour_waypoints\') ORDER BY 1, 2',
);
expect(fks).toEqual([
{ table: 'places', from: 'place_id', to: 'id', on_delete: 'CASCADE' },
{ table: 'tour_types', from: 'tour_type', to: 'key', on_delete: 'NO ACTION' },
{ table: 'tours', from: 'place_id', to: 'place_id', on_delete: 'CASCADE' },
]);
} finally {
await orm.close(true);
}
}, 30000);
it('TOURMIG-002: the CHECKs refuse out-of-range values and a place delete cascades through tours to its waypoints', async () => {
const orm = await ormBeforeTarget();
try {
await migrateTo(orm, TARGET);
await rawExec(orm, 'PRAGMA foreign_keys = ON');
await seedTripWithPlace(orm);
await expect(
rawExec(orm, "INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (100, 'hike', 7)"),
).rejects.toThrow(/CHECK/);
await expect(rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'kayak')")).rejects.toThrow(
/FOREIGN KEY/,
);
await rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'hike')");
expect(await rawQuery(orm, 'SELECT max_hiking_difficulty FROM tours')).toEqual([{ max_hiking_difficulty: 2 }]);
await expect(
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 91, 0, 'start', 0)"),
).rejects.toThrow(/CHECK/);
await expect(
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 0, 0, 'detour', 0)"),
).rejects.toThrow(/CHECK/);
await rawExec(
orm,
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
);
await expect(
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'via', 1)"),
).rejects.toThrow(/UNIQUE/);
await rawExec(orm, 'DELETE FROM places WHERE id = 100');
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tours')).toEqual([{ n: 0 }]);
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tour_waypoints')).toEqual([{ n: 0 }]);
} finally {
await orm.close(true);
}
}, 30000);
it('TOURMIG-003: on an instance that already has the pre-ORM Tours tables it keeps every row and drops only the redundant index', async () => {
const orm = await ormBeforeTarget();
try {
// The pre-ORM branch's final shape, rows and all, with its custom hike row.
await rawExec(
orm,
`CREATE TABLE tour_types (
key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL, routing_profile TEXT,
is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0)`,
);
await rawExec(
orm,
"INSERT INTO tour_types (key, label_key, icon, color, routing_profile) VALUES ('hike', 'tourTypes.hike', 'Mountain', '#000000', 'pedestrian')",
);
await rawExec(
orm,
`CREATE TABLE tours (
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL, elevation_loss REAL,
duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL, tour_group_id INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6))`,
);
await rawExec(orm, 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)');
await rawExec(
orm,
`CREATE TABLE tour_waypoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180),
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), sequence INTEGER NOT NULL CHECK(sequence >= 0),
UNIQUE(place_id, sequence))`,
);
await rawExec(orm, 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)');
await seedTripWithPlace(orm);
await rawExec(
orm,
"INSERT INTO tours (place_id, tour_type, distance, max_hiking_difficulty) VALUES (100, 'hike', 12.5, 4)",
);
await rawExec(
orm,
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
);
await migrateTo(orm, TARGET);
expect(await rawQuery(orm, 'SELECT key, color FROM tour_types')).toEqual([{ key: 'hike', color: '#000000' }]);
expect(await rawQuery(orm, 'SELECT place_id, tour_type, distance, max_hiking_difficulty FROM tours')).toEqual([
{ place_id: 100, tour_type: 'hike', distance: 12.5, max_hiking_difficulty: 4 },
]);
expect(await rawQuery(orm, 'SELECT role, sequence FROM tour_waypoints ORDER BY sequence')).toEqual([
{ role: 'start', sequence: 0 },
{ role: 'end', sequence: 1 },
]);
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
} finally {
await orm.close(true);
}
}, 30000);
});