@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/fake.ts
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zerotal/testing — fake data generator
|
|
3
|
+
*
|
|
4
|
+
* South-African-flavoured faker for use in factories, seeders, and tests.
|
|
5
|
+
* Zero external dependencies — all data lives in data.ts.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { fake } from '@zerotal/testing';
|
|
9
|
+
*
|
|
10
|
+
* fake.name() // "Sipho Dlamini"
|
|
11
|
+
* fake.email() // "sipho.dlamini73@gmail.com"
|
|
12
|
+
* fake.email({ corporate: true }) // "sipho.dlamini@shoprite.co.za"
|
|
13
|
+
* fake.sentence() // medium sentence
|
|
14
|
+
* fake.sentence({ length: 'long' })
|
|
15
|
+
* fake.paragraphs(3) // 3 paragraphs joined with \n\n
|
|
16
|
+
* fake.phone() // "071 234 5678"
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
firstNames,
|
|
21
|
+
lastNames,
|
|
22
|
+
cities,
|
|
23
|
+
provinces,
|
|
24
|
+
suburbs,
|
|
25
|
+
streetNames,
|
|
26
|
+
streetTypes,
|
|
27
|
+
emailDomains,
|
|
28
|
+
companies,
|
|
29
|
+
mobileAreaCodes,
|
|
30
|
+
postalCodes,
|
|
31
|
+
jobTitles,
|
|
32
|
+
departments,
|
|
33
|
+
loremNouns,
|
|
34
|
+
loremAdjectives,
|
|
35
|
+
loremVerbs,
|
|
36
|
+
loremFillers,
|
|
37
|
+
titlePhrases,
|
|
38
|
+
} from "./data.ts";
|
|
39
|
+
import { Str } from "@zerotal/core";
|
|
40
|
+
|
|
41
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function rng(): number {
|
|
44
|
+
return Math.random();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pick<T>(arr: readonly T[]): T {
|
|
48
|
+
return arr[Math.floor(rng() * arr.length)] as T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pickN<T>(arr: readonly T[], n: number): T[] {
|
|
52
|
+
const copy = [...arr];
|
|
53
|
+
const out: T[] = [];
|
|
54
|
+
for (let i = 0; i < n && copy.length > 0; i++) {
|
|
55
|
+
const idx = Math.floor(rng() * copy.length);
|
|
56
|
+
out.push(copy[idx] as T);
|
|
57
|
+
copy.splice(idx, 1);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function int(min: number, max: number): number {
|
|
63
|
+
return Math.floor(rng() * (max - min + 1)) + min;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const capitalize = (s: string): string => Str.capitalize(s);
|
|
67
|
+
const slugify = (text: string): string => Str.slugify(text);
|
|
68
|
+
|
|
69
|
+
function companyDomain(company: string): string {
|
|
70
|
+
// Well-known abbreviations
|
|
71
|
+
const abbrevs: Record<string, string> = {
|
|
72
|
+
"first national bank": "fnb",
|
|
73
|
+
"pick n pay": "picknpay",
|
|
74
|
+
"amazon web services": "aws",
|
|
75
|
+
"microsoft south africa": "microsoft",
|
|
76
|
+
"old mutual": "oldmutual",
|
|
77
|
+
"standard bank": "standardbank",
|
|
78
|
+
discovery: "discovery",
|
|
79
|
+
"dis-chem": "dischem",
|
|
80
|
+
"cell c": "cellc",
|
|
81
|
+
};
|
|
82
|
+
const key = company.toLowerCase();
|
|
83
|
+
const base = abbrevs[key] ?? key.replace(/[^a-z0-9]/g, "");
|
|
84
|
+
return `${base}.co.za`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Sentence / paragraph builder ──────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
type TextLength = "short" | "medium" | "long";
|
|
90
|
+
|
|
91
|
+
const WORD_COUNTS: Record<TextLength, [number, number]> = {
|
|
92
|
+
short: [5, 9],
|
|
93
|
+
medium: [11, 18],
|
|
94
|
+
long: [22, 38],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const SENTENCE_COUNTS: Record<TextLength, [number, number]> = {
|
|
98
|
+
short: [2, 3],
|
|
99
|
+
medium: [3, 5],
|
|
100
|
+
long: [5, 8],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Sentence patterns — each token is picked from the right pool
|
|
104
|
+
// Format: N=noun A=adjective V=verb F=filler W=any
|
|
105
|
+
const PATTERNS: Array<Array<"N" | "A" | "V" | "F">> = [
|
|
106
|
+
["A", "N", "V", "N"],
|
|
107
|
+
["N", "V", "A", "N"],
|
|
108
|
+
["A", "N", "V", "A", "N"],
|
|
109
|
+
["V", "A", "N", "F", "N"],
|
|
110
|
+
["N", "F", "N", "V", "A", "N"],
|
|
111
|
+
["A", "N", "F", "V", "N"],
|
|
112
|
+
["V", "N", "A", "F", "A", "N"],
|
|
113
|
+
["N", "V", "N", "F", "N"],
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function buildSentence(): string {
|
|
117
|
+
const pattern = pick(PATTERNS);
|
|
118
|
+
const tokens = pattern.map((t) => {
|
|
119
|
+
switch (t) {
|
|
120
|
+
case "N":
|
|
121
|
+
return pick(loremNouns);
|
|
122
|
+
case "A":
|
|
123
|
+
return pick(loremAdjectives);
|
|
124
|
+
case "V":
|
|
125
|
+
return pick(loremVerbs);
|
|
126
|
+
case "F":
|
|
127
|
+
return pick(loremFillers);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return capitalize(tokens.join(" ")) + ".";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildWordSentence(wordCount: number): string {
|
|
134
|
+
// Single words only. The vocab holds a few noun *phrases* ("blog post"), so
|
|
135
|
+
// picking `wordCount` entries from the whole pool yields a sentence of more
|
|
136
|
+
// than `wordCount` words whenever a phrase is drawn — `fake.word()` already
|
|
137
|
+
// filters them out for the same reason.
|
|
138
|
+
const pool = singleWords();
|
|
139
|
+
const words = Array.from({ length: wordCount }, () => pick(pool));
|
|
140
|
+
return capitalize(words.join(" ")) + ".";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The lorem vocabulary with multi-word phrases removed. */
|
|
144
|
+
function singleWords(): string[] {
|
|
145
|
+
return [...loremNouns, ...loremAdjectives, ...loremVerbs].filter((w) => !w.includes(" "));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export const fake = {
|
|
151
|
+
// ── Primitives ──────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** Random element from an array. */
|
|
154
|
+
pick<T>(arr: readonly T[]): T {
|
|
155
|
+
return pick(arr);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/** Shuffled copy of an array. */
|
|
159
|
+
shuffle<T>(arr: readonly T[]): T[] {
|
|
160
|
+
const out = [...arr];
|
|
161
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
162
|
+
const j = Math.floor(rng() * (i + 1));
|
|
163
|
+
[out[i], out[j]] = [out[j]!, out[i]!];
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** n unique random elements from an array. */
|
|
169
|
+
sample<T>(arr: readonly T[], n: number): T[] {
|
|
170
|
+
return pickN(arr, n);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/** Random integer between min and max (inclusive). */
|
|
174
|
+
number(min = 0, max = 1000): number {
|
|
175
|
+
return int(min, max);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/** Random float between min and max with optional decimal places. */
|
|
179
|
+
float(min = 0, max = 1, decimals = 2): number {
|
|
180
|
+
const raw = rng() * (max - min) + min;
|
|
181
|
+
return Number(raw.toFixed(decimals));
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/** Random boolean, optionally weighted (default 50/50). */
|
|
185
|
+
boolean(trueWeight = 0.5): boolean {
|
|
186
|
+
return rng() < trueWeight;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/** UUID v4. */
|
|
190
|
+
uuid(): string {
|
|
191
|
+
return crypto.randomUUID();
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/** Random alphanumeric string of given length. */
|
|
195
|
+
string(length = 10): string {
|
|
196
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
197
|
+
let out = "";
|
|
198
|
+
for (let i = 0; i < length; i++) out += pick([...chars]);
|
|
199
|
+
return out;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Return value with given probability; null otherwise.
|
|
204
|
+
* @example fake.maybe(fake.phone(), 0.7) // phone 70% of the time
|
|
205
|
+
*/
|
|
206
|
+
maybe<T>(value: T, probability = 0.5): T | null {
|
|
207
|
+
return rng() < probability ? value : null;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
// ── Dates ────────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/** Random Date in the given range (defaults: last year → now). */
|
|
213
|
+
date(from?: Date, to?: Date): Date {
|
|
214
|
+
const end = (to ?? new Date()).getTime();
|
|
215
|
+
const start = (from ?? new Date(end - 365 * 24 * 60 * 60 * 1000)).getTime();
|
|
216
|
+
return new Date(start + rng() * (end - start));
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/** Random Date in the past, within `years` years. */
|
|
220
|
+
pastDate(years = 3): Date {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
return new Date(now - rng() * years * 365 * 24 * 60 * 60 * 1000);
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/** Random Date in the future, within `years` years. */
|
|
226
|
+
futureDate(years = 2): Date {
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
return new Date(now + rng() * years * 365 * 24 * 60 * 60 * 1000);
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
/** ISO 8601 date string (e.g. "2024-03-15T10:23:00.000Z"). */
|
|
232
|
+
isoDate(from?: Date, to?: Date): string {
|
|
233
|
+
return fake.date(from, to).toISOString();
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** Unix timestamp in seconds. */
|
|
237
|
+
timestamp(): number {
|
|
238
|
+
return Math.floor(fake.date().getTime() / 1000);
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
// ── Names ────────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** Random first name drawn from the full SA-diverse pool. */
|
|
244
|
+
firstName(): string {
|
|
245
|
+
return pick(firstNames);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
/** Random last name drawn from the full SA-diverse pool. */
|
|
249
|
+
lastName(): string {
|
|
250
|
+
return pick(lastNames);
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
/** Full name: "FirstName LastName". */
|
|
254
|
+
name(): string {
|
|
255
|
+
return `${fake.firstName()} ${fake.lastName()}`;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
// ── Contact ──────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Realistic email address.
|
|
262
|
+
*
|
|
263
|
+
* @param opts.name Seed string (defaults to a generated name).
|
|
264
|
+
* @param opts.corporate If true, uses a slugified company domain (.co.za).
|
|
265
|
+
* @param opts.number Append a number to the local part (default: 50% chance).
|
|
266
|
+
*/
|
|
267
|
+
email(opts?: { name?: string; corporate?: boolean; number?: boolean }): string {
|
|
268
|
+
const rawName = opts?.name ?? fake.name();
|
|
269
|
+
const parts = rawName
|
|
270
|
+
.toLowerCase()
|
|
271
|
+
.replace(/[^a-z\s]/g, "")
|
|
272
|
+
.split(/\s+/)
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
const style = int(0, 2);
|
|
275
|
+
|
|
276
|
+
let local: string;
|
|
277
|
+
if (style === 0 && parts.length >= 2) {
|
|
278
|
+
local = `${parts[0]}.${parts[1]}`;
|
|
279
|
+
} else if (style === 1 && parts.length >= 2) {
|
|
280
|
+
local = `${parts[0]![0]}${parts[1]}`;
|
|
281
|
+
} else {
|
|
282
|
+
local = parts[0] ?? "user";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const addNum = opts?.number ?? rng() < 0.4;
|
|
286
|
+
if (addNum) local += int(1, 99);
|
|
287
|
+
|
|
288
|
+
const domain = opts?.corporate ? companyDomain(pick(companies)) : pick(emailDomains);
|
|
289
|
+
|
|
290
|
+
return `${local}@${domain}`;
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* SA mobile phone number.
|
|
295
|
+
* Format: "0XX XXX XXXX"
|
|
296
|
+
*/
|
|
297
|
+
phone(): string {
|
|
298
|
+
const area = pick(mobileAreaCodes);
|
|
299
|
+
const mid = String(int(100, 999));
|
|
300
|
+
const last = String(int(1000, 9999));
|
|
301
|
+
return `${area} ${mid} ${last}`;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
// ── Location ─────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
city(): string {
|
|
307
|
+
return pick(cities);
|
|
308
|
+
},
|
|
309
|
+
province(): string {
|
|
310
|
+
return pick(provinces);
|
|
311
|
+
},
|
|
312
|
+
suburb(): string {
|
|
313
|
+
return pick(suburbs);
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/** "12 Mandela Street" style address line. */
|
|
317
|
+
streetAddress(): string {
|
|
318
|
+
return `${int(1, 299)} ${pick(streetNames)} ${pick(streetTypes)}`;
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
/** 4-digit SA postal code. */
|
|
322
|
+
postalCode(): string {
|
|
323
|
+
return pick(postalCodes);
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
/** Full postal address. */
|
|
327
|
+
address(): string {
|
|
328
|
+
return [
|
|
329
|
+
fake.streetAddress(),
|
|
330
|
+
pick(suburbs),
|
|
331
|
+
pick(cities),
|
|
332
|
+
pick(provinces),
|
|
333
|
+
fake.postalCode(),
|
|
334
|
+
].join(", ");
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
// ── Organisation ─────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
company(): string {
|
|
340
|
+
return pick(companies);
|
|
341
|
+
},
|
|
342
|
+
jobTitle(): string {
|
|
343
|
+
return pick(jobTitles);
|
|
344
|
+
},
|
|
345
|
+
department(): string {
|
|
346
|
+
return pick(departments);
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
// ── Text ─────────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
/** Single word from the lorem vocabulary. */
|
|
352
|
+
word(): string {
|
|
353
|
+
// The vocab includes a few noun *phrases* ("blog post", "trade show") that are
|
|
354
|
+
// useful for sentences but not for a single word — exclude anything with a space.
|
|
355
|
+
return pick(singleWords());
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
/** n space-joined words. */
|
|
359
|
+
words(n = 5): string {
|
|
360
|
+
// Single words, so `words(n).split(" ")` really has n entries.
|
|
361
|
+
const pool = singleWords();
|
|
362
|
+
return Array.from({ length: n }, () => pick(pool)).join(" ");
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* A single sentence.
|
|
367
|
+
*
|
|
368
|
+
* @param opts.length 'short' (5-9 words) | 'medium' (11-18) | 'long' (22-38). Default: 'medium'.
|
|
369
|
+
* @param opts.words Exact word count override.
|
|
370
|
+
*/
|
|
371
|
+
sentence(opts?: { length?: TextLength; words?: number }): string {
|
|
372
|
+
if (opts?.words) return buildWordSentence(opts.words);
|
|
373
|
+
const len = opts?.length ?? "medium";
|
|
374
|
+
const [min, max] = WORD_COUNTS[len];
|
|
375
|
+
if (len === "short") return buildSentence();
|
|
376
|
+
return buildWordSentence(int(min, max));
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/** n sentences joined with a space. */
|
|
380
|
+
sentences(n = 3, opts?: { length?: TextLength }): string {
|
|
381
|
+
return Array.from({ length: n }, () => fake.sentence(opts)).join(" ");
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A paragraph of sentences.
|
|
386
|
+
*
|
|
387
|
+
* @param opts.length 'short' (2-3 sentences) | 'medium' (3-5) | 'long' (5-8). Default: 'medium'.
|
|
388
|
+
*/
|
|
389
|
+
paragraph(opts?: { length?: TextLength }): string {
|
|
390
|
+
const len = opts?.length ?? "medium";
|
|
391
|
+
const [min, max] = SENTENCE_COUNTS[len];
|
|
392
|
+
const n = int(min, max);
|
|
393
|
+
return Array.from({ length: n }, () => fake.sentence({ length: len })).join(" ");
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
/** n paragraphs joined with a blank line. */
|
|
397
|
+
paragraphs(n = 3, opts?: { length?: TextLength }): string {
|
|
398
|
+
return Array.from({ length: n }, () => fake.paragraph(opts)).join("\n\n");
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
// ── Titles & slugs ───────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A realistic article/post title.
|
|
405
|
+
* Returns a pre-written phrase 70% of the time; constructs one otherwise.
|
|
406
|
+
*/
|
|
407
|
+
title(): string {
|
|
408
|
+
if (rng() < 0.7) return pick(titlePhrases);
|
|
409
|
+
const adj = capitalize(pick(loremAdjectives));
|
|
410
|
+
const noun = capitalize(pick(loremNouns));
|
|
411
|
+
const verb = capitalize(pick(loremVerbs));
|
|
412
|
+
return pick([
|
|
413
|
+
`${verb}ing ${adj} ${noun}s`,
|
|
414
|
+
`Understanding ${adj} ${noun}`,
|
|
415
|
+
`A Guide to ${adj} ${noun}`,
|
|
416
|
+
`${adj} ${noun}: Best Practices`,
|
|
417
|
+
`How to ${verb} Your ${noun}`,
|
|
418
|
+
`The ${adj} ${noun} Playbook`,
|
|
419
|
+
]);
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
/** URL-safe slug from a title or generated one. */
|
|
423
|
+
slug(text?: string): string {
|
|
424
|
+
return slugify(text ?? fake.title());
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
// ── URLs & passwords ─────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/** Random HTTPS URL. */
|
|
430
|
+
url(opts?: { path?: boolean }): string {
|
|
431
|
+
const domain = companyDomain(pick(companies));
|
|
432
|
+
const path = opts?.path !== false ? `/${fake.slug()}-${int(1, 999)}` : "";
|
|
433
|
+
return `https://www.${domain}${path}`;
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Realistic-looking password.
|
|
438
|
+
*
|
|
439
|
+
* @param opts.length Total length (default: 12).
|
|
440
|
+
* @param opts.simple If true, lowercase + digits only (for test fixtures).
|
|
441
|
+
*/
|
|
442
|
+
password(opts?: { length?: number; simple?: boolean }): string {
|
|
443
|
+
const len = opts?.length ?? 12;
|
|
444
|
+
if (opts?.simple) return fake.string(len);
|
|
445
|
+
|
|
446
|
+
const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
447
|
+
const lower = "abcdefghjkmnpqrstuvwxyz";
|
|
448
|
+
const digits = "23456789";
|
|
449
|
+
const syms = "!@#$%^&*";
|
|
450
|
+
|
|
451
|
+
// Guarantee at least one of each type
|
|
452
|
+
const out = [pick([...upper]), pick([...lower]), pick([...digits]), pick([...syms])];
|
|
453
|
+
|
|
454
|
+
const all = upper + lower + digits + syms;
|
|
455
|
+
while (out.length < len) out.push(pick([...all]));
|
|
456
|
+
|
|
457
|
+
// Shuffle to avoid predictable prefix
|
|
458
|
+
return fake.shuffle(out).join("");
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
export type Fake = typeof fake;
|
package/src/fakeFile.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zerotal/testing — file builders for upload tests.
|
|
3
|
+
*
|
|
4
|
+
* The files here are *real*: a PNG built by `fakeFile.image()` has the PNG
|
|
5
|
+
* signature, a valid IHDR at the size you asked for, and compressed pixel data.
|
|
6
|
+
* That matters because the framework does not trust what an upload claims to
|
|
7
|
+
* be — `UploadedFile.store()` and `detectType()` sniff the leading bytes and
|
|
8
|
+
* name the stored file from what they find. A placeholder full of zero bytes
|
|
9
|
+
* declared as `image/png` would sail through a `mimes` check and then be stored
|
|
10
|
+
* as `application/octet-stream`, so the test would pass while the behaviour it
|
|
11
|
+
* describes never happened.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { fakeFile } from '@zerotal/testing';
|
|
15
|
+
*
|
|
16
|
+
* await app.multipart('/avatar', { avatar: fakeFile.image('me.png') });
|
|
17
|
+
* await app.multipart('/docs', { doc: fakeFile.pdf('terms.pdf') });
|
|
18
|
+
* await app.multipart('/import', { csv: fakeFile.create('rows.csv', 'a,b\n1,2', 'text/csv') });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { deflateSync } from "node:zlib";
|
|
22
|
+
|
|
23
|
+
export const fakeFile = {
|
|
24
|
+
/**
|
|
25
|
+
* A file with exactly the contents you give it.
|
|
26
|
+
*
|
|
27
|
+
* @param name - Filename sent with the upload.
|
|
28
|
+
* @param content - File contents; a string is encoded as UTF-8.
|
|
29
|
+
* @param type - MIME type declared for the part.
|
|
30
|
+
*/
|
|
31
|
+
create(name: string, content: string | Uint8Array = "", type = "text/plain"): File {
|
|
32
|
+
const parts: BlobPart[] = [typeof content === "string" ? content : (content as BlobPart)];
|
|
33
|
+
return new File(parts, name, { type });
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A file of `size` bytes, for exercising size limits. The contents are filler,
|
|
38
|
+
* so use {@link image} or {@link pdf} when the bytes have to be recognisable.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // Over a 2 MB limit
|
|
42
|
+
* fakeFile.sized('huge.bin', 3 * 1024 * 1024);
|
|
43
|
+
*/
|
|
44
|
+
sized(name: string, size: number, type = "application/octet-stream"): File {
|
|
45
|
+
return new File([new Uint8Array(size)], name, { type });
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A valid PNG image of the given dimensions.
|
|
50
|
+
*
|
|
51
|
+
* @param name - Filename sent with the upload.
|
|
52
|
+
* @param options.width - Pixel width (default 10).
|
|
53
|
+
* @param options.height - Pixel height (default 10).
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* fakeFile.image('avatar.png', { width: 64, height: 64 });
|
|
57
|
+
*/
|
|
58
|
+
image(name = "image.png", options: { width?: number; height?: number } = {}): File {
|
|
59
|
+
const { width = 10, height = 10 } = options;
|
|
60
|
+
return new File([_png(width, height)], name, { type: "image/png" });
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/** A minimal but structurally valid JPEG. */
|
|
64
|
+
jpeg(name = "image.jpg"): File {
|
|
65
|
+
return new File([_jpeg()], name, { type: "image/jpeg" });
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** A minimal GIF89a image. */
|
|
69
|
+
gif(name = "image.gif"): File {
|
|
70
|
+
const bytes = new Uint8Array([
|
|
71
|
+
0x47,
|
|
72
|
+
0x49,
|
|
73
|
+
0x46,
|
|
74
|
+
0x38,
|
|
75
|
+
0x39,
|
|
76
|
+
0x61, // "GIF89a"
|
|
77
|
+
0x01,
|
|
78
|
+
0x00,
|
|
79
|
+
0x01,
|
|
80
|
+
0x00,
|
|
81
|
+
0x80,
|
|
82
|
+
0x00,
|
|
83
|
+
0x00,
|
|
84
|
+
0x00,
|
|
85
|
+
0x00,
|
|
86
|
+
0x00,
|
|
87
|
+
0xff,
|
|
88
|
+
0xff,
|
|
89
|
+
0xff,
|
|
90
|
+
0x21,
|
|
91
|
+
0xf9,
|
|
92
|
+
0x04,
|
|
93
|
+
0x01,
|
|
94
|
+
0x00,
|
|
95
|
+
0x00,
|
|
96
|
+
0x00,
|
|
97
|
+
0x00,
|
|
98
|
+
0x2c,
|
|
99
|
+
0x00,
|
|
100
|
+
0x00,
|
|
101
|
+
0x00,
|
|
102
|
+
0x00,
|
|
103
|
+
0x01,
|
|
104
|
+
0x00,
|
|
105
|
+
0x01,
|
|
106
|
+
0x00,
|
|
107
|
+
0x00,
|
|
108
|
+
0x02,
|
|
109
|
+
0x02,
|
|
110
|
+
0x44,
|
|
111
|
+
0x01,
|
|
112
|
+
0x00,
|
|
113
|
+
0x3b,
|
|
114
|
+
]);
|
|
115
|
+
return new File([bytes], name, { type: "image/gif" });
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/** A minimal one-page PDF. */
|
|
119
|
+
pdf(name = "document.pdf"): File {
|
|
120
|
+
return new File([_PDF], name, { type: "application/pdf" });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export type FakeFile = typeof fakeFile;
|
|
125
|
+
|
|
126
|
+
// ── Format builders ───────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** Build a solid-white PNG of `width`×`height`. */
|
|
129
|
+
function _png(width: number, height: number): Uint8Array<ArrayBuffer> {
|
|
130
|
+
// Raw scanlines: each row is a filter byte (0 = None) followed by RGB triples.
|
|
131
|
+
const stride = width * 3 + 1;
|
|
132
|
+
const raw = new Uint8Array(stride * height);
|
|
133
|
+
for (let y = 0; y < height; y++) {
|
|
134
|
+
raw[y * stride] = 0;
|
|
135
|
+
raw.fill(0xff, y * stride + 1, y * stride + stride);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ihdr = new Uint8Array(13);
|
|
139
|
+
const view = new DataView(ihdr.buffer);
|
|
140
|
+
view.setUint32(0, width);
|
|
141
|
+
view.setUint32(4, height);
|
|
142
|
+
ihdr[8] = 8; // bit depth
|
|
143
|
+
ihdr[9] = 2; // colour type: truecolour
|
|
144
|
+
ihdr[10] = 0; // compression
|
|
145
|
+
ihdr[11] = 0; // filter
|
|
146
|
+
ihdr[12] = 0; // interlace
|
|
147
|
+
|
|
148
|
+
return _concat([
|
|
149
|
+
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
150
|
+
_chunk("IHDR", ihdr),
|
|
151
|
+
_chunk("IDAT", new Uint8Array(deflateSync(raw))),
|
|
152
|
+
_chunk("IEND", new Uint8Array(0)),
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** length ‖ type ‖ data ‖ CRC32(type ‖ data) — the PNG chunk layout. */
|
|
157
|
+
function _chunk(type: string, data: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
158
|
+
const typeBytes = new Uint8Array([...type].map((c) => c.charCodeAt(0)));
|
|
159
|
+
const out = new Uint8Array(12 + data.length);
|
|
160
|
+
const view = new DataView(out.buffer);
|
|
161
|
+
view.setUint32(0, data.length);
|
|
162
|
+
out.set(typeBytes, 4);
|
|
163
|
+
out.set(data, 8);
|
|
164
|
+
view.setUint32(8 + data.length, _crc32(_concat([typeBytes, data])));
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let _crcTable: Uint32Array | null = null;
|
|
169
|
+
|
|
170
|
+
function _crc32(bytes: Uint8Array): number {
|
|
171
|
+
if (!_crcTable) {
|
|
172
|
+
_crcTable = new Uint32Array(256);
|
|
173
|
+
for (let n = 0; n < 256; n++) {
|
|
174
|
+
let c = n;
|
|
175
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
176
|
+
_crcTable[n] = c >>> 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let crc = 0xffffffff;
|
|
180
|
+
for (const byte of bytes) crc = _crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
|
181
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** SOI ‖ JFIF APP0 ‖ EOI — enough for the signature check and a real header. */
|
|
185
|
+
function _jpeg(): Uint8Array<ArrayBuffer> {
|
|
186
|
+
return new Uint8Array([
|
|
187
|
+
0xff,
|
|
188
|
+
0xd8, // SOI
|
|
189
|
+
0xff,
|
|
190
|
+
0xe0,
|
|
191
|
+
0x00,
|
|
192
|
+
0x10, // APP0, length 16
|
|
193
|
+
0x4a,
|
|
194
|
+
0x46,
|
|
195
|
+
0x49,
|
|
196
|
+
0x46,
|
|
197
|
+
0x00, // "JFIF\0"
|
|
198
|
+
0x01,
|
|
199
|
+
0x01, // version 1.1
|
|
200
|
+
0x00, // units: none
|
|
201
|
+
0x00,
|
|
202
|
+
0x01,
|
|
203
|
+
0x00,
|
|
204
|
+
0x01, // density 1×1
|
|
205
|
+
0x00,
|
|
206
|
+
0x00, // no thumbnail
|
|
207
|
+
0xff,
|
|
208
|
+
0xd9, // EOI
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const _PDF = `%PDF-1.4
|
|
213
|
+
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
|
214
|
+
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
|
215
|
+
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
|
|
216
|
+
trailer<</Root 1 0 R>>
|
|
217
|
+
%%EOF
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
function _concat(chunks: Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
221
|
+
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
222
|
+
const out = new Uint8Array(total);
|
|
223
|
+
let offset = 0;
|
|
224
|
+
for (const chunk of chunks) {
|
|
225
|
+
out.set(chunk, offset);
|
|
226
|
+
offset += chunk.length;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Ambient declarations specific to this package.
|
|
2
|
+
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
+
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
+
|
|
5
|
+
// ── Bun globals ───────────────────────────────────────────────────────────────
|
|
6
|
+
interface Request {
|
|
7
|
+
readonly params?: Record<string, string>;
|
|
8
|
+
readonly cookies: Bun.CookieMap;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface BunProcess { exited: Promise<number> }
|
|
12
|
+
|
|
13
|
+
interface SQLInstance {
|
|
14
|
+
<T = Record<string, unknown>>(
|
|
15
|
+
strings: TemplateStringsArray,
|
|
16
|
+
...values: unknown[]
|
|
17
|
+
): Promise<T[]>;
|
|
18
|
+
begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
|
|
19
|
+
end(): Promise<void>;
|
|
20
|
+
}
|