test(server): ORM suites open the schema snapshot; RESET_TABLES derived from the schema

This commit is contained in:
jubnl
2026-09-21 18:12:07 +02:00
parent f4fe27a3f8
commit ef184ec7c2
9 changed files with 60 additions and 135 deletions
+32 -106
View File
@@ -43,105 +43,26 @@ import type { RateLimitService } from '../../src/nest/common/rate-limit.service'
// that re-enters src/db/database.
export { CAN_ACCESS_TRIP_SQL, buildDbMock } from './db-mock';
// Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset).
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
// photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
const RESET_TABLES = [
'school_holiday_periods',
'school_holiday_regions',
'school_holiday_countries',
// Collab
'file_links',
'collab_message_reactions',
'collab_poll_votes',
'collab_messages',
'collab_polls',
'collab_notes',
// Road trip (#1797). Both hang off days, so they go before it like the rest
// of the day content; a new domain's tables belong here or its rows leak from
// one case into the next.
'roadtrip_day_tracks',
'roadtrip_vias',
// Dawarich (#2279). The suggestions reference places and bucket_list, so they
// are cleared before both; the connection row hangs off the user.
'dawarich_visit_suggestions',
'dawarich_connections',
// Day content
'day_notes',
'todo_category_assignees',
'todo_items',
'assignment_participants',
'day_assignments',
// Places
'place_regions',
'place_tags',
'places',
// Packing
'packing_category_assignees',
'packing_bag_members',
'packing_bags',
'packing_template_items',
'packing_template_categories',
'packing_templates',
'packing_items',
// Budget
'budget_item_members',
'budget_items',
// Photos & files
'trip_photos',
'trip_album_links',
'trip_files',
'photos',
// Reservations
'reservation_day_positions',
'reservations',
// Accommodations & days
'day_accommodations',
'days',
// Trip
'share_tokens',
'trip_invite_tokens',
'trip_members',
'trips',
// Journey
'journey_books',
'journey_share_tokens',
'journey_photos',
'journey_entries',
'journey_contributors',
'journey_trips',
'journeys',
// Vacay
'vacay_user_settings',
'vacay_shares',
'vacay_entries',
'vacay_company_holidays',
'vacay_holiday_calendars',
'vacay_plan_members',
'vacay_user_colors',
'vacay_user_years',
'vacay_years',
'vacay_plans',
// Atlas
'visited_regions',
'visited_countries',
'bucket_list',
// Notifications & audit
'notification_channel_preferences',
'notifications',
'audit_log',
// System notices
'user_notice_dismissals',
// User data
'settings',
'mcp_tokens',
'invite_tokens',
'tags',
'app_settings',
'webauthn_challenges',
'webauthn_credentials',
'users',
];
/**
* Tables `resetTestDb` must NOT clear: seed and config data every test assumes
* is present (categories, addons, the provider catalogues) plus the two
* migration bookkeeping tables. Everything else in the schema is user data and
* is derived from `sqlite_master` at reset time — see `resetTestDb`.
*/
const KEEP_TABLES = new Set([
// Seeded reference data (`seedDefaults` below re-seeds the first three).
'categories',
'addons',
'photo_providers',
'photo_provider_fields',
'document_providers',
'document_provider_fields',
// Migration bookkeeping: clearing these would make the next boot replay the
// whole history over a schema that already has it.
'schema_version',
'migrations',
'mikro_orm_migrations',
]);
const DEFAULT_CATEGORIES = [
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
@@ -244,13 +165,18 @@ export function createTestDb(): Database.Database {
*/
export function resetTestDb(db: Database.Database): void {
db.exec('PRAGMA foreign_keys = OFF');
const existingTables = new Set(
(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]).map(r => r.name)
);
for (const table of RESET_TABLES) {
if (existingTables.has(table)) {
db.exec(`DELETE FROM "${table}"`);
}
// Derived from the live schema, not from a list. This used to be a
// hand-mirrored `RESET_TABLES` array kept "in sync with schema.ts +
// migrations.ts" — by the time it was replaced it had drifted by ~51 tables
// (collections, oauth, plugins, settlements, …), every one of which leaked
// its rows from one test into the next. Deletion order does not matter:
// foreign_keys is OFF for the duration.
const tables = (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[])
.map(r => r.name)
// sqlite_* are SQLite's own (sqlite_sequence, sqlite_stat1) — never ours.
.filter(name => !name.startsWith('sqlite_') && !KEEP_TABLES.has(name));
for (const table of tables) {
db.exec(`DELETE FROM "${table}"`);
}
// No sqlite_sequence reset here, on purpose: the legacy helper never reset
// sequences either, so ids keep growing across tests within a file. Several
@@ -1,12 +1,13 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createTestDb, resetTestDb } from '../../../helpers/test-db';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createDay, createTrip, createUser } from '../../../helpers/factories';
import { DayNotes } from '../../../../src/db/entities/DayNotes.entity';
import type { DayNotesRepository } from '../../../../src/db/repositories/DayNotes.repository';
import { DB_TIMESTAMP_RE } from '../../../../src/db/types';
const testDb = createTestDb();
const testDb = createSnapshotTestDb();
let t: TestOrm;
let notes: DayNotesRepository;
@@ -1,11 +1,12 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createTestDb, resetTestDb } from '../../../helpers/test-db';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createDay, createTrip, createUser } from '../../../helpers/factories';
import { Days } from '../../../../src/db/entities/Days.entity';
import type { DaysRepository } from '../../../../src/db/repositories/Days.repository';
const testDb = createTestDb();
const testDb = createSnapshotTestDb();
let t: TestOrm;
let days: DaysRepository;
@@ -1,11 +1,12 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { CAN_ACCESS_TRIP_SQL, createTestDb, resetTestDb } from '../../../helpers/test-db';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { CAN_ACCESS_TRIP_SQL, resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { addTripMember, createTrip, createUser } from '../../../helpers/factories';
import { Trips } from '../../../../src/db/entities/Trips.entity';
import type { TripsRepository } from '../../../../src/db/repositories/Trips.repository';
const testDb = createTestDb();
const testDb = createSnapshotTestDb();
let t: TestOrm;
let trips: TripsRepository;
@@ -36,11 +36,6 @@ function ormFor(dbName: string): Promise<MikroORM> {
migrations: { path: MIGRATIONS, pathTs: MIGRATIONS, snapshot: false, silent: true },
seeder: { path: SEEDERS, pathTs: SEEDERS, defaultSeeder: 'DatabaseSeeder' },
dbName,
// Same as MikroORM's own default, but declared here so the `import()` sits
// in a module vitest transforms. MikroORM's copy lives in node_modules and
// is loaded raw, where Node's resolver rejects the extensionless imports
// these CommonJS-target migrations use.
dynamicImportProvider: (id: string) => import(id),
});
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createTestDb, resetTestDb } from '../../helpers/test-db';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createUser } from '../../helpers/factories';
import { Users } from '../../../src/db/entities/Users.entity';
const testDb = createTestDb();
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb); });
@@ -1,9 +1,7 @@
import { describe, expect, it } from 'vitest';
import { DB_TIMESTAMP_RE, DbTimestampType, dbNow } from '../../../../src/db/types';
import type { Platform } from '@mikro-orm/core';
// The type never touches the platform for value conversion; a stub is enough.
const platform = {} as Platform;
// The value converters take no platform: the wire format is the same text on
// every dialect (see db-timestamp.type.ts).
const type = new DbTimestampType();
describe('dbNow', () => {
@@ -18,29 +16,29 @@ describe('dbNow', () => {
describe('DbTimestampType', () => {
it('TS-003: passes stored text through unchanged on read', () => {
expect(type.convertToJSValue('2026-09-21 13:05:09', platform)).toBe('2026-09-21 13:05:09');
expect(type.convertToJSValue('2026-09-21 13:05:09')).toBe('2026-09-21 13:05:09');
});
it('TS-004: formats a Date a driver hands back (Postgres later) to the same text', () => {
expect(type.convertToJSValue(new Date(Date.UTC(2026, 8, 21, 13, 5, 9)) as unknown as string, platform))
expect(type.convertToJSValue(new Date(Date.UTC(2026, 8, 21, 13, 5, 9)) as unknown as string))
.toBe('2026-09-21 13:05:09');
});
it('TS-005: a millisecond integer (a Date written by the stock DateTimeType) reads back as text', () => {
expect(type.convertToJSValue(Date.UTC(2026, 8, 21, 13, 5, 9) as unknown as string, platform))
expect(type.convertToJSValue(Date.UTC(2026, 8, 21, 13, 5, 9) as unknown as string))
.toBe('2026-09-21 13:05:09');
});
it('TS-006: writes text unchanged and a stray Date as text, never as a number', () => {
expect(type.convertToDatabaseValue('2026-09-21 13:05:09', platform)).toBe('2026-09-21 13:05:09');
expect(type.convertToDatabaseValue(new Date(Date.UTC(2026, 8, 21, 13, 5, 9)) as unknown as string, platform))
expect(type.convertToDatabaseValue('2026-09-21 13:05:09')).toBe('2026-09-21 13:05:09');
expect(type.convertToDatabaseValue(new Date(Date.UTC(2026, 8, 21, 13, 5, 9)) as unknown as string))
.toBe('2026-09-21 13:05:09');
});
it('TS-007: null and undefined survive both directions', () => {
expect(type.convertToDatabaseValue(null, platform)).toBeNull();
expect(type.convertToDatabaseValue(undefined, platform)).toBeUndefined();
expect(type.convertToJSValue(null, platform)).toBeNull();
expect(type.convertToDatabaseValue(null)).toBeNull();
expect(type.convertToDatabaseValue(undefined)).toBeUndefined();
expect(type.convertToJSValue(null)).toBeNull();
});
it('TS-008: compares as a string and reports a string runtime type', () => {
@@ -1,11 +1,12 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createTestDb, resetTestDb } from '../../../helpers/test-db';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { Users } from '../../../../src/db/entities/Users.entity';
import { withRequestContext } from '../../../../src/nest/database/request-context';
const testDb = createTestDb();
const testDb = createSnapshotTestDb();
let t: TestOrm;
// Global context DISALLOWED here on purpose: this is the production setting.
@@ -1,12 +1,13 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createTestDb, resetTestDb } from '../../../helpers/test-db';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTrip, createUser } from '../../../helpers/factories';
import { Days } from '../../../../src/db/entities/Days.entity';
import type { DaysRepository } from '../../../../src/db/repositories/Days.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
const testDb = createTestDb();
const testDb = createSnapshotTestDb();
let t: TestOrm;
let uow: UnitOfWork;
let days: DaysRepository;