mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
c956fe92ba
dev now runs on MikroORM, so the positional migrations, seeds.ts and DatabaseService the Tours server side was built on are gone. Behaviour is unchanged; persistence moves onto dev's architecture: - one MikroORM migration for tour_types, tours and tour_waypoints, idempotent so instances that ran the old positional steps keep their data; the hike tour type is seeded inside it, the addon row in the AddonSeeder - entities and repositories for the three tables, tours domain on repositories and UnitOfWork - tour facet joins in places, assignments and days re-applied on the repository code Fixes found while porting: - GPX import checks addon, trip access and permission in the handler instead of guards, so a refused upload gets a 403/404 instead of a connection reset - copying a trip copies its tours and waypoints - a tour cannot be moved onto a day that already has it - updateTour checks existence inside its transaction Adds tours e2e tests and rewrites the tours unit tests onto the migrated test database.
189 lines
8.4 KiB
TypeScript
189 lines
8.4 KiB
TypeScript
import { EntityRepositoryType, ReferenceKind } from '@mikro-orm/core';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { ALL_ENTITIES } from '../../../src/db/entities';
|
|
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
|
import { IdempotencyKeys } from '../../../src/db/entities/IdempotencyKeys.entity';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
|
|
/**
|
|
* 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 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.
|
|
// + 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 130 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();
|
|
|
|
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
|
|
.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);
|
|
});
|
|
});
|