mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
test(db): createTestOrm binds an ORM to a suite's own SQLite handle
This commit is contained in:
Generated
+2
@@ -22652,6 +22652,7 @@
|
||||
"@mikro-orm/nestjs": "^7.1.0",
|
||||
"@mikro-orm/postgresql": "^7.2.1",
|
||||
"@mikro-orm/seeder": "^7.2.1",
|
||||
"@mikro-orm/sql": "^7.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@nestjs/common": "^11.2.5",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
@@ -22678,6 +22679,7 @@
|
||||
"jimp": "^1.6.1",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"kysely": "^0.29.6",
|
||||
"nestjs-zod": "^5.5.0",
|
||||
"nodemailer": "^9.1.1",
|
||||
"otplib": "^12.0.1",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@mikro-orm/nestjs": "^7.1.0",
|
||||
"@mikro-orm/postgresql": "^7.2.1",
|
||||
"@mikro-orm/seeder": "^7.2.1",
|
||||
"@mikro-orm/sql": "^7.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@nestjs/common": "^11.2.5",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
@@ -64,6 +65,7 @@
|
||||
"jimp": "^1.6.1",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"kysely": "^0.29.6",
|
||||
"nestjs-zod": "^5.5.0",
|
||||
"nodemailer": "^9.1.1",
|
||||
"otplib": "^12.0.1",
|
||||
|
||||
+67
-30
@@ -2,41 +2,78 @@ import { getRawConnection } from './database';
|
||||
import type { Configuration } from '@mikro-orm/core';
|
||||
import { SqliteConnection, SqliteDriver } from '@mikro-orm/sqlite';
|
||||
|
||||
import { SqliteDialect } from 'kysely';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { SqliteDialect, SqliteDriver as KyselySqliteDriver, type Dialect, type SqliteDialectConfig } from 'kysely';
|
||||
|
||||
/**
|
||||
* A SQLite driver that reuses the connection `db/database.ts` already owns
|
||||
* instead of opening a second one.
|
||||
*
|
||||
* This is not a preference — it is forced by the test harness. `database.ts`
|
||||
* hands each vitest worker `:memory:`, and in better-sqlite3 an in-memory
|
||||
* database belongs to its *connection*: two connections to `:memory:` are two
|
||||
* unrelated databases. MikroORM's stock `SqliteConnection.createKyselyDialect()`
|
||||
* always does `new Database(dbName)`, so it would migrate a database nothing
|
||||
* else in the process can see.
|
||||
*
|
||||
* Sharing it also keeps the single-writer property the app has always had: one
|
||||
* handle, one WAL, no two-writers-on-one-file question in production.
|
||||
* The stock Kysely SQLite driver's `destroy()` unconditionally does
|
||||
* `db.close()`. MikroORM's own `close()` (`AbstractSqlConnection.close()`)
|
||||
* always calls the Kysely client's `destroy()` too, regardless of the `force`
|
||||
* flag it was given, and it caches that client behind a private field — so a
|
||||
* `SqliteConnection` subclass has no hook to intercept just that one call.
|
||||
* The fix has to live at the Kysely layer: hand MikroORM a driver identical to
|
||||
* the stock one except `destroy()` is a no-op, so closing the ORM never closes
|
||||
* a handle somebody else owns.
|
||||
*/
|
||||
class SharedSqliteConnection extends SqliteConnection {
|
||||
/**
|
||||
* Resolved lazily, on every (re)connect rather than once at import: a backup
|
||||
* restore closes the handle and opens a new one, and Kysely caches whatever it
|
||||
* is given for the life of its client. `reinitialize()` closes this connection
|
||||
* so that the next `connect()` lands here again and picks up the new handle.
|
||||
*/
|
||||
override createKyselyDialect(): SqliteDialect {
|
||||
return new SqliteDialect({ database: getRawConnection() });
|
||||
class NonClosingSqliteDriver extends KyselySqliteDriver {
|
||||
override async destroy(): Promise<void> {
|
||||
// The handle's owner (whoever `getHandle` below reads from) closes it.
|
||||
}
|
||||
}
|
||||
|
||||
export class SharedSqliteDriver extends SqliteDriver {
|
||||
constructor(config: Configuration) {
|
||||
super(config);
|
||||
// The base constructor already built a stock SqliteConnection; replace it
|
||||
// before anything connects. `connection` is protected on AbstractSqlDriver,
|
||||
// and there is no supported hook for supplying the class from a subclass of
|
||||
// SqliteDriver (its own constructor hardcodes SqliteConnection).
|
||||
(this as unknown as { connection: SqliteConnection }).connection = new SharedSqliteConnection(config);
|
||||
/**
|
||||
* A SQLite driver bound to a better-sqlite3 handle somebody else owns.
|
||||
*
|
||||
* Forced by the test harness: `database.ts` hands each vitest worker
|
||||
* `:memory:`, and in better-sqlite3 an in-memory database belongs to its
|
||||
* *connection* — two connections to `:memory:` are two unrelated databases.
|
||||
* MikroORM's stock `SqliteConnection.createKyselyDialect()` always does
|
||||
* `new Database(dbName)`, so it would migrate a database nothing else in the
|
||||
* process can see. Sharing the handle also keeps the single-writer property the
|
||||
* app has always had: one handle, one WAL.
|
||||
*
|
||||
* `getHandle` is called on every (re)connect rather than once: a backup restore
|
||||
* closes the handle and opens a new one, and Kysely caches whatever it is given
|
||||
* for the life of its client, so `reinitialize()` closes the connection and the
|
||||
* next `connect()` lands here again with the new handle.
|
||||
*/
|
||||
export function createBoundSqliteDriver(getHandle: () => Database.Database): typeof SqliteDriver {
|
||||
class BoundSqliteConnection extends SqliteConnection {
|
||||
override createKyselyDialect(): Dialect {
|
||||
const config: SqliteDialectConfig = { database: getHandle() };
|
||||
// Delegate the compiler/adapter/introspector to a throwaway stock dialect
|
||||
// (stateless factories); only `createDriver()` needs the non-closing swap.
|
||||
const stock = new SqliteDialect(config);
|
||||
return {
|
||||
createDriver: () => new NonClosingSqliteDriver(config),
|
||||
createQueryCompiler: () => stock.createQueryCompiler(),
|
||||
createAdapter: () => stock.createAdapter(),
|
||||
createIntrospector: (db) => stock.createIntrospector(db),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return class BoundSqliteDriver extends SqliteDriver {
|
||||
constructor(config: Configuration) {
|
||||
super(config);
|
||||
// The base constructor already built a stock SqliteConnection; replace it
|
||||
// before anything connects. `connection` is protected on AbstractSqlDriver
|
||||
// and SqliteDriver's constructor hardcodes the class, so there is no
|
||||
// supported hook for supplying it.
|
||||
(this as unknown as { connection: SqliteConnection }).connection = new BoundSqliteConnection(config);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The production driver: bound to the connection `db/database.ts` owns.
|
||||
*
|
||||
* Wrapped in a closure rather than passed as `getRawConnection` directly:
|
||||
* `createBoundSqliteDriver` doesn't call its argument until something actually
|
||||
* connects, but merely *reading* the `getRawConnection` binding here would —
|
||||
* this module is evaluated at import time, and a test that mocks
|
||||
* `db/database` with a partial replacement (most do, via `buildDbMock` or a
|
||||
* hand-rolled object) throws on that read even when the test never touches
|
||||
* the database at all.
|
||||
*/
|
||||
export const SharedSqliteDriver = createBoundSqliteDriver(() => getRawConnection());
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { EntityClass, EntityRepository, GetRepository } from '@mikro-orm/core';
|
||||
import { MikroORM, type EntityManager } from '@mikro-orm/sqlite';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { ALL_ENTITIES } from '../../src/db/entities';
|
||||
import { createBoundSqliteDriver } from '../../src/db/orm-driver';
|
||||
|
||||
export interface TestOrm {
|
||||
orm: MikroORM;
|
||||
/** A fork of the global EntityManager; `clear()` empties its identity map. */
|
||||
em: EntityManager;
|
||||
repo<T extends object>(entity: EntityClass<T>): GetRepository<T, EntityRepository<T>>;
|
||||
clear(): void;
|
||||
/** Closes the ORM only — the better-sqlite3 handle stays open for the caller. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ORM over a test database the caller already opened (usually
|
||||
* `createTestDb()`), so factories can keep inserting with raw SQL through the
|
||||
* same handle and the ORM sees those rows.
|
||||
*
|
||||
* `allowGlobalContext` defaults to true here and only here: a test builds a
|
||||
* service with `t.repo(X)` and calls it without an HTTP request around it.
|
||||
* Production keeps the default (false), which is what `withRequestContext` in
|
||||
* `src/nest/database/request-context.ts` is for.
|
||||
*
|
||||
* `close()` on the underlying MikroORM connection ignores the `force` flag and
|
||||
* always tears down its Kysely client — what actually keeps the handle open
|
||||
* is `NonClosingSqliteDriver` in `../../src/db/orm-driver.ts`, which
|
||||
* `createBoundSqliteDriver` hands MikroORM in place of Kysely's stock
|
||||
* `SqliteDriver`; its `destroy()` is a no-op, so this `close()` never reaches
|
||||
* the handle at all.
|
||||
*/
|
||||
export async function createTestOrm(
|
||||
db: Database.Database,
|
||||
options: { allowGlobalContext?: boolean } = {},
|
||||
): Promise<TestOrm> {
|
||||
const orm = await MikroORM.init({
|
||||
entities: [...ALL_ENTITIES],
|
||||
driver: createBoundSqliteDriver(() => db),
|
||||
dbName: ':memory:',
|
||||
allowGlobalContext: options.allowGlobalContext ?? true,
|
||||
discovery: { warnWhenNoEntities: false },
|
||||
});
|
||||
const em = orm.em.fork();
|
||||
return {
|
||||
orm,
|
||||
em,
|
||||
repo: (entity) => em.getRepository(entity),
|
||||
clear: () => em.clear(),
|
||||
close: async () => {
|
||||
// The `force` flag is irrelevant here — MikroORM's connection.close()
|
||||
// always destroys its Kysely client either way. It's the driver's
|
||||
// `destroy()` being a no-op (see the docstring above) that keeps the
|
||||
// handle open, not this call site.
|
||||
await orm.close(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createTestDb, 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();
|
||||
let t: TestOrm;
|
||||
|
||||
beforeAll(async () => { t = await createTestOrm(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
describe('createTestOrm', () => {
|
||||
it('ORM-001: reads through the very handle it was given', async () => {
|
||||
const { user } = createUser(testDb, { username: 'alice' });
|
||||
const found = await t.repo(Users).findOne({ id: user.id });
|
||||
expect(found?.username).toBe('alice');
|
||||
});
|
||||
|
||||
it('ORM-002: clear() drops the identity map so a raw update is visible', async () => {
|
||||
const { user } = createUser(testDb, { username: 'bob' });
|
||||
await t.repo(Users).findOne({ id: user.id });
|
||||
testDb.prepare('UPDATE users SET username = ? WHERE id = ?').run('robert', user.id);
|
||||
t.clear();
|
||||
const again = await t.repo(Users).findOne({ id: user.id });
|
||||
expect(again?.username).toBe('robert');
|
||||
});
|
||||
|
||||
it('ORM-003: closing the ORM leaves the handle usable', async () => {
|
||||
const local = await createTestOrm(testDb);
|
||||
// Force a real connection first — MikroORM connects lazily, so closing an
|
||||
// untouched ORM would prove nothing about NonClosingSqliteDriver.
|
||||
await local.repo(Users).findOne({ id: 1 });
|
||||
await local.close();
|
||||
expect(testDb.prepare('SELECT 1 AS one').get()).toEqual({ one: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user