mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
3fe34061fd
A saved tour can now be kept in a collection, apart from any trip, and later made into a tour of another trip. The bookmark button in a tour's footer and in the planner's head (waiting for the save while there are unsaved changes) opens a dialog with every list of the user; a list they may only read stays in it, dimmed with a lock and a tooltip. The list keeps an independent copy: the line, the type, every measure and setting, the waypoints in order with their roles, the origin, and the trip and tour it was saved from as they were called then. Its days, its links to places of the trip and its obstacle decisions stay in the trip, and later changes to the tour do not reach the copy. A collection shows its kept tours as cards above its places: the grade tile with the source badge, measures and the waypoint count as badges, the trip it came from, and who kept it. The owner or an admin of the list can remove one after a question; tours made from it stay. "Add to a trip" shows the kept tour on a map with its facts and numbered waypoints, lists the user's trips (the ones they may not add tours to dimmed), and asks outright whether the waypoints come along. "Line only" keeps the line as saved and adds no waypoints; a tour kept without a line can only bring them. The new tour has an id of its own, no days and no new places, and names the trip and tour it was saved from; the dialog then offers to open that trip. Neither the collection nor the original tour changes. On the server the kept tours are a table of their own in the tours domain (collection_tours, migration 046100), each row written whole once. Every copy, the in-trip "save as new tour", the take-over from another trip and both directions here, now goes through one copy model (tour-copy.helpers) instead of a second one. Reading and removing follow the collection's roles, keeping one also needs access to the source trip, and taking one over needs place_edit on the target trip; the kept tour is read again inside the write, so one removed meanwhile leaves no half-made tour. The MCP tools list_collection_tours, get_collection_tour, save_tour_to_collection, take_over_collection_tour and delete_collection_tour run over the same service and checks. All 27 locales carry the new strings, and the wiki describes the flow.
213 lines
10 KiB
TypeScript
213 lines
10 KiB
TypeScript
import { ALL_ENTITIES } from '../../../src/db/entities';
|
|
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
|
import { IdempotencyKeys } from '../../../src/db/entities/IdempotencyKeys.entity';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { EntityRepositoryType, ReferenceKind } from '@mikro-orm/core';
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
/**
|
|
* Permanent guard for `task-4-review-shape.md` Important finding 1: a
|
|
* numeric class-field initialiser must never render `NaN` (see
|
|
* `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 133, 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.
|
|
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
|
|
// + scheduler_leases: name, owner, expires_at (the scheduler always writes all three).
|
|
// + user_sessions: id, user_id, expires_at.
|
|
// + ferrata_tiles: tile_key, payload, fetched_at (a fetched tile always carries all three).
|
|
// + paddle_regions: id, name; paddle_features: id, region_id, osm_ref, kind, geometry, tags, tag_hash and
|
|
// its bounding box (a feature is only ever written whole, from one read of its region).
|
|
// + tour_obstacle_reviews: place_id, obstacle_key, decision, both fingerprints, obstacle_snapshot, decided_at.
|
|
// + trip_journal_links: id, trip_id, journey_id, created_at; trip_journal_link_members: link_id, user_id,
|
|
// added_at (a link and its members are always written whole).
|
|
// + paddle_water_graphs: file, rev, bytes, built_at, stats and its bounding box (a network is only ever written
|
|
// whole, from one build of its region).
|
|
// + paddle_region_requests: region_id, name, reason (a request is only ever filed whole).
|
|
// + tour_station_visits: assignment_id, station_key, status (a visit is only ever written whole, by the
|
|
// day plan that checks a city walk's station off).
|
|
// + tour_change_steps: id, place_id, proposal_id, base_revision, revision, changes, before_state (a step is
|
|
// only ever written whole, by the save of a confirmed change).
|
|
// + tour_activities: id, place_id, track_place_id (an assignment always names its tour and its recording).
|
|
// + tour_activity_exclusions: id, user_id, place_id (an exclusion always names its user and its tour).
|
|
// + collection_tours: id, collection_id, name, waypoints, tour_type, origin, source_trip_title, source_tour_name
|
|
// (a kept tour is only ever written whole, from the tour it was saved from).
|
|
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 518;
|
|
|
|
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 133 entities', () => {
|
|
const failures: string[] = [];
|
|
for (const schema of ALL_ENTITIES) {
|
|
const Ctor = schema.class as new () => Record<string, unknown>;
|
|
const instance = new Ctor();
|
|
for (const [key, value] of Object.entries(instance)) {
|
|
if (typeof value === 'number' && Number.isNaN(value)) {
|
|
failures.push(`${schema.meta.className}.${key}`);
|
|
}
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* Report-only (per the brief): a NOT NULL scalar property with no
|
|
* `default`/`defaultRaw` at all legitimately has no class-field
|
|
* initialiser — the caller is expected to supply it (`name!: string`, for
|
|
* example). That is normal and not a bug, so this never asserts the list
|
|
* is empty; it exists so a reviewer can see the shape at a glance and so a
|
|
* suspicious spike in the count (e.g. every numeric property in an entity
|
|
* suddenly losing its default) shows up in test output without failing
|
|
* the suite on a false positive.
|
|
*/
|
|
it('CLASSFIELD-002: exactly the known NOT NULL, no-default scalar properties have no class-field initialiser', async () => {
|
|
const testDb = createSnapshotTestDb();
|
|
try {
|
|
const t = await createTestOrm(testDb);
|
|
try {
|
|
const report: string[] = [];
|
|
for (const schema of ALL_ENTITIES) {
|
|
const meta = t.orm.getMetadata().get(schema.class);
|
|
const Ctor = schema.class as new () => Record<string, unknown>;
|
|
const instance = new Ctor();
|
|
for (const prop of meta.props) {
|
|
if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue;
|
|
if (prop.nullable) continue;
|
|
if (prop.default !== undefined || prop.defaultRaw !== undefined) continue;
|
|
if (!(prop.name in instance)) continue;
|
|
if (instance[prop.name] === undefined) {
|
|
report.push(`${meta.className}.${prop.name}`);
|
|
}
|
|
}
|
|
}
|
|
// Informational only — see the doc comment above. `console.info` (not
|
|
// a `console.log` vitest might buffer away) so it survives a run.
|
|
// A ratchet, not a census: the failure mode this file guards (a regeneration
|
|
// handing every numeric property a bogus default) makes this number DROP;
|
|
// a new NOT NULL column without a default bumps it by one — update the
|
|
// constant with the migration that adds the column.
|
|
expect(report.length, report.join(', ')).toBe(KNOWN_UNINITIALISED_NOT_NULL_SCALARS);
|
|
} finally {
|
|
await t.close();
|
|
}
|
|
} finally {
|
|
testDb.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Plan 3b pre-task (`scripts/generate-entities.ts`'s `RULE10_repositoryTypeMarker`
|
|
* / `injectRepositoryTypeMarker`): `[EntityRepositoryType]?: XRepository;` is
|
|
* a type-only class member, never routed through `meta.addProperty` — this
|
|
* pins that it stays that way, so CLASSFIELD-002's census above (and its
|
|
* pinned count, 423) can never silently start counting it.
|
|
*/
|
|
describe('[EntityRepositoryType] marker is excluded from the class-field census (RULE10)', () => {
|
|
it('CLASSFIELD-003: every entity has an own [EntityRepositoryType] symbol property that is never a real metadata property and never leaks as a string key', async () => {
|
|
const testDb = createSnapshotTestDb();
|
|
try {
|
|
const t = await createTestOrm(testDb);
|
|
try {
|
|
const failures: string[] = [];
|
|
for (const schema of ALL_ENTITIES) {
|
|
const meta = t.orm.getMetadata().get(schema.class);
|
|
const Ctor = schema.class as new () => Record<string, unknown>;
|
|
const instance = new Ctor();
|
|
if (!Object.getOwnPropertySymbols(instance).includes(EntityRepositoryType)) {
|
|
failures.push(`${meta.className}: missing an own [EntityRepositoryType] symbol property`);
|
|
}
|
|
if (Object.keys(instance).includes('EntityRepositoryType')) {
|
|
failures.push(`${meta.className}: [EntityRepositoryType] leaked as a string-keyed own property`);
|
|
}
|
|
if (meta.props.some((prop) => prop.name === 'EntityRepositoryType')) {
|
|
failures.push(`${meta.className}: [EntityRepositoryType] was registered as a real metadata property`);
|
|
}
|
|
}
|
|
expect(failures).toEqual([]);
|
|
} finally {
|
|
await t.close();
|
|
}
|
|
} finally {
|
|
testDb.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The reviewer's exact repro (`task-4-review-shape.md`, Important finding
|
|
* 1): before the fix, `em.create(BudgetItems, …)` and
|
|
* `em.create(IdempotencyKeys, …)` both threw `SqliteError: no such column:
|
|
* NaN` at `flush()` — Kysely rendered the `NaN` class-field initialiser as a
|
|
* bare SQL identifier. Neither probe names the broken columns
|
|
* (`persons`/`days`/`place_id`/`reservation_id` on `BudgetItems`,
|
|
* `created_at` on `IdempotencyKeys`) — the whole point is that a caller
|
|
* should never have to.
|
|
*/
|
|
describe("insert probes (task-4-review-shape.md, the reviewer's exact repro)", () => {
|
|
const testDb = createSnapshotTestDb();
|
|
let t: TestOrm;
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestOrm(testDb);
|
|
});
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
t.clear();
|
|
});
|
|
afterAll(async () => {
|
|
await t.close();
|
|
testDb.close();
|
|
});
|
|
|
|
it('INSERT-PROBE-001: em.create(BudgetItems, { trip, name, total_price, exchange_rate }) + flush succeeds', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const created = t.em.create(BudgetItems, {
|
|
trip: trip.id,
|
|
name: 'Dinner',
|
|
total_price: 4200,
|
|
exchange_rate: 1,
|
|
});
|
|
await t.em.persist(created).flush();
|
|
|
|
// test-sql-allow: reads the stored columns behind the ORM to prove what its own insert wrote.
|
|
const row = testDb
|
|
.prepare('SELECT persons, days, place_id, reservation_id FROM budget_items WHERE id = ?')
|
|
.get(created.id);
|
|
expect(row).toEqual({ persons: null, days: null, place_id: null, reservation_id: null });
|
|
});
|
|
|
|
it('INSERT-PROBE-002: em.create(IdempotencyKeys, { key, user, method, path, status_code, response_body }) + flush succeeds', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const created = t.em.create(IdempotencyKeys, {
|
|
key: 'idem-key-probe-1',
|
|
user: user.id,
|
|
method: 'POST',
|
|
path: '/api/trips',
|
|
status_code: 201,
|
|
response_body: '{}',
|
|
});
|
|
await t.em.persist(created).flush();
|
|
|
|
const row = testDb
|
|
// test-sql-allow: reads the stored column behind the ORM to prove the database default filled it.
|
|
.prepare('SELECT created_at FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?')
|
|
.get('idem-key-probe-1', user.id, 'POST', '/api/trips') as { created_at: number };
|
|
// The DB's own defaultRaw (strftime('%s','now')) filled it in — never NaN, never null.
|
|
expect(row.created_at).toEqual(expect.any(Number));
|
|
expect(Number.isNaN(row.created_at)).toBe(false);
|
|
});
|
|
});
|