mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user