@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/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
+ }