@zerotal/testing 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/package.json +56 -0
- package/src/TestApp.ts +573 -0
- package/src/TestExceptionHandler.ts +54 -0
- package/src/TestResponse.ts +953 -0
- package/src/assertions.ts +84 -0
- package/src/data.ts +4433 -0
- package/src/factory.ts +288 -0
- package/src/fake.ts +462 -0
- package/src/fakeFile.ts +229 -0
- package/src/global.d.ts +20 -0
- package/src/index.ts +30 -0
- package/src/migrateDatabase.ts +66 -0
- package/src/preload.ts +33 -0
- package/src/refreshDatabase.ts +115 -0
- package/src/resetTestState.ts +12 -0
- package/src/storageAssertions.ts +52 -0
- package/src/withDatabase.ts +52 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { createTestApp, TestApp } from "./TestApp.ts";
|
|
2
|
+
export type { TestFileInput, TestFormValue } from "./TestApp.ts";
|
|
3
|
+
export { TestResponse } from "./TestResponse.ts";
|
|
4
|
+
export type { SessionDecoder, TestResponseContext, InertiaPage } from "./TestResponse.ts";
|
|
5
|
+
export { withDatabase } from "./withDatabase.ts";
|
|
6
|
+
export { refreshDatabase } from "./refreshDatabase.ts";
|
|
7
|
+
export type { RefreshDatabaseOptions } from "./refreshDatabase.ts";
|
|
8
|
+
export { migrateDatabase } from "./migrateDatabase.ts";
|
|
9
|
+
export type { MigrateDatabaseOptions } from "./migrateDatabase.ts";
|
|
10
|
+
export { resetTestState } from "./resetTestState.ts";
|
|
11
|
+
export { assertDatabaseHas, assertDatabaseMissing, assertDatabaseCount } from "./assertions.ts";
|
|
12
|
+
export { assertStoredFile, assertMissingFile } from "./storageAssertions.ts";
|
|
13
|
+
export { Factory, FactoryBatch } from "./factory.ts";
|
|
14
|
+
export type { FactoryPayload } from "./factory.ts";
|
|
15
|
+
export { fake } from "./fake.ts";
|
|
16
|
+
export { fakeFile } from "./fakeFile.ts";
|
|
17
|
+
export type { FakeFile } from "./fakeFile.ts";
|
|
18
|
+
|
|
19
|
+
// Testing fakes. Each one lives with the subsystem it stands in for; the ones
|
|
20
|
+
// whose package this already depends on are re-exported here so a test does not
|
|
21
|
+
// have to know which package a given fake ships in. The two that would pull a
|
|
22
|
+
// new dependency in are reached through their own facades instead:
|
|
23
|
+
// `Broadcast.fake()` from @zerotal/broadcasting and `Social.fake()` from
|
|
24
|
+
// @zerotal/auth.
|
|
25
|
+
export { EventFake } from "@zerotal/core";
|
|
26
|
+
export { Http } from "@zerotal/core/http";
|
|
27
|
+
export { QueueFake } from "@zerotal/queue";
|
|
28
|
+
export { NotificationFake } from "@zerotal/notifications";
|
|
29
|
+
export { FakeDisk } from "@zerotal/core/storage";
|
|
30
|
+
export type { FakeStoredFile } from "@zerotal/core/storage";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MigrationRunner,
|
|
3
|
+
currentOrmContext,
|
|
4
|
+
_getModelConnection,
|
|
5
|
+
_setBaseModelConnection,
|
|
6
|
+
_setDbConnection,
|
|
7
|
+
_getDbConnectionOverride,
|
|
8
|
+
type SQLInstance,
|
|
9
|
+
} from "@zerotal/orm";
|
|
10
|
+
|
|
11
|
+
export interface MigrateDatabaseOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Connection to migrate. Defaults to the active model connection, which is
|
|
14
|
+
* what `createTestApp()` and `refreshDatabase({ connection })` install.
|
|
15
|
+
*/
|
|
16
|
+
connection?: SQLInstance;
|
|
17
|
+
/** Directory holding the migration files. Defaults to `database/migrations`. */
|
|
18
|
+
path?: string;
|
|
19
|
+
/** Tracking-table name. Defaults to `migrations`. */
|
|
20
|
+
table?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a test database's schema by running the project's real migrations.
|
|
25
|
+
*
|
|
26
|
+
* A test schema written by hand is a second definition of the same tables, and
|
|
27
|
+
* the two drift: a column added in a migration is missing in the tests until
|
|
28
|
+
* something fails for a reason that has nothing to do with the change. Running
|
|
29
|
+
* the migrations themselves means the schema under test is the schema that
|
|
30
|
+
* ships.
|
|
31
|
+
*
|
|
32
|
+
* @returns The names of the migrations that ran.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* describe('Post', () => {
|
|
36
|
+
* refreshDatabase({ connection: db, migrate: true });
|
|
37
|
+
* // …every test now sees the real schema, and rolls back after itself
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // Standalone, outside refreshDatabase
|
|
42
|
+
* beforeAll(() => migrateDatabase({ connection: db }));
|
|
43
|
+
*/
|
|
44
|
+
export async function migrateDatabase(options: MigrateDatabaseOptions = {}): Promise<string[]> {
|
|
45
|
+
const { path = "database/migrations", table } = options;
|
|
46
|
+
const connection = options.connection ?? _getModelConnection();
|
|
47
|
+
|
|
48
|
+
// A migration's `up()` reaches for the ambient connection through `Schema`,
|
|
49
|
+
// not through the runner, so the connection has to be installed in both slots
|
|
50
|
+
// for the DDL to land on the database the test is actually using.
|
|
51
|
+
// Both slots are restored to exactly what they held, override and all — a
|
|
52
|
+
// helper that leaves a connection pinned behind it would silently detach the
|
|
53
|
+
// next test from whatever the container resolves.
|
|
54
|
+
const previousDb = _getDbConnectionOverride();
|
|
55
|
+
const previousModel = currentOrmContext().overrideConnection;
|
|
56
|
+
|
|
57
|
+
_setDbConnection(connection);
|
|
58
|
+
_setBaseModelConnection(connection);
|
|
59
|
+
try {
|
|
60
|
+
const runner = new MigrationRunner({ connection, ...(table ? { table } : {}) });
|
|
61
|
+
return await runner.runFromDirectory(path);
|
|
62
|
+
} finally {
|
|
63
|
+
_setDbConnection(previousDb);
|
|
64
|
+
_setBaseModelConnection(previousModel);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/preload.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zerotal/testing/preload — auto-wires the DB connection for every test worker.
|
|
3
|
+
*
|
|
4
|
+
* Loaded via `bun test --preload @zerotal/testing/preload` when you run
|
|
5
|
+
* `bun zt test`. Reads ZT_DB_URL from the environment (set by
|
|
6
|
+
* TestCommand) and calls _setBaseModelConnection + _setDbConnection so that
|
|
7
|
+
* withDatabase() and DB.table() work without any manual beforeAll setup.
|
|
8
|
+
*
|
|
9
|
+
* Each Bun test worker runs its own copy of this module, so connections are
|
|
10
|
+
* isolated per file — no cross-file leakage.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { SQL } from "bun";
|
|
14
|
+
import { _setBaseModelConnection, _setBaseModelDialect, _setDbConnection } from "@zerotal/orm";
|
|
15
|
+
|
|
16
|
+
const url = Bun.env["ZT_DB_URL"];
|
|
17
|
+
|
|
18
|
+
if (url && url !== ":memory:") {
|
|
19
|
+
const db = new SQL(url);
|
|
20
|
+
|
|
21
|
+
_setBaseModelConnection(db);
|
|
22
|
+
_setDbConnection(db);
|
|
23
|
+
|
|
24
|
+
if (url.startsWith("mysql://") || url.startsWith("mysql2://")) {
|
|
25
|
+
_setBaseModelDialect("mysql");
|
|
26
|
+
} else if (url.startsWith("postgres://") || url.startsWith("postgresql://")) {
|
|
27
|
+
_setBaseModelDialect("postgres");
|
|
28
|
+
}
|
|
29
|
+
// else: sqlite (default)
|
|
30
|
+
}
|
|
31
|
+
// When ZT_DB_URL is ':memory:', we leave the connections unset so each test
|
|
32
|
+
// can create its own in-process SQLite via createTestApp() or withDatabase().
|
|
33
|
+
// Bun's global `sql` fallback (which reads DATABASE_URL) is also still active.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { _getModelConnection, _setBaseModelConnection, type SQLInstance } from "@zerotal/orm";
|
|
2
|
+
import { migrateDatabase } from "./migrateDatabase.ts";
|
|
3
|
+
|
|
4
|
+
interface TestHooks {
|
|
5
|
+
beforeAll(fn: () => void | Promise<void>): void;
|
|
6
|
+
afterAll(fn: () => void | Promise<void>): void;
|
|
7
|
+
beforeEach(fn: () => void | Promise<void>): void;
|
|
8
|
+
afterEach(fn: () => void | Promise<void>): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function _hooks(): TestHooks {
|
|
12
|
+
try {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
return require("bun:test") as TestHooks;
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error("[Zerotal] refreshDatabase() can only be used inside a `bun test` run.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RefreshDatabaseOptions {
|
|
21
|
+
connection?: SQLInstance;
|
|
22
|
+
/**
|
|
23
|
+
* Build the schema by running the project's migrations before the suite.
|
|
24
|
+
* `true` uses `database/migrations`; pass a string for a different directory.
|
|
25
|
+
*
|
|
26
|
+
* Prefer this over hand-written DDL in `setup` — see {@link migrateDatabase}.
|
|
27
|
+
*/
|
|
28
|
+
migrate?: boolean | string;
|
|
29
|
+
setup?: (db: SQLInstance) => void | Promise<void>;
|
|
30
|
+
teardown?: (db: SQLInstance) => void | Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function refreshDatabase(options: RefreshDatabaseOptions = {}): void {
|
|
34
|
+
const { beforeAll, afterAll, beforeEach, afterEach } = _hooks();
|
|
35
|
+
|
|
36
|
+
let releaseGate: (() => void) | null = null;
|
|
37
|
+
let txDone: Promise<void> | null = null;
|
|
38
|
+
let prevConn: SQLInstance | null = null;
|
|
39
|
+
let installedConn = false;
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
if (options.connection) {
|
|
43
|
+
_setBaseModelConnection(options.connection);
|
|
44
|
+
installedConn = true;
|
|
45
|
+
}
|
|
46
|
+
if (options.migrate) {
|
|
47
|
+
await migrateDatabase({
|
|
48
|
+
connection: _getModelConnection(),
|
|
49
|
+
...(typeof options.migrate === "string" ? { path: options.migrate } : {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (options.setup) {
|
|
53
|
+
await options.setup(_getModelConnection());
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
if (options.teardown) {
|
|
59
|
+
await options.teardown(_getModelConnection());
|
|
60
|
+
}
|
|
61
|
+
if (installedConn) {
|
|
62
|
+
_setBaseModelConnection(null);
|
|
63
|
+
installedConn = false;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
prevConn = _getModelConnection();
|
|
69
|
+
const conn = prevConn as unknown as {
|
|
70
|
+
begin<T>(cb: (tx: unknown) => Promise<T>): Promise<T>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const ROLLBACK = Symbol("zerotal.test.rollback");
|
|
74
|
+
let openGate!: () => void;
|
|
75
|
+
const gate = new Promise<void>((r) => {
|
|
76
|
+
openGate = r;
|
|
77
|
+
});
|
|
78
|
+
let markReady!: () => void;
|
|
79
|
+
const ready = new Promise<void>((r) => {
|
|
80
|
+
markReady = r;
|
|
81
|
+
});
|
|
82
|
+
let txConn!: SQLInstance;
|
|
83
|
+
|
|
84
|
+
txDone = conn
|
|
85
|
+
.begin(async (tx) => {
|
|
86
|
+
txConn = tx as SQLInstance;
|
|
87
|
+
markReady();
|
|
88
|
+
await gate;
|
|
89
|
+
throw ROLLBACK;
|
|
90
|
+
})
|
|
91
|
+
.then(
|
|
92
|
+
() => undefined,
|
|
93
|
+
(e: unknown) => {
|
|
94
|
+
if (e !== ROLLBACK) throw e;
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await ready;
|
|
99
|
+
_setBaseModelConnection(txConn);
|
|
100
|
+
releaseGate = openGate;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
if (releaseGate) {
|
|
105
|
+
releaseGate();
|
|
106
|
+
releaseGate = null;
|
|
107
|
+
}
|
|
108
|
+
if (txDone) {
|
|
109
|
+
await txDone;
|
|
110
|
+
txDone = null;
|
|
111
|
+
}
|
|
112
|
+
_setBaseModelConnection(prevConn);
|
|
113
|
+
prevConn = null;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Application, Router, FrameworkEvents } from "@zerotal/core";
|
|
2
|
+
import { resetOrmContext } from "@zerotal/orm";
|
|
3
|
+
|
|
4
|
+
export function resetTestState(): void {
|
|
5
|
+
Application._resetInstance();
|
|
6
|
+
Router.reset();
|
|
7
|
+
resetOrmContext();
|
|
8
|
+
// Clear framework instrumentation subscriptions so handlers registered by one
|
|
9
|
+
// suite (e.g. devtools tracing) don't leak into the next. Providers re-subscribe
|
|
10
|
+
// on boot.
|
|
11
|
+
FrameworkEvents.clear();
|
|
12
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Storage } from "@zerotal/core/storage";
|
|
2
|
+
import type { StorageDriver } from "@zerotal/core/storage";
|
|
3
|
+
|
|
4
|
+
function _resolveDriver(diskOrDriver: string | StorageDriver): StorageDriver {
|
|
5
|
+
return typeof diskOrDriver === "string" ? Storage.disk(diskOrDriver) : diskOrDriver;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Assert that a file exists on the given storage disk or driver.
|
|
10
|
+
*
|
|
11
|
+
* Pass a disk name (string) to use the `Storage` facade (requires StorageProvider
|
|
12
|
+
* to be registered), or pass a `StorageDriver` instance for isolated unit tests.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Integration test — disk name:
|
|
16
|
+
* await assertStoredFile('local', 'avatars/42.jpg');
|
|
17
|
+
*
|
|
18
|
+
* // Unit test — driver instance:
|
|
19
|
+
* await assertStoredFile(new LocalDriver('./tmp', '/'), 'report.pdf');
|
|
20
|
+
*/
|
|
21
|
+
export async function assertStoredFile(
|
|
22
|
+
diskOrDriver: string | StorageDriver,
|
|
23
|
+
path: string,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const exists = await _resolveDriver(diskOrDriver).exists(path);
|
|
26
|
+
if (!exists) {
|
|
27
|
+
const label = typeof diskOrDriver === "string" ? `disk "${diskOrDriver}"` : "driver";
|
|
28
|
+
throw new Error(
|
|
29
|
+
`assertStoredFile: expected file "${path}" to exist on ${label}, but it was not found.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Assert that a file does NOT exist on the given storage disk or driver.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* await assertMissingFile('local', 'tmp/deleted.txt');
|
|
39
|
+
* await assertMissingFile(driver, 'should-be-gone.txt');
|
|
40
|
+
*/
|
|
41
|
+
export async function assertMissingFile(
|
|
42
|
+
diskOrDriver: string | StorageDriver,
|
|
43
|
+
path: string,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const exists = await _resolveDriver(diskOrDriver).exists(path);
|
|
46
|
+
if (exists) {
|
|
47
|
+
const label = typeof diskOrDriver === "string" ? `disk "${diskOrDriver}"` : "driver";
|
|
48
|
+
throw new Error(
|
|
49
|
+
`assertMissingFile: expected file "${path}" NOT to exist on ${label}, but it was found.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { _getModelConnection, _setBaseModelConnection, type SQLInstance } from "@zerotal/orm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a test function in a database transaction that always rolls back.
|
|
5
|
+
* Every query inside the callback uses the transaction, so changes are
|
|
6
|
+
* invisible to other connections and never reach the actual database.
|
|
7
|
+
*
|
|
8
|
+
* Use with bun:test's `it()`:
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* it('creates a user', withDatabase(async () => {
|
|
12
|
+
* const user = await User.create({ name: 'Alice', email: 'alice@example.com' });
|
|
13
|
+
* expect(user.id).toBeDefined();
|
|
14
|
+
* await assertDatabaseHas('users', { email: 'alice@example.com' });
|
|
15
|
+
* // Rolls back after this function returns — no cleanup needed.
|
|
16
|
+
* }));
|
|
17
|
+
*/
|
|
18
|
+
export function withDatabase(fn: () => Promise<void>): () => Promise<void> {
|
|
19
|
+
return async (): Promise<void> => {
|
|
20
|
+
const conn = _getModelConnection() as unknown as {
|
|
21
|
+
begin<T>(fn: (tx: unknown) => Promise<T>): Promise<T>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let testError: unknown;
|
|
25
|
+
let testFailed = false;
|
|
26
|
+
|
|
27
|
+
// Capture the pre-test connection so we can restore it after the transaction.
|
|
28
|
+
const prevConn = _getModelConnection();
|
|
29
|
+
|
|
30
|
+
await conn
|
|
31
|
+
.begin(async (tx) => {
|
|
32
|
+
_setBaseModelConnection(tx as SQLInstance);
|
|
33
|
+
try {
|
|
34
|
+
await fn();
|
|
35
|
+
} catch (e) {
|
|
36
|
+
testError = e;
|
|
37
|
+
testFailed = true;
|
|
38
|
+
} finally {
|
|
39
|
+
// Restore to whatever was active before (usually the test DB), not null.
|
|
40
|
+
_setBaseModelConnection(prevConn);
|
|
41
|
+
}
|
|
42
|
+
// Always rollback by throwing a sentinel — caught below.
|
|
43
|
+
throw new Error("__ZT_TEST_ROLLBACK__");
|
|
44
|
+
})
|
|
45
|
+
.catch((e: unknown) => {
|
|
46
|
+
if ((e as Error | null)?.message !== "__ZT_TEST_ROLLBACK__") throw e;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Re-throw any error that came from the test body itself.
|
|
50
|
+
if (testFailed) throw testError;
|
|
51
|
+
};
|
|
52
|
+
}
|