Files
TREK/server/tests/helpers/db-mock.ts
T
Maurice 9a59703a34 docs(ci): complete the job list in test.yml and follow the renamed parity job
The list at the top of test.yml now names every step group its jobs hold,
the contributing guide names the parity job as it is called now, and
db-mock.ts says test-db reaches the auth controller only when a rate limit
is reset.
2026-10-20 08:27:01 +02:00

154 lines
6.3 KiB
TypeScript

/**
* Imported from inside `vi.mock('…/db/database')` factories, so it must stay a
* leaf: anything under src/nest pulled in here would load `db/database` while
* its mock is still being built and capture the real module.
*
* `tests/helpers/test-db.ts` used to define `createSnapshotTestDb`/`buildDbMock`
* itself, but it also reaches `AuthPublicController` (for `resetRateLimits`,
* imported when that helper runs, statically before), which drags in the
* whole auth graph — including `jwt-verify.ts`, which
* imports `../../db/database`. A `vi.mock('../../src/db/database', async () =>
* { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/
* test-db'); ... })` factory that re-enters `db/database` while building its
* own mock for that exact module gets handed the REAL, unmocked module for
* that one import — Vitest can't give a still-building mock to its own
* re-entrant importer. The real module's test-mode singleton then opens its
* OWN copy of the schema snapshot on first use (`openDb()` → `readSchemaSnapshot()`), so
* whichever code path captured `db` this way sees a pristine, never-reset
* database (the snapshot's seeded `admin@trek.local`) instead of the mock
* `buildDbMock` builds — while everything that imports `db/database` AFTER
* the factory returns gets the real mock. Which one a given guard/service
* saw then depended on unrelated import order, not on anything the test
* controls — e.g. a fresh `role: 'user'` account reading as `role: 'admin'`,
* or a trip the caller owns coming back "not found".
*
* So this module only imports `better-sqlite3` and `schema-snapshot.ts`
* (node builtins only) — nothing that could transitively import
* `src/db/database.ts` again. Keep it that way.
*/
import { readSchemaSnapshot } from '../../src/db/schema-snapshot';
import Database from 'better-sqlite3';
/**
* A copy of the migrated-and-seeded database the vitest global setup wrote
* (`tests/global-setup.ts`), for suites that boot the app with `buildApp()`.
* Those run the MikroORM migrator and seeders against the mocked connection,
* which only works on a database the ORM's migration table already knows.
* The legacy `createTestDb()` (in test-db.ts) stays for unit suites that never
* boot the app.
*
* @returns a normalised copy: the snapshot's seeded first-run `admin` row and
* the `users` autoincrement sequence have already been removed (see below), so
* the first user any caller creates lands on id 1.
*/
export function createSnapshotTestDb(): Database.Database {
const snapshot = readSchemaSnapshot();
if (!snapshot) {
throw new Error('No schema snapshot: run under vitest (tests/global-setup.ts writes it once per run).');
}
const db = new Database(snapshot);
// No journal_mode PRAGMA here: this is a deserialised in-memory database, so
// WAL mode (a disk-file concept) is a no-op on it.
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA foreign_keys = ON');
// The snapshot carries the first-run seeded `admin` row (id 1); the legacy
// createTestDb() started with no users at all, so the first user a test
// created was always id 1. Normalise ONCE, here, rather than resetting the
// sequence on every resetTestDb() call: ids then keep growing across tests
// within a file exactly as they did under the legacy helper, which several
// suites (oauth.test.ts's per-user client cap, mcp.test.ts's in-memory
// session registry, memories-synology.test.ts's insert-once fixtures) rely
// on to stay disjoint from one test to the next.
db.exec('DELETE FROM users');
db.exec("DELETE FROM sqlite_sequence WHERE name = 'users'");
return db;
}
/**
* Byte-for-byte the statement in src/db/database.ts.
*
* Exported because the same query is copied into ~90 test files, and every one
* of those copies had dropped `t.currency` — so the budget domain, which reads
* exactly that column off the access row, was only ever exercising its 'EUR'
* fallback. Import this instead of retyping it.
*/
export const CAN_ACCESS_TRIP_SQL = `
SELECT t.id, t.user_id, t.currency FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`;
/**
* Returns the mock factory for vi.mock('../../src/db/database', ...).
* The returned object mirrors the shape of database.ts exports.
*
* @example
* vi.mock('../../src/db/database', async () => {
* const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
* return buildDbMock(createSnapshotTestDb());
* });
*/
export function buildDbMock(testDb: Database.Database) {
return {
db: testDb,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: async (placeId: number | string) => {
interface PlaceRow {
id: number;
category_id: number | null;
category_name: string | null;
category_color: string | null;
category_icon: string | null;
[key: string]: unknown;
}
const place = testDb
.prepare(
`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = ?
`,
)
.get(placeId) as PlaceRow | undefined;
if (!place) return null;
const tags = testDb
.prepare(
`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`,
)
.all(placeId);
return {
...place,
category: place.category_id
? {
id: place.category_id,
name: place.category_name,
color: place.category_color,
icon: place.category_icon,
}
: null,
tags,
};
},
canAccessTrip: async (tripId: number | string, userId: number) => {
return testDb.prepare(CAN_ACCESS_TRIP_SQL).get(userId, tripId, userId);
},
isOwner: async (tripId: number | string, userId: number) => {
return !!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
},
getRawConnection: () => testDb,
openDb: () => {},
registerReinitializeHook: () => {},
runDemoSeed: () => {},
};
}