@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/factory.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { BaseModel, InsertPayload } from "@zerotal/orm";
|
|
2
|
+
import { _suppressHooks } from "@zerotal/orm";
|
|
3
|
+
import { fake } from "./fake.ts";
|
|
4
|
+
|
|
5
|
+
type ModelCtor<T extends BaseModel> = (new () => T) & typeof BaseModel;
|
|
6
|
+
|
|
7
|
+
// ── Shared internal types ─────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keys of T whose names follow the camelCase foreign-key convention (*Id).
|
|
11
|
+
* Matches: userId, postId, authorId, parentCategoryId, etc.
|
|
12
|
+
* Does NOT match: id (handled by AutoManagedKeys in InsertPayload).
|
|
13
|
+
*/
|
|
14
|
+
type FKKeys<T> = {
|
|
15
|
+
[K in keyof T & string]-?: K extends `${string}Id` ? K : never;
|
|
16
|
+
}[keyof T & string];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The return type expected from a factory definition callback.
|
|
20
|
+
*
|
|
21
|
+
* Like InsertPayload<T> but with all foreign-key fields (*Id) made optional —
|
|
22
|
+
* they are injected at create-time via `.for(model)` or explicit overrides,
|
|
23
|
+
* so the factory definition does not need to supply them.
|
|
24
|
+
*
|
|
25
|
+
* Non-FK required fields (title, body, name, email, …) remain required,
|
|
26
|
+
* giving the definition callback full type safety without false positives.
|
|
27
|
+
*/
|
|
28
|
+
export type FactoryPayload<T extends BaseModel> = Omit<
|
|
29
|
+
InsertPayload<T>,
|
|
30
|
+
FKKeys<T> & keyof InsertPayload<T>
|
|
31
|
+
> &
|
|
32
|
+
Partial<Pick<InsertPayload<T>, FKKeys<T> & keyof InsertPayload<T>>>;
|
|
33
|
+
|
|
34
|
+
type DefinitionFn<T extends BaseModel> = (f: typeof fake) => FactoryPayload<T>;
|
|
35
|
+
|
|
36
|
+
interface FactoryConfig<T extends BaseModel> {
|
|
37
|
+
forcedState: string | null;
|
|
38
|
+
relations: Array<{ model: BaseModel; key: string }>;
|
|
39
|
+
afterCallbacks: Array<(instance: T) => Promise<void> | void>;
|
|
40
|
+
withEvents: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _defaultConfig<T extends BaseModel>(): FactoryConfig<T> {
|
|
44
|
+
return { forcedState: null, relations: [], afterCallbacks: [], withEvents: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function _createOne<T extends BaseModel>(
|
|
48
|
+
ModelClass: ModelCtor<T>,
|
|
49
|
+
definition: DefinitionFn<T>,
|
|
50
|
+
config: FactoryConfig<T>,
|
|
51
|
+
overrides: Partial<InsertPayload<T>>,
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
const run = async (): Promise<T> => {
|
|
54
|
+
// Merge order: definition defaults → relation FKs → caller overrides.
|
|
55
|
+
// Relation FKs override definition defaults (a Post's userId must match the parent).
|
|
56
|
+
// Bypasses fillable/guarded on purpose — factories are trusted code.
|
|
57
|
+
const relData = config.relations.reduce<Record<string, unknown>>((acc, { model, key }) => {
|
|
58
|
+
acc[key] = model.id;
|
|
59
|
+
return acc;
|
|
60
|
+
}, {});
|
|
61
|
+
const data = { ...(definition(fake) as Record<string, unknown>), ...relData, ...overrides };
|
|
62
|
+
|
|
63
|
+
const inst = new ModelClass();
|
|
64
|
+
Object.assign(inst, data);
|
|
65
|
+
await inst.save();
|
|
66
|
+
|
|
67
|
+
if (config.forcedState) {
|
|
68
|
+
await (inst as unknown as { forceState(s: string): Promise<void> }).forceState(
|
|
69
|
+
config.forcedState,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const cb of config.afterCallbacks) await cb(inst);
|
|
74
|
+
return inst;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return config.withEvents ? run() : _suppressHooks(run);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── New class-based API: Factory<T> ──────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fluent, type-safe model factory for tests and database seeders.
|
|
84
|
+
*
|
|
85
|
+
* Define once, use everywhere. The definition callback receives the built-in
|
|
86
|
+
* `fake` helper for quick random data — or ignore it and use your own faker.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* // database/factories/UserFactory.ts
|
|
90
|
+
* export const UserFactory = Factory.define(User, (f) => ({
|
|
91
|
+
* name: f.string(10),
|
|
92
|
+
* email: f.email(),
|
|
93
|
+
* password: 'password', // auto-hashed by BaseModel.hashable
|
|
94
|
+
* }));
|
|
95
|
+
*
|
|
96
|
+
* // In a test:
|
|
97
|
+
* const user = await UserFactory.create({ name: 'Alice' });
|
|
98
|
+
* const five = await UserFactory.count(5).create();
|
|
99
|
+
* const sub = await SubscriptionFactory.state('expired').create();
|
|
100
|
+
* const post = await PostFactory.for(user).create();
|
|
101
|
+
*/
|
|
102
|
+
export class Factory<T extends BaseModel> {
|
|
103
|
+
private constructor(
|
|
104
|
+
protected readonly _ModelClass: ModelCtor<T>,
|
|
105
|
+
protected readonly _definition: DefinitionFn<T>,
|
|
106
|
+
protected readonly _config: FactoryConfig<T>,
|
|
107
|
+
) {}
|
|
108
|
+
|
|
109
|
+
/** Define a factory for a model class. Returns a reusable `Factory<T>` instance. */
|
|
110
|
+
static define<T extends BaseModel>(
|
|
111
|
+
ModelClass: ModelCtor<T>,
|
|
112
|
+
definition: DefinitionFn<T>,
|
|
113
|
+
): Factory<T> {
|
|
114
|
+
return new Factory(ModelClass, definition, _defaultConfig());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Force the created instance to `stateName` via `forceState()`,
|
|
119
|
+
* bypassing all guards and `onTransition` callbacks.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* const expired = await SubscriptionFactory.state('expired').create();
|
|
123
|
+
*/
|
|
124
|
+
state(stateName: string): Factory<T> {
|
|
125
|
+
return new Factory(this._ModelClass, this._definition, {
|
|
126
|
+
...this._config,
|
|
127
|
+
forcedState: stateName,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Inject a parent model's primary key as a foreign key.
|
|
133
|
+
* `foreignKey` defaults to `<ModelName>Id` (camelCase).
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* const post = await PostFactory.for(user).create();
|
|
137
|
+
* const post = await PostFactory.for(user, 'authorId').create();
|
|
138
|
+
*/
|
|
139
|
+
for(model: BaseModel, foreignKey?: string): Factory<T> {
|
|
140
|
+
const name = model.constructor.name;
|
|
141
|
+
const key = foreignKey ?? `${name.charAt(0).toLowerCase()}${name.slice(1)}Id`;
|
|
142
|
+
return new Factory(this._ModelClass, this._definition, {
|
|
143
|
+
...this._config,
|
|
144
|
+
relations: [...this._config.relations, { model, key }],
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Run `callback` after each instance is created. */
|
|
149
|
+
afterCreate(callback: (instance: T) => Promise<void> | void): Factory<T> {
|
|
150
|
+
return new Factory(this._ModelClass, this._definition, {
|
|
151
|
+
...this._config,
|
|
152
|
+
afterCallbacks: [...this._config.afterCallbacks, callback],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Enable observer and hook dispatch for instances created by this factory.
|
|
158
|
+
*
|
|
159
|
+
* By default factories silence all model observers and hooks so that seeders
|
|
160
|
+
* don't emit side-effects (logs, emails, jobs). Call `.dispatchEvents()` when
|
|
161
|
+
* you explicitly need the full lifecycle to fire — e.g. in a test that asserts
|
|
162
|
+
* a side-effect triggered by model creation.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* // Seeder — silent by default, no "User registered" log spam
|
|
166
|
+
* await UserFactory.count(20).create();
|
|
167
|
+
*
|
|
168
|
+
* // Test — assert the welcome email was queued
|
|
169
|
+
* const user = await UserFactory.dispatchEvents().create();
|
|
170
|
+
* Queue.assertDispatched(WelcomeEmailJob);
|
|
171
|
+
*/
|
|
172
|
+
dispatchEvents(): Factory<T> {
|
|
173
|
+
return new Factory(this._ModelClass, this._definition, {
|
|
174
|
+
...this._config,
|
|
175
|
+
withEvents: true,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Switch to batch mode — `create()` will return `Promise<T[]>` instead of `Promise<T>`.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* const users = await UserFactory.count(5).create();
|
|
184
|
+
* // ^? User[]
|
|
185
|
+
*/
|
|
186
|
+
count(n: number): FactoryBatch<T> {
|
|
187
|
+
return new FactoryBatch(this._ModelClass, this._definition, this._config, n);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Build an in-memory instance without touching the database. */
|
|
191
|
+
make(overrides: Partial<InsertPayload<T>> = {}): T {
|
|
192
|
+
const relData = this._config.relations.reduce<Record<string, unknown>>(
|
|
193
|
+
(acc, { model, key }) => {
|
|
194
|
+
acc[key] = model.id;
|
|
195
|
+
return acc;
|
|
196
|
+
},
|
|
197
|
+
{},
|
|
198
|
+
);
|
|
199
|
+
const data = {
|
|
200
|
+
...(this._definition(fake) as Record<string, unknown>),
|
|
201
|
+
...relData,
|
|
202
|
+
...overrides,
|
|
203
|
+
};
|
|
204
|
+
const inst = new this._ModelClass();
|
|
205
|
+
Object.assign(inst, data);
|
|
206
|
+
return inst;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Insert a single instance into the database. */
|
|
210
|
+
async create(overrides: Partial<InsertPayload<T>> = {}): Promise<T> {
|
|
211
|
+
return _createOne(this._ModelClass, this._definition, this._config, overrides);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Insert `n` instances sequentially without switching to batch mode. */
|
|
215
|
+
async createMany(n: number, overrides: Partial<InsertPayload<T>> = {}): Promise<T[]> {
|
|
216
|
+
const results: T[] = [];
|
|
217
|
+
for (let i = 0; i < n; i++) {
|
|
218
|
+
results.push(await _createOne(this._ModelClass, this._definition, this._config, overrides));
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── FactoryBatch<T> ───────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/** Returned by `Factory.count(n)`. `create()` returns `Promise<T[]>`. */
|
|
227
|
+
export class FactoryBatch<T extends BaseModel> {
|
|
228
|
+
constructor(
|
|
229
|
+
private readonly _ModelClass: ModelCtor<T>,
|
|
230
|
+
private readonly _definition: DefinitionFn<T>,
|
|
231
|
+
private readonly _config: FactoryConfig<T>,
|
|
232
|
+
private readonly _n: number,
|
|
233
|
+
) {}
|
|
234
|
+
|
|
235
|
+
state(stateName: string): FactoryBatch<T> {
|
|
236
|
+
return new FactoryBatch(
|
|
237
|
+
this._ModelClass,
|
|
238
|
+
this._definition,
|
|
239
|
+
{ ...this._config, forcedState: stateName },
|
|
240
|
+
this._n,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for(model: BaseModel, foreignKey?: string): FactoryBatch<T> {
|
|
245
|
+
const name = model.constructor.name;
|
|
246
|
+
const key = foreignKey ?? `${name.charAt(0).toLowerCase()}${name.slice(1)}Id`;
|
|
247
|
+
return new FactoryBatch(
|
|
248
|
+
this._ModelClass,
|
|
249
|
+
this._definition,
|
|
250
|
+
{ ...this._config, relations: [...this._config.relations, { model, key }] },
|
|
251
|
+
this._n,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
afterCreate(callback: (instance: T) => Promise<void> | void): FactoryBatch<T> {
|
|
256
|
+
return new FactoryBatch(
|
|
257
|
+
this._ModelClass,
|
|
258
|
+
this._definition,
|
|
259
|
+
{ ...this._config, afterCallbacks: [...this._config.afterCallbacks, callback] },
|
|
260
|
+
this._n,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Enable observer and hook dispatch. Silent by default — see `Factory.dispatchEvents()`. */
|
|
265
|
+
dispatchEvents(): FactoryBatch<T> {
|
|
266
|
+
return new FactoryBatch(
|
|
267
|
+
this._ModelClass,
|
|
268
|
+
this._definition,
|
|
269
|
+
{ ...this._config, withEvents: true },
|
|
270
|
+
this._n,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Insert `n` instances sequentially and return them as an array.
|
|
276
|
+
*
|
|
277
|
+
* Sequential (not parallel) to stay compatible with SQLite's single-write
|
|
278
|
+
* `last_insert_rowid()` behaviour. For Postgres this constraint doesn't apply
|
|
279
|
+
* but serial inserts are correct on all databases.
|
|
280
|
+
*/
|
|
281
|
+
async create(overrides: Partial<InsertPayload<T>> = {}): Promise<T[]> {
|
|
282
|
+
const results: T[] = [];
|
|
283
|
+
for (let i = 0; i < this._n; i++) {
|
|
284
|
+
results.push(await _createOne(this._ModelClass, this._definition, this._config, overrides));
|
|
285
|
+
}
|
|
286
|
+
return results;
|
|
287
|
+
}
|
|
288
|
+
}
|