@stratal/testing 0.0.26 → 0.1.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 +562 -0
- package/README.md +137 -36
- package/dist/database/index.d.mts +2 -2
- package/dist/database/index.mjs +2 -2
- package/dist/database-Rojs69r3.mjs +453 -0
- package/dist/database-Rojs69r3.mjs.map +1 -0
- package/dist/{decorate-B7nr7eBl.mjs → decorate-RQD1h28J.mjs} +1 -1
- package/dist/feature-flags/index.mjs +1 -1
- package/dist/{feature-flags-BiLhfSGh.mjs → feature-flags-CZ1a4M2g.mjs} +2 -2
- package/dist/{feature-flags-BiLhfSGh.mjs.map → feature-flags-CZ1a4M2g.mjs.map} +1 -1
- package/dist/index-B8Mw1Dxk.d.mts +175 -0
- package/dist/index-B8Mw1Dxk.d.mts.map +1 -0
- package/dist/{index-CrHzUDKX.d.mts → index-D7FM6EKp.d.mts} +21 -3
- package/dist/index-D7FM6EKp.d.mts.map +1 -0
- package/dist/index-qgWNJRdC.d.mts.map +1 -1
- package/dist/index.d.mts +105 -46
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +218 -108
- package/dist/index.mjs.map +1 -1
- package/dist/mocks/cloudflare-workers.d.mts +24 -0
- package/dist/mocks/cloudflare-workers.d.mts.map +1 -0
- package/dist/mocks/cloudflare-workers.mjs +28 -0
- package/dist/mocks/cloudflare-workers.mjs.map +1 -0
- package/dist/mocks/index.d.mts +2 -2
- package/dist/mocks/index.mjs +2 -2
- package/dist/mocks/noop-rate-limiter-store.d.mts +1 -3
- package/dist/mocks/noop-rate-limiter-store.d.mts.map +1 -1
- package/dist/mocks/zenstack-language.d.mts +26 -29
- package/dist/mocks/zenstack-language.d.mts.map +1 -1
- package/dist/mocks/zenstack-language.mjs +1 -1
- package/dist/mocks/zenstack-language.mjs.map +1 -1
- package/dist/storage/index.d.mts +1 -1
- package/dist/storage/index.mjs +1 -1
- package/dist/{storage-DhoxWqyF.mjs → storage-BiGamMA8.mjs} +73 -6
- package/dist/storage-BiGamMA8.mjs.map +1 -0
- package/dist/test-worker-exports-dCRARYEc.d.mts +144 -0
- package/dist/test-worker-exports-dCRARYEc.d.mts.map +1 -0
- package/dist/test-workers-cache-D02Pt_dE.mjs +183 -0
- package/dist/test-workers-cache-D02Pt_dE.mjs.map +1 -0
- package/dist/vitest-plugin/index.d.mts +14 -28
- package/dist/vitest-plugin/index.d.mts.map +1 -1
- package/dist/vitest-plugin/index.mjs +32 -20
- package/dist/vitest-plugin/index.mjs.map +1 -1
- package/package.json +30 -21
- package/dist/database-B02eYKhE.mjs +0 -334
- package/dist/database-B02eYKhE.mjs.map +0 -1
- package/dist/index-BIr5nLof.d.mts +0 -122
- package/dist/index-BIr5nLof.d.mts.map +0 -1
- package/dist/index-CrHzUDKX.d.mts.map +0 -1
- package/dist/storage-DhoxWqyF.mjs.map +0 -1
- package/dist/test-email-provider-B7cjj97-.d.mts +0 -21
- package/dist/test-email-provider-B7cjj97-.d.mts.map +0 -1
- package/dist/test-email-provider-Dr-nhE0x.mjs +0 -34
- package/dist/test-email-provider-Dr-nhE0x.mjs.map +0 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { IRateLimiterStore } from "stratal/rate-limiter";
|
|
2
|
-
|
|
3
2
|
//#region src/mocks/noop-rate-limiter-store.d.ts
|
|
4
3
|
/**
|
|
5
4
|
* Rate-limit store that never persists, so every read misses and every
|
|
@@ -11,11 +10,10 @@ import { IRateLimiterStore } from "stratal/rate-limiter";
|
|
|
11
10
|
* this store via the framework bridge). Suites that want to test limiting
|
|
12
11
|
* behavior can override `RATE_LIMITER_TOKENS.Store` back to a real store.
|
|
13
12
|
*/
|
|
14
|
-
declare class NoopRateLimiterStore implements IRateLimiterStore {
|
|
13
|
+
export declare class NoopRateLimiterStore implements IRateLimiterStore {
|
|
15
14
|
get<T = unknown>(_key: string): Promise<T | null>;
|
|
16
15
|
set<T = unknown>(_key: string, _value: T, _ttlSeconds: number): Promise<void>;
|
|
17
16
|
delete(_key: string): Promise<void>;
|
|
18
17
|
}
|
|
19
18
|
//#endregion
|
|
20
|
-
export { NoopRateLimiterStore };
|
|
21
19
|
//# sourceMappingURL=noop-rate-limiter-store.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"noop-rate-limiter-store.d.mts","names":[],"sources":["../../src/mocks/noop-rate-limiter-store.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"noop-rate-limiter-store.d.mts","names":[],"sources":["../../src/mocks/noop-rate-limiter-store.ts"],"mappings":";;;;;;;;;;;;qBAYa,gCAAgC;EAC3C,IAAI,aAAa,eAAe,QAAQ;EAIxC,IAAI,aAAa,cAAc,QAAQ,GAAG,sBAAsB;EAKhE,OAAO,eAAe"}
|
|
@@ -6,43 +6,40 @@
|
|
|
6
6
|
* incompatible with Cloudflare Workers runtime. These exports are only used
|
|
7
7
|
* for schema generation (CLI functionality), not runtime authentication.
|
|
8
8
|
*
|
|
9
|
-
* This mock provides stub implementations to allow tests to run in
|
|
9
|
+
* This mock provides stub implementations to allow tests to run in the Workers Vitest integration.
|
|
10
10
|
*/
|
|
11
|
-
declare const formatDocument: (content: string) => string;
|
|
12
|
-
declare const loadDocument: (_path: string) => {
|
|
11
|
+
export declare const formatDocument: (content: string) => string;
|
|
12
|
+
export declare const loadDocument: (_path: string) => {
|
|
13
13
|
success: boolean;
|
|
14
14
|
errors: string[];
|
|
15
15
|
};
|
|
16
|
-
declare class ZModelCodeGenerator {
|
|
16
|
+
export declare class ZModelCodeGenerator {
|
|
17
17
|
generate(_model: unknown): string;
|
|
18
18
|
}
|
|
19
|
-
declare const isDataModel: (_item: unknown) => boolean;
|
|
20
|
-
declare const isAttribute: (_item: unknown) => boolean;
|
|
21
|
-
declare const isAbstractDeclaration: (_item: unknown) => boolean;
|
|
22
|
-
declare const isExpression: (_item: unknown) => boolean;
|
|
23
|
-
declare const isLiteralExpr: (_item: unknown) => boolean;
|
|
24
|
-
declare const isArrayExpr: (_item: unknown) => boolean;
|
|
25
|
-
declare const isAttributeArg: (_item: unknown) => boolean;
|
|
26
|
-
declare const isBinaryExpr: (_item: unknown) => boolean;
|
|
27
|
-
declare const isBooleanLiteral: (_item: unknown) => boolean;
|
|
28
|
-
declare const isConfigArrayExpr: (_item: unknown) => boolean;
|
|
29
|
-
declare const isConfigField: (_item: unknown) => boolean;
|
|
30
|
-
declare const isDataField: (_item: unknown) => boolean;
|
|
31
|
-
declare const isDataFieldAttribute: (_item: unknown) => boolean;
|
|
32
|
-
declare const isDataFieldType: (_item: unknown) => boolean;
|
|
33
|
-
declare const isDataModelAttribute: (_item: unknown) => boolean;
|
|
34
|
-
declare const hasAttribute: (_node: unknown, _name?: string) => boolean;
|
|
19
|
+
export declare const isDataModel: (_item: unknown) => boolean;
|
|
20
|
+
export declare const isAttribute: (_item: unknown) => boolean;
|
|
21
|
+
export declare const isAbstractDeclaration: (_item: unknown) => boolean;
|
|
22
|
+
export declare const isExpression: (_item: unknown) => boolean;
|
|
23
|
+
export declare const isLiteralExpr: (_item: unknown) => boolean;
|
|
24
|
+
export declare const isArrayExpr: (_item: unknown) => boolean;
|
|
25
|
+
export declare const isAttributeArg: (_item: unknown) => boolean;
|
|
26
|
+
export declare const isBinaryExpr: (_item: unknown) => boolean;
|
|
27
|
+
export declare const isBooleanLiteral: (_item: unknown) => boolean;
|
|
28
|
+
export declare const isConfigArrayExpr: (_item: unknown) => boolean;
|
|
29
|
+
export declare const isConfigField: (_item: unknown) => boolean;
|
|
30
|
+
export declare const isDataField: (_item: unknown) => boolean;
|
|
31
|
+
export declare const isDataFieldAttribute: (_item: unknown) => boolean;
|
|
32
|
+
export declare const isDataFieldType: (_item: unknown) => boolean;
|
|
33
|
+
export declare const isDataModelAttribute: (_item: unknown) => boolean;
|
|
34
|
+
export declare const hasAttribute: (_node: unknown, _name?: string) => boolean;
|
|
35
35
|
declare const _default: {
|
|
36
|
-
formatDocument:
|
|
37
|
-
loadDocument:
|
|
38
|
-
success: boolean;
|
|
39
|
-
errors: string[];
|
|
40
|
-
};
|
|
36
|
+
formatDocument: typeof formatDocument;
|
|
37
|
+
loadDocument: typeof loadDocument;
|
|
41
38
|
ZModelCodeGenerator: typeof ZModelCodeGenerator;
|
|
42
|
-
isDataModel:
|
|
43
|
-
isAttribute:
|
|
44
|
-
hasAttribute:
|
|
39
|
+
isDataModel: typeof isDataModel;
|
|
40
|
+
isAttribute: typeof isAttribute;
|
|
41
|
+
hasAttribute: typeof hasAttribute;
|
|
45
42
|
};
|
|
46
43
|
//#endregion
|
|
47
|
-
export {
|
|
44
|
+
export { _default as default };
|
|
48
45
|
//# sourceMappingURL=zenstack-language.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"zenstack-language.d.mts","names":[],"sources":["../../src/mocks/zenstack-language.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"zenstack-language.d.mts","names":[],"sources":["../../src/mocks/zenstack-language.ts"],"mappings":";;;;;;;;;;qBAWa,iBAAc;qBAEd,eAAY;EACxB;EACA;;qBAGY;EACZ,SAAS;;qBAMG,cAAW;qBACX,cAAW;qBACX,wBAAqB;qBACrB,eAAY;qBACZ,gBAAa;qBACb,cAAW;qBACX,iBAAc;qBACd,eAAY;qBACZ,mBAAgB;qBAChB,oBAAiB;qBACjB,gBAAa;qBACb,cAAW;qBACX,uBAAoB;qBACpB,kBAAe;qBACf,uBAAoB;qBAGpB,eAAY,gBAAkB"}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* incompatible with Cloudflare Workers runtime. These exports are only used
|
|
7
7
|
* for schema generation (CLI functionality), not runtime authentication.
|
|
8
8
|
*
|
|
9
|
-
* This mock provides stub implementations to allow tests to run in
|
|
9
|
+
* This mock provides stub implementations to allow tests to run in the Workers Vitest integration.
|
|
10
10
|
*/
|
|
11
11
|
const formatDocument = (content) => content;
|
|
12
12
|
const loadDocument = (_path) => ({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"zenstack-language.mjs","names":[],"sources":["../../src/mocks/zenstack-language.ts"],"sourcesContent":["/**\n * Mock for @zenstackhq/language package\n *\n * The @zenstackhq/language package depends on vscode-languageserver which is\n * incompatible with Cloudflare Workers runtime. These exports are only used\n * for schema generation (CLI functionality), not runtime authentication.\n *\n * This mock provides stub implementations to allow tests to run in
|
|
1
|
+
{"version":3,"file":"zenstack-language.mjs","names":[],"sources":["../../src/mocks/zenstack-language.ts"],"sourcesContent":["/**\n * Mock for @zenstackhq/language package\n *\n * The @zenstackhq/language package depends on vscode-languageserver which is\n * incompatible with Cloudflare Workers runtime. These exports are only used\n * for schema generation (CLI functionality), not runtime authentication.\n *\n * This mock provides stub implementations to allow tests to run in the Workers Vitest integration.\n */\n\n// Main exports from @zenstackhq/language\nexport const formatDocument = (content: string) => content\n\nexport const loadDocument = (_path: string) => ({\n\tsuccess: false,\n\terrors: ['Mock: loadDocument not available in test environment'],\n})\n\nexport class ZModelCodeGenerator {\n\tgenerate(_model: unknown): string {\n\t\treturn ''\n\t}\n}\n\n// AST exports from @zenstackhq/language/ast\nexport const isDataModel = (_item: unknown): boolean => false\nexport const isAttribute = (_item: unknown): boolean => false\nexport const isAbstractDeclaration = (_item: unknown): boolean => false\nexport const isExpression = (_item: unknown): boolean => false\nexport const isLiteralExpr = (_item: unknown): boolean => false\nexport const isArrayExpr = (_item: unknown): boolean => false\nexport const isAttributeArg = (_item: unknown): boolean => false\nexport const isBinaryExpr = (_item: unknown): boolean => false\nexport const isBooleanLiteral = (_item: unknown): boolean => false\nexport const isConfigArrayExpr = (_item: unknown): boolean => false\nexport const isConfigField = (_item: unknown): boolean => false\nexport const isDataField = (_item: unknown): boolean => false\nexport const isDataFieldAttribute = (_item: unknown): boolean => false\nexport const isDataFieldType = (_item: unknown): boolean => false\nexport const isDataModelAttribute = (_item: unknown): boolean => false\n\n// Utils exports from @zenstackhq/language/utils\nexport const hasAttribute = (_node: unknown, _name?: string): boolean => false\n\nexport default {\n\tformatDocument,\n\tloadDocument,\n\tZModelCodeGenerator,\n\tisDataModel,\n\tisAttribute,\n\thasAttribute,\n}\n"],"mappings":";;;;;;;;;;AAWA,MAAa,kBAAkB,YAAoB;AAEnD,MAAa,gBAAgB,WAAmB;CAC/C,SAAS;CACT,QAAQ,CAAC,sDAAsD;AAChE;AAEA,IAAa,sBAAb,MAAiC;CAChC,SAAS,QAAyB;EACjC,OAAO;CACR;AACD;AAGA,MAAa,eAAe,UAA4B;AACxD,MAAa,eAAe,UAA4B;AACxD,MAAa,yBAAyB,UAA4B;AAClE,MAAa,gBAAgB,UAA4B;AACzD,MAAa,iBAAiB,UAA4B;AAC1D,MAAa,eAAe,UAA4B;AACxD,MAAa,kBAAkB,UAA4B;AAC3D,MAAa,gBAAgB,UAA4B;AACzD,MAAa,oBAAoB,UAA4B;AAC7D,MAAa,qBAAqB,UAA4B;AAC9D,MAAa,iBAAiB,UAA4B;AAC1D,MAAa,eAAe,UAA4B;AACxD,MAAa,wBAAwB,UAA4B;AACjE,MAAa,mBAAmB,UAA4B;AAC5D,MAAa,wBAAwB,UAA4B;AAGjE,MAAa,gBAAgB,OAAgB,UAA4B;AAEzE,IAAA,4BAAe;CACd;CACA;CACA;CACA;CACA;CACA;AACD"}
|
package/dist/storage/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as StoredFile, t as FakeStorageService } from "../index-
|
|
1
|
+
import { n as StoredFile, t as FakeStorageService } from "../index-D7FM6EKp.mjs";
|
|
2
2
|
export { FakeStorageService, type StoredFile };
|
package/dist/storage/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as FakeStorageService } from "../storage-
|
|
1
|
+
import { t as FakeStorageService } from "../storage-BiGamMA8.mjs";
|
|
2
2
|
export { FakeStorageService };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { t as __decorate } from "./decorate-
|
|
1
|
+
import { t as __decorate } from "./decorate-RQD1h28J.mjs";
|
|
2
2
|
import { FileNotFoundError, STORAGE_TOKENS, StorageService } from "stratal/storage";
|
|
3
3
|
import { Transient, inject } from "stratal/di";
|
|
4
4
|
import { expect } from "vitest";
|
|
5
|
-
//#region \0@oxc-project+runtime@0.
|
|
5
|
+
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateParam.js
|
|
6
6
|
function __decorateParam(paramIndex, decorator) {
|
|
7
7
|
return function(target, key) {
|
|
8
8
|
decorator(target, key, paramIndex);
|
|
@@ -10,6 +10,23 @@ function __decorateParam(paramIndex, decorator) {
|
|
|
10
10
|
}
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/storage/fake-storage.service.ts
|
|
13
|
+
/**
|
|
14
|
+
* Objects per `list` page when a test does not ask for a size.
|
|
15
|
+
*
|
|
16
|
+
* One, so that any listing of two or more objects truncates and a caller who skips the `cursor`
|
|
17
|
+
* loop fails here rather than against a real bucket.
|
|
18
|
+
*/
|
|
19
|
+
const FAKE_LIST_PAGE_SIZE = 1;
|
|
20
|
+
/** Shared by `head` and `list` so a listed object and a headed one cannot drift apart. */
|
|
21
|
+
function toHeadResult(path, file) {
|
|
22
|
+
return {
|
|
23
|
+
path,
|
|
24
|
+
size: file.size,
|
|
25
|
+
contentType: file.mimeType,
|
|
26
|
+
uploadedAt: file.uploadedAt,
|
|
27
|
+
metadata: file.metadata
|
|
28
|
+
};
|
|
29
|
+
}
|
|
13
30
|
let FakeStorageService = class FakeStorageService extends StorageService {
|
|
14
31
|
storageManager;
|
|
15
32
|
options;
|
|
@@ -73,6 +90,56 @@ let FakeStorageService = class FakeStorageService extends StorageService {
|
|
|
73
90
|
return Promise.resolve(this.files.has(path));
|
|
74
91
|
}
|
|
75
92
|
/**
|
|
93
|
+
* Delete many files from fake storage
|
|
94
|
+
*/
|
|
95
|
+
async deleteMany(paths, disk) {
|
|
96
|
+
this.resolveDisk(disk);
|
|
97
|
+
for (const path of paths) this.files.delete(path);
|
|
98
|
+
return Promise.resolve();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read a file's metadata from fake storage, without its contents
|
|
102
|
+
*/
|
|
103
|
+
async head(path, disk) {
|
|
104
|
+
this.resolveDisk(disk);
|
|
105
|
+
const file = this.files.get(path);
|
|
106
|
+
if (!file) return Promise.resolve(null);
|
|
107
|
+
return Promise.resolve(toHeadResult(path, file));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* List files in fake storage, one page at a time
|
|
111
|
+
*
|
|
112
|
+
* Truncates after ONE object unless `limit` says otherwise. Real R2 pages at 1000, but no
|
|
113
|
+
* fixture stores 1000 files — matching that number would return everything in one page and let
|
|
114
|
+
* a caller who never follows `cursor` pass every test, then undercount against a real bucket.
|
|
115
|
+
* Paging at one means any listing of two or more objects exercises the loop.
|
|
116
|
+
*
|
|
117
|
+
* Pass `limit` to widen the page when a test is asserting page contents rather than the loop.
|
|
118
|
+
*/
|
|
119
|
+
async list(options = {}, disk) {
|
|
120
|
+
this.resolveDisk(disk);
|
|
121
|
+
const prefix = options.prefix ?? "";
|
|
122
|
+
const matching = Array.from(this.files.keys()).filter((path) => path.startsWith(prefix)).sort();
|
|
123
|
+
const start = options.cursor === void 0 ? 0 : matching.indexOf(options.cursor);
|
|
124
|
+
if (start < 0) return Promise.reject(/* @__PURE__ */ new Error(`Unknown storage list cursor: ${options.cursor ?? ""}`));
|
|
125
|
+
const limit = options.limit ?? FAKE_LIST_PAGE_SIZE;
|
|
126
|
+
const page = matching.slice(start, start + limit);
|
|
127
|
+
const next = matching[start + limit];
|
|
128
|
+
return Promise.resolve({
|
|
129
|
+
objects: page.map((path) => {
|
|
130
|
+
const object = toHeadResult(path, this.files.get(path));
|
|
131
|
+
if (options.includeMetadata) return object;
|
|
132
|
+
return {
|
|
133
|
+
...object,
|
|
134
|
+
contentType: void 0,
|
|
135
|
+
metadata: void 0
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
truncated: next !== void 0,
|
|
139
|
+
cursor: next
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
76
143
|
* Generate a fake presigned download URL
|
|
77
144
|
*/
|
|
78
145
|
getPresignedDownloadUrl(path, expiresIn) {
|
|
@@ -96,7 +163,7 @@ let FakeStorageService = class FakeStorageService extends StorageService {
|
|
|
96
163
|
async chunkedUpload(body, path, options, disk) {
|
|
97
164
|
const content = await this.bodyToUint8Array(body);
|
|
98
165
|
const size = options.size ?? content.length;
|
|
99
|
-
return this.upload(
|
|
166
|
+
return this.upload(content, path, {
|
|
100
167
|
...options,
|
|
101
168
|
size
|
|
102
169
|
}, disk);
|
|
@@ -170,7 +237,7 @@ let FakeStorageService = class FakeStorageService extends StorageService {
|
|
|
170
237
|
};
|
|
171
238
|
}
|
|
172
239
|
async bodyToUint8Array(body) {
|
|
173
|
-
if (!body) return new Uint8Array(0);
|
|
240
|
+
if (!body) return /* @__PURE__ */ new Uint8Array(0);
|
|
174
241
|
if (body instanceof Uint8Array) return body;
|
|
175
242
|
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
176
243
|
if (typeof body === "string") return new TextEncoder().encode(body);
|
|
@@ -180,7 +247,7 @@ let FakeStorageService = class FakeStorageService extends StorageService {
|
|
|
180
247
|
}
|
|
181
248
|
if (body instanceof ReadableStream) return new Uint8Array(await new Response(body).arrayBuffer());
|
|
182
249
|
if (body instanceof FormData || body instanceof URLSearchParams) return new Uint8Array(await new Response(body).arrayBuffer());
|
|
183
|
-
return new Uint8Array(0);
|
|
250
|
+
return /* @__PURE__ */ new Uint8Array(0);
|
|
184
251
|
}
|
|
185
252
|
};
|
|
186
253
|
FakeStorageService = __decorate([
|
|
@@ -191,4 +258,4 @@ FakeStorageService = __decorate([
|
|
|
191
258
|
//#endregion
|
|
192
259
|
export { FakeStorageService as t };
|
|
193
260
|
|
|
194
|
-
//# sourceMappingURL=storage-
|
|
261
|
+
//# sourceMappingURL=storage-BiGamMA8.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage-BiGamMA8.mjs","names":[],"sources":["../src/storage/fake-storage.service.ts"],"sourcesContent":["import { Transient, inject } from 'stratal/di'\nimport {\n FileNotFoundError,\n STORAGE_TOKENS,\n type StorageManagerService,\n StorageService,\n type StreamingBlobPayloadInputTypes,\n type DownloadResult,\n type HeadResult,\n type ListOptions,\n type ListResult,\n type PresignedUrlResult,\n type StorageConfig,\n type UploadOptions,\n type UploadResult,\n} from 'stratal/storage'\nimport { expect } from 'vitest'\n\n/**\n * Objects per `list` page when a test does not ask for a size.\n *\n * One, so that any listing of two or more objects truncates and a caller who skips the `cursor`\n * loop fails here rather than against a real bucket.\n */\nconst FAKE_LIST_PAGE_SIZE = 1\n\n/** Shared by `head` and `list` so a listed object and a headed one cannot drift apart. */\nfunction toHeadResult(path: string, file: StoredFile): HeadResult {\n return {\n path,\n size: file.size,\n contentType: file.mimeType,\n uploadedAt: file.uploadedAt,\n metadata: file.metadata,\n }\n}\n\n/**\n * Stored file representation in memory\n */\nexport interface StoredFile {\n content: Uint8Array\n mimeType: string\n size: number\n metadata?: Record<string, string>\n uploadedAt: Date\n}\n\n/**\n * FakeStorageService\n *\n * In-memory storage implementation for testing.\n * Registered by default in TestingModuleBuilder.\n *\n * Similar to Laravel's Storage::fake() - stores files in memory\n * and provides assertion helpers for testing.\n *\n * @example\n * ```typescript\n * // Access via TestingModule\n * module.storage.assertExists('path/to/file.pdf')\n * module.storage.assertMissing('deleted/file.pdf')\n * module.storage.clear() // Reset between tests\n * ```\n */\n@Transient(STORAGE_TOKENS.StorageService)\nexport class FakeStorageService extends StorageService {\n private files = new Map<string, StoredFile>()\n\n constructor(\n @inject(STORAGE_TOKENS.StorageManager)\n protected readonly storageManager: StorageManagerService,\n @inject(STORAGE_TOKENS.Options)\n protected readonly options: StorageConfig\n ) {\n super(storageManager, options)\n }\n\n /**\n * Upload content to fake storage\n */\n async upload(\n body: StreamingBlobPayloadInputTypes,\n relativePath: string,\n options: UploadOptions,\n disk?: string\n ): Promise<UploadResult> {\n const content = await this.bodyToUint8Array(body)\n const diskName = this.resolveDisk(disk)\n\n this.files.set(relativePath, {\n content,\n mimeType: options.mimeType ?? 'application/octet-stream',\n size: options.size,\n metadata: options.metadata,\n uploadedAt: new Date(),\n })\n\n return {\n path: relativePath,\n disk: diskName,\n fullPath: relativePath,\n size: options.size,\n mimeType: options.mimeType ?? 'application/octet-stream',\n uploadedAt: new Date(),\n }\n }\n\n /**\n * Download a file from fake storage\n */\n download(path: string): Promise<DownloadResult> {\n const file = this.files.get(path)\n\n if (!file) {\n return Promise.reject(new FileNotFoundError(path))\n }\n\n return Promise.resolve({\n toStream: () => new ReadableStream({\n start(controller) {\n controller.enqueue(file.content)\n controller.close()\n },\n }),\n toString: () => Promise.resolve(new TextDecoder().decode(file.content)),\n toArrayBuffer: () => Promise.resolve(file.content),\n contentType: file.mimeType,\n size: file.size,\n metadata: file.metadata,\n })\n }\n\n /**\n * Delete a file from fake storage\n */\n delete(path: string): Promise<void> {\n this.files.delete(path)\n return Promise.resolve()\n }\n\n /**\n * Check if a file exists in fake storage\n */\n exists(path: string): Promise<boolean> {\n return Promise.resolve(this.files.has(path))\n }\n\n /**\n * Delete many files from fake storage\n */\n // `async` so an unconfigured disk arrives as a rejected promise, as it does from the real\n // service. Thrown synchronously it would slip past a caller that chains `.catch()` without\n // awaiting — a difference in error surface is still a difference the fake exists to avoid.\n async deleteMany(paths: string[], disk?: string): Promise<void> {\n this.resolveDisk(disk)\n\n for (const path of paths) {\n this.files.delete(path)\n }\n\n return Promise.resolve()\n }\n\n /**\n * Read a file's metadata from fake storage, without its contents\n */\n async head(path: string, disk?: string): Promise<HeadResult | null> {\n this.resolveDisk(disk)\n\n const file = this.files.get(path)\n if (!file) {\n return Promise.resolve(null)\n }\n\n return Promise.resolve(toHeadResult(path, file))\n }\n\n /**\n * List files in fake storage, one page at a time\n *\n * Truncates after ONE object unless `limit` says otherwise. Real R2 pages at 1000, but no\n * fixture stores 1000 files — matching that number would return everything in one page and let\n * a caller who never follows `cursor` pass every test, then undercount against a real bucket.\n * Paging at one means any listing of two or more objects exercises the loop.\n *\n * Pass `limit` to widen the page when a test is asserting page contents rather than the loop.\n */\n async list(options: ListOptions = {}, disk?: string): Promise<ListResult> {\n this.resolveDisk(disk)\n\n const prefix = options.prefix ?? ''\n // Sorted so pagination is deterministic — an unordered Map iteration would make `cursor`\n // meaningless and the page boundaries unrepeatable between runs.\n const matching = Array.from(this.files.keys())\n .filter((path) => path.startsWith(prefix))\n .sort()\n\n const start = options.cursor === undefined ? 0 : matching.indexOf(options.cursor)\n if (start < 0) {\n return Promise.reject(new Error(`Unknown storage list cursor: ${options.cursor ?? ''}`))\n }\n\n const limit = options.limit ?? FAKE_LIST_PAGE_SIZE\n const page = matching.slice(start, start + limit)\n const next = matching[start + limit]\n\n return Promise.resolve({\n objects: page.map((path) => {\n const object = toHeadResult(path, this.files.get(path)!)\n if (options.includeMetadata) {\n return object\n }\n\n // Dropped unless asked for, because R2 omits them unless asked for. A fake that always\n // returned them would let a caller read `contentType` off a listing in tests and find it\n // undefined against a real bucket.\n return { ...object, contentType: undefined, metadata: undefined }\n }),\n truncated: next !== undefined,\n cursor: next,\n })\n }\n\n /**\n * Generate a fake presigned download URL\n */\n getPresignedDownloadUrl(\n path: string,\n expiresIn?: number\n ): Promise<PresignedUrlResult> {\n return Promise.resolve(this.createPresignedUrl(path, 'GET', expiresIn))\n }\n\n /**\n * Generate a fake presigned upload URL\n */\n getPresignedUploadUrl(\n path: string,\n expiresIn?: number\n ): Promise<PresignedUrlResult> {\n return Promise.resolve(this.createPresignedUrl(path, 'PUT', expiresIn))\n }\n\n /**\n * Generate a fake presigned delete URL\n */\n getPresignedDeleteUrl(\n path: string,\n expiresIn?: number\n ): Promise<PresignedUrlResult> {\n return Promise.resolve(this.createPresignedUrl(path, 'DELETE', expiresIn))\n }\n\n /**\n * Chunked upload (same as regular upload for fake)\n */\n async chunkedUpload(\n body: StreamingBlobPayloadInputTypes,\n path: string,\n options: Omit<UploadOptions, 'size'> & { size?: number },\n disk?: string\n ): Promise<UploadResult> {\n const content = await this.bodyToUint8Array(body)\n const size = options.size ?? content.length\n\n // Reuse the already-materialized bytes rather than `body`: a `ReadableStream`\n // is single-use, so reading it here and again inside `upload` would throw\n // \"ReadableStream is disturbed\". `content` is a Uint8Array and safe to re-read.\n return this.upload(content, path, { ...options, size }, disk)\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Test Assertion Helpers\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Assert that a file exists at the given path\n *\n * @param path - Path to check\n * @throws AssertionError if file does not exist\n */\n assertExists(path: string): void {\n expect(\n this.files.has(path),\n `Expected file to exist at: ${path}\\nStored files: ${this.getStoredPaths().join(', ') || '(none)'}`\n ).toBe(true)\n }\n\n /**\n * Assert that a file does NOT exist at the given path\n *\n * @param path - Path to check\n * @throws AssertionError if file exists\n */\n assertMissing(path: string): void {\n expect(\n this.files.has(path),\n `Expected file NOT to exist at: ${path}`\n ).toBe(false)\n }\n\n /**\n * Assert storage is empty\n *\n * @throws AssertionError if any files exist\n */\n assertEmpty(): void {\n expect(\n this.files.size,\n `Expected storage to be empty but found ${this.files.size} files: ${this.getStoredPaths().join(', ')}`\n ).toBe(0)\n }\n\n /**\n * Assert storage has exactly N files\n *\n * @param count - Expected number of files\n * @throws AssertionError if count doesn't match\n */\n assertCount(count: number): void {\n expect(\n this.files.size,\n `Expected ${count} files in storage but found ${this.files.size}`\n ).toBe(count)\n }\n\n /**\n * Get all stored files (for inspection)\n */\n getStoredFiles(): Map<string, StoredFile> {\n return new Map(this.files)\n }\n\n /**\n * Get all stored file paths\n */\n getStoredPaths(): string[] {\n return Array.from(this.files.keys())\n }\n\n /**\n * Get a specific file by path\n */\n getFile(path: string): StoredFile | undefined {\n return this.files.get(path)\n }\n\n /**\n * Clear all stored files (call in beforeEach for test isolation)\n */\n clear(): void {\n this.files.clear()\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Private Helpers\n // ─────────────────────────────────────────────────────────────────────────\n\n private createPresignedUrl(\n path: string,\n method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',\n expiresIn = 300\n ): PresignedUrlResult {\n const expiresAt = new Date(Date.now() + expiresIn * 1000)\n\n return {\n url: `https://fake-storage.test/${path}?method=${method}&expires=${expiresAt.toISOString()}`,\n expiresIn,\n expiresAt,\n method,\n }\n }\n\n private async bodyToUint8Array(body: StreamingBlobPayloadInputTypes | null | undefined): Promise<Uint8Array> {\n if (!body) {\n return new Uint8Array(0)\n }\n\n if (body instanceof Uint8Array) {\n return body\n }\n\n if (body instanceof ArrayBuffer) {\n return new Uint8Array(body)\n }\n\n if (typeof body === 'string') {\n return new TextEncoder().encode(body)\n }\n\n if (body instanceof Blob) {\n const buffer = await body.arrayBuffer()\n return new Uint8Array(buffer)\n }\n\n if (body instanceof ReadableStream) {\n return new Uint8Array(await new Response(body).arrayBuffer())\n }\n\n // FormData or URLSearchParams - convert via Response\n if (body instanceof FormData || body instanceof URLSearchParams) {\n return new Uint8Array(await new Response(body).arrayBuffer())\n }\n\n return new Uint8Array(0)\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAwBA,MAAM,sBAAsB;;AAG5B,SAAS,aAAa,MAAc,MAA8B;CAChE,OAAO;EACL;EACA,MAAM,KAAK;EACX,aAAa,KAAK;EAClB,YAAY,KAAK;EACjB,UAAU,KAAK;CACjB;AACF;AA+BO,IAAM,qBAAN,MAAM,2BAA2B,eAAe;CAKhC;CAEA;CANrB,wBAAgB,IAAI,IAAwB;CAE5C,YACE,gBAEA,SAEA;EACA,MAAM,gBAAgB,OAAO;EAJV,KAAA,iBAAA;EAEA,KAAA,UAAA;CAGrB;;;;CAKA,MAAM,OACJ,MACA,cACA,SACA,MACuB;EACvB,MAAM,UAAU,MAAM,KAAK,iBAAiB,IAAI;EAChD,MAAM,WAAW,KAAK,YAAY,IAAI;EAEtC,KAAK,MAAM,IAAI,cAAc;GAC3B;GACA,UAAU,QAAQ,YAAY;GAC9B,MAAM,QAAQ;GACd,UAAU,QAAQ;GAClB,4BAAY,IAAI,KAAK;EACvB,CAAC;EAED,OAAO;GACL,MAAM;GACN,MAAM;GACN,UAAU;GACV,MAAM,QAAQ;GACd,UAAU,QAAQ,YAAY;GAC9B,4BAAY,IAAI,KAAK;EACvB;CACF;;;;CAKA,SAAS,MAAuC;EAC9C,MAAM,OAAO,KAAK,MAAM,IAAI,IAAI;EAEhC,IAAI,CAAC,MACH,OAAO,QAAQ,OAAO,IAAI,kBAAkB,IAAI,CAAC;EAGnD,OAAO,QAAQ,QAAQ;GACrB,gBAAgB,IAAI,eAAe,EACjC,MAAM,YAAY;IAChB,WAAW,QAAQ,KAAK,OAAO;IAC/B,WAAW,MAAM;GACnB,EACF,CAAC;GACD,gBAAgB,QAAQ,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,OAAO,CAAC;GACtE,qBAAqB,QAAQ,QAAQ,KAAK,OAAO;GACjD,aAAa,KAAK;GAClB,MAAM,KAAK;GACX,UAAU,KAAK;EACjB,CAAC;CACH;;;;CAKA,OAAO,MAA6B;EAClC,KAAK,MAAM,OAAO,IAAI;EACtB,OAAO,QAAQ,QAAQ;CACzB;;;;CAKA,OAAO,MAAgC;EACrC,OAAO,QAAQ,QAAQ,KAAK,MAAM,IAAI,IAAI,CAAC;CAC7C;;;;CAQA,MAAM,WAAW,OAAiB,MAA8B;EAC9D,KAAK,YAAY,IAAI;EAErB,KAAK,MAAM,QAAQ,OACjB,KAAK,MAAM,OAAO,IAAI;EAGxB,OAAO,QAAQ,QAAQ;CACzB;;;;CAKA,MAAM,KAAK,MAAc,MAA2C;EAClE,KAAK,YAAY,IAAI;EAErB,MAAM,OAAO,KAAK,MAAM,IAAI,IAAI;EAChC,IAAI,CAAC,MACH,OAAO,QAAQ,QAAQ,IAAI;EAG7B,OAAO,QAAQ,QAAQ,aAAa,MAAM,IAAI,CAAC;CACjD;;;;;;;;;;;CAYA,MAAM,KAAK,UAAuB,CAAC,GAAG,MAAoC;EACxE,KAAK,YAAY,IAAI;EAErB,MAAM,SAAS,QAAQ,UAAU;EAGjC,MAAM,WAAW,MAAM,KAAK,KAAK,MAAM,KAAK,CAAC,CAAC,CAC3C,QAAQ,SAAS,KAAK,WAAW,MAAM,CAAC,CAAC,CACzC,KAAK;EAER,MAAM,QAAQ,QAAQ,WAAW,KAAA,IAAY,IAAI,SAAS,QAAQ,QAAQ,MAAM;EAChF,IAAI,QAAQ,GACV,OAAO,QAAQ,uBAAO,IAAI,MAAM,gCAAgC,QAAQ,UAAU,IAAI,CAAC;EAGzF,MAAM,QAAQ,QAAQ,SAAS;EAC/B,MAAM,OAAO,SAAS,MAAM,OAAO,QAAQ,KAAK;EAChD,MAAM,OAAO,SAAS,QAAQ;EAE9B,OAAO,QAAQ,QAAQ;GACrB,SAAS,KAAK,KAAK,SAAS;IAC1B,MAAM,SAAS,aAAa,MAAM,KAAK,MAAM,IAAI,IAAI,CAAE;IACvD,IAAI,QAAQ,iBACV,OAAO;IAMT,OAAO;KAAE,GAAG;KAAQ,aAAa,KAAA;KAAW,UAAU,KAAA;IAAU;GAClE,CAAC;GACD,WAAW,SAAS,KAAA;GACpB,QAAQ;EACV,CAAC;CACH;;;;CAKA,wBACE,MACA,WAC6B;EAC7B,OAAO,QAAQ,QAAQ,KAAK,mBAAmB,MAAM,OAAO,SAAS,CAAC;CACxE;;;;CAKA,sBACE,MACA,WAC6B;EAC7B,OAAO,QAAQ,QAAQ,KAAK,mBAAmB,MAAM,OAAO,SAAS,CAAC;CACxE;;;;CAKA,sBACE,MACA,WAC6B;EAC7B,OAAO,QAAQ,QAAQ,KAAK,mBAAmB,MAAM,UAAU,SAAS,CAAC;CAC3E;;;;CAKA,MAAM,cACJ,MACA,MACA,SACA,MACuB;EACvB,MAAM,UAAU,MAAM,KAAK,iBAAiB,IAAI;EAChD,MAAM,OAAO,QAAQ,QAAQ,QAAQ;EAKrC,OAAO,KAAK,OAAO,SAAS,MAAM;GAAE,GAAG;GAAS;EAAK,GAAG,IAAI;CAC9D;;;;;;;CAYA,aAAa,MAAoB;EAC/B,OACE,KAAK,MAAM,IAAI,IAAI,GACnB,8BAA8B,KAAK,kBAAkB,KAAK,eAAe,CAAC,CAAC,KAAK,IAAI,KAAK,UAC3F,CAAC,CAAC,KAAK,IAAI;CACb;;;;;;;CAQA,cAAc,MAAoB;EAChC,OACE,KAAK,MAAM,IAAI,IAAI,GACnB,kCAAkC,MACpC,CAAC,CAAC,KAAK,KAAK;CACd;;;;;;CAOA,cAAoB;EAClB,OACE,KAAK,MAAM,MACX,0CAA0C,KAAK,MAAM,KAAK,UAAU,KAAK,eAAe,CAAC,CAAC,KAAK,IAAI,GACrG,CAAC,CAAC,KAAK,CAAC;CACV;;;;;;;CAQA,YAAY,OAAqB;EAC/B,OACE,KAAK,MAAM,MACX,YAAY,MAAM,8BAA8B,KAAK,MAAM,MAC7D,CAAC,CAAC,KAAK,KAAK;CACd;;;;CAKA,iBAA0C;EACxC,OAAO,IAAI,IAAI,KAAK,KAAK;CAC3B;;;;CAKA,iBAA2B;EACzB,OAAO,MAAM,KAAK,KAAK,MAAM,KAAK,CAAC;CACrC;;;;CAKA,QAAQ,MAAsC;EAC5C,OAAO,KAAK,MAAM,IAAI,IAAI;CAC5B;;;;CAKA,QAAc;EACZ,KAAK,MAAM,MAAM;CACnB;CAMA,mBACE,MACA,QACA,YAAY,KACQ;EACpB,MAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;EAExD,OAAO;GACL,KAAK,6BAA6B,KAAK,UAAU,OAAO,WAAW,UAAU,YAAY;GACzF;GACA;GACA;EACF;CACF;CAEA,MAAc,iBAAiB,MAA8E;EAC3G,IAAI,CAAC,MACH,uBAAO,IAAI,WAAW,CAAC;EAGzB,IAAI,gBAAgB,YAClB,OAAO;EAGT,IAAI,gBAAgB,aAClB,OAAO,IAAI,WAAW,IAAI;EAG5B,IAAI,OAAO,SAAS,UAClB,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,IAAI;EAGtC,IAAI,gBAAgB,MAAM;GACxB,MAAM,SAAS,MAAM,KAAK,YAAY;GACtC,OAAO,IAAI,WAAW,MAAM;EAC9B;EAEA,IAAI,gBAAgB,gBAClB,OAAO,IAAI,WAAW,MAAM,IAAI,SAAS,IAAI,CAAC,CAAC,YAAY,CAAC;EAI9D,IAAI,gBAAgB,YAAY,gBAAgB,iBAC9C,OAAO,IAAI,WAAW,MAAM,IAAI,SAAS,IAAI,CAAC,CAAC,YAAY,CAAC;EAG9D,uBAAO,IAAI,WAAW,CAAC;CACzB;AACF;;CAtVC,UAAU,eAAe,cAAc;CAKnC,gBAAA,GAAA,OAAO,eAAe,cAAc,CAAA;CAEpC,gBAAA,GAAA,OAAO,eAAe,OAAO,CAAA"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { PurgeSpec, WorkersCache } from "stratal/response-cache";
|
|
2
|
+
import { EmailBatchSendResult, EmailSendResult, IEmailProvider, ResolvedEmailMessage } from "stratal/email";
|
|
3
|
+
//#region src/mocks/test-email-provider.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* In-memory email provider for tests.
|
|
6
|
+
*
|
|
7
|
+
* The sync queue provider runs `EmailConsumer` inline on dispatch, which would
|
|
8
|
+
* otherwise open a real SMTP connection from the test worker. The testing
|
|
9
|
+
* module builder installs this provider by default (overridable via
|
|
10
|
+
* `overrideProvider(EMAIL_TOKENS.EmailProviderFactory)`), recording every
|
|
11
|
+
* message so tests can assert on what was sent.
|
|
12
|
+
*/
|
|
13
|
+
declare class TestEmailProvider implements IEmailProvider {
|
|
14
|
+
/** Every message handed to the provider, in send order. */
|
|
15
|
+
readonly sent: ResolvedEmailMessage[];
|
|
16
|
+
send(message: ResolvedEmailMessage): Promise<EmailSendResult>;
|
|
17
|
+
sendBatch(messages: ResolvedEmailMessage[]): Promise<EmailBatchSendResult>;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/mocks/test-workers-cache.d.ts
|
|
21
|
+
/**
|
|
22
|
+
* In-memory stand-in for Cloudflare Workers Caching's `ctx.cache`.
|
|
23
|
+
*
|
|
24
|
+
* Neither Miniflare nor workerd populates `ExecutionContext.cache` locally,
|
|
25
|
+
* which would otherwise leave every `@Cacheable` route in a test serving
|
|
26
|
+
* uncached and stamped `private, no-store` — so a spec asserting the headers
|
|
27
|
+
* the route declares would read the ones an unconfigured runtime produces. The
|
|
28
|
+
* testing module builder installs this by default so those routes are testable
|
|
29
|
+
* with zero configuration: `@Cacheable` responses carry real
|
|
30
|
+
* `Cache-Control`/`Cache-Tag` headers, and `@PurgesCache` purges are recorded
|
|
31
|
+
* rather than skipped.
|
|
32
|
+
*
|
|
33
|
+
* Every spec passed to `purge()` is recorded, in call order, for assertion
|
|
34
|
+
* via `module.cache.purges`. Purges always succeed — this stub only proves an
|
|
35
|
+
* app's cache configuration is correct, not Workers Caching's own failure
|
|
36
|
+
* modes (that's covered inside `stratal` itself, against a mocked
|
|
37
|
+
* `WorkersCache`). Opt out with `Test.createTestingModule({ cache: false })`
|
|
38
|
+
* to reproduce a runtime where Workers Caching is genuinely unconfigured —
|
|
39
|
+
* e.g. to test the `ResponseCacheConfigError` boot guard.
|
|
40
|
+
*/
|
|
41
|
+
declare class TestWorkersCache implements WorkersCache {
|
|
42
|
+
/** Every spec passed to `purge()`, in call order. */
|
|
43
|
+
readonly purges: PurgeSpec[];
|
|
44
|
+
purge(spec: PurgeSpec): Promise<{
|
|
45
|
+
success: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/mocks/test-worker-exports.d.ts
|
|
50
|
+
/** One `ctx.exports.<Name>.fetch()` the gateway made, recorded for assertion. */
|
|
51
|
+
interface LoopbackCall {
|
|
52
|
+
/** The export name the gateway dispatched to — `gateway: { entrypoint }`. */
|
|
53
|
+
entrypoint: string;
|
|
54
|
+
method: string;
|
|
55
|
+
url: string;
|
|
56
|
+
/** The resolved partitions the gateway placed in `ctx.props`. */
|
|
57
|
+
props: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
/** Runs a loopback in cached mode. Installed by the testing module builder. */
|
|
60
|
+
type LoopbackHandler = (request: Request, props: Record<string, unknown>) => Promise<Response>;
|
|
61
|
+
/**
|
|
62
|
+
* In-memory stand-in for `ctx.exports` — the loopback bindings a Worker has to
|
|
63
|
+
* its own top-level exports.
|
|
64
|
+
*
|
|
65
|
+
* Neither Miniflare nor workerd populates `ExecutionContext.exports` for a
|
|
66
|
+
* `Test.createTestingModule()` app, so without this every consumer that adopts
|
|
67
|
+
* `ResponseCacheModule.forRoot({ gateway: { entrypoint } })` would have their
|
|
68
|
+
* whole suite fail on the first request: the gateway's boot verification
|
|
69
|
+
* throws `ResponseCacheConfigError` the moment it cannot reach the configured
|
|
70
|
+
* entrypoint. That is the same usability defect {@link TestWorkersCache} fixes
|
|
71
|
+
* for `ctx.cache`, and it is fixed the same way — installed by default, opt out
|
|
72
|
+
* with `Test.createTestingModule({ cache: false })`.
|
|
73
|
+
*
|
|
74
|
+
* The stub is not inert. A dispatched request is re-run through the *same*
|
|
75
|
+
* Hono app with an unmarked execution context carrying the gateway's chosen
|
|
76
|
+
* `props` — exactly what the real cached entrypoint does — so a partitioned
|
|
77
|
+
* route is genuinely exercised end to end rather than short-circuited. Every
|
|
78
|
+
* call is recorded, in order, for assertion via `module.gateway.loopbacks`,
|
|
79
|
+
* mirroring `module.cache.purges`:
|
|
80
|
+
*
|
|
81
|
+
* ```typescript
|
|
82
|
+
* await module.http.get('/dashboard').withHeaders({ ... }).send()
|
|
83
|
+
* expect(module.gateway.loopbacks).toEqual([
|
|
84
|
+
* { entrypoint: 'Cached', method: 'GET', url: '...', props: { user: 'u-1' } },
|
|
85
|
+
* ])
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* An empty `loopbacks` after a request to a partitioned route is itself the
|
|
89
|
+
* assertion for the fail-closed cases — an unresolved partition, a non-GET, a
|
|
90
|
+
* route with no `partitionBy` — all of which must run inline.
|
|
91
|
+
*
|
|
92
|
+
* Purges forwarded over RPC (`ctx.exports.<Name>.purge(spec)`) land on the same
|
|
93
|
+
* {@link TestWorkersCache} as a direct `ctx.cache.purge()`, so
|
|
94
|
+
* `module.cache.purges` reads identically whether or not a gateway is
|
|
95
|
+
* configured.
|
|
96
|
+
*/
|
|
97
|
+
declare class TestWorkerExports {
|
|
98
|
+
private readonly cache;
|
|
99
|
+
/** Every loopback dispatch, in call order. */
|
|
100
|
+
readonly loopbacks: LoopbackCall[];
|
|
101
|
+
private handler;
|
|
102
|
+
private readonly stubs;
|
|
103
|
+
constructor(cache: TestWorkersCache | undefined);
|
|
104
|
+
/**
|
|
105
|
+
* Install the function that actually serves a loopback.
|
|
106
|
+
*
|
|
107
|
+
* Set after the `Application` exists, because the handler needs its Hono
|
|
108
|
+
* app — while the execution context carrying this stub has to be built
|
|
109
|
+
* before it, to be passed to the constructor.
|
|
110
|
+
*
|
|
111
|
+
* @internal
|
|
112
|
+
*/
|
|
113
|
+
setHandler(handler: LoopbackHandler): void;
|
|
114
|
+
/**
|
|
115
|
+
* The object to install as `ctx.exports`.
|
|
116
|
+
*
|
|
117
|
+
* A `Proxy` rather than a fixed record because the export name is the
|
|
118
|
+
* consumer's to choose — the framework only ever sees the string in
|
|
119
|
+
* `gateway: { entrypoint }`. Answering for every name keeps a typo'd
|
|
120
|
+
* entrypoint from being caught here, in a stub, instead of in the
|
|
121
|
+
* consumer's real Wrangler config where it matters; that trade is
|
|
122
|
+
* deliberate, since a suite failing on a name the test never mentions is
|
|
123
|
+
* far more confusing than a deploy-time error.
|
|
124
|
+
*
|
|
125
|
+
* A typo is meant to be caught earlier still: `gateway.entrypoint` is typed
|
|
126
|
+
* as `CachedEntrypointName`, so once the consumer has run `wrangler types` a
|
|
127
|
+
* wrong name is a compile error at the config site — before this stub, and
|
|
128
|
+
* before deploy. This Proxy is the runtime fallback for projects without
|
|
129
|
+
* generated types, not the primary guard.
|
|
130
|
+
*/
|
|
131
|
+
get context(): Record<string, unknown>;
|
|
132
|
+
/**
|
|
133
|
+
* A loopback binding, shaped like the real one:
|
|
134
|
+
* `Fetcher<T> & ((opts: { props }) => Fetcher<T>)`. Both the callable form
|
|
135
|
+
* (which is how `ctx.props` is chosen) and the direct form are supported,
|
|
136
|
+
* so whichever the framework uses is genuinely exercised.
|
|
137
|
+
*/
|
|
138
|
+
private stubFor;
|
|
139
|
+
private dispatch;
|
|
140
|
+
private purge;
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
export { TestEmailProvider as i, TestWorkerExports as n, TestWorkersCache as r, LoopbackCall as t };
|
|
144
|
+
//# sourceMappingURL=test-worker-exports-dCRARYEc.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-worker-exports-dCRARYEc.d.mts","names":[],"sources":["../src/mocks/test-email-provider.ts","../src/mocks/test-workers-cache.ts","../src/mocks/test-worker-exports.ts"],"mappings":";;;;;;;;;;;;cAgBa,6BAA6B;;WAEhC,MAAM;EAEf,KAAK,SAAS,uBAAuB,QAAQ;EAKvC,UAAU,UAAU,yBAAyB,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;cCH/C,4BAA4B;;WAE9B,QAAQ;EAEjB,MAAM,MAAM,YAAY;IAAU;;;;;;UCtBnB;;EAEf;EACA;EACA;;EAEA,OAAO;;;KAIG,mBACV,SAAS,SACT,OAAO,4BACJ,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAsCA;mBAOkB;;WALpB,WAAW;UAEZ;mBACS;EAEjB,YAA6B,OAAO;;;;;;;;;;EAWpC,WAAW,SAAS;;;;;;;;;;;;;;;;;;MAqBhB,WAAW;;;;;;;UAmBP;UAkBM;UAiBA"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
//#region src/mocks/test-email-provider.ts
|
|
2
|
+
/**
|
|
3
|
+
* In-memory email provider for tests.
|
|
4
|
+
*
|
|
5
|
+
* The sync queue provider runs `EmailConsumer` inline on dispatch, which would
|
|
6
|
+
* otherwise open a real SMTP connection from the test worker. The testing
|
|
7
|
+
* module builder installs this provider by default (overridable via
|
|
8
|
+
* `overrideProvider(EMAIL_TOKENS.EmailProviderFactory)`), recording every
|
|
9
|
+
* message so tests can assert on what was sent.
|
|
10
|
+
*/
|
|
11
|
+
var TestEmailProvider = class {
|
|
12
|
+
/** Every message handed to the provider, in send order. */
|
|
13
|
+
sent = [];
|
|
14
|
+
send(message) {
|
|
15
|
+
this.sent.push(message);
|
|
16
|
+
return Promise.resolve({
|
|
17
|
+
messageId: `test-${this.sent.length}`,
|
|
18
|
+
accepted: true
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async sendBatch(messages) {
|
|
22
|
+
const results = await Promise.all(messages.map((message) => this.send(message)));
|
|
23
|
+
return {
|
|
24
|
+
total: results.length,
|
|
25
|
+
successful: results.length,
|
|
26
|
+
failed: 0,
|
|
27
|
+
results
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/mocks/test-worker-exports.ts
|
|
33
|
+
/**
|
|
34
|
+
* In-memory stand-in for `ctx.exports` — the loopback bindings a Worker has to
|
|
35
|
+
* its own top-level exports.
|
|
36
|
+
*
|
|
37
|
+
* Neither Miniflare nor workerd populates `ExecutionContext.exports` for a
|
|
38
|
+
* `Test.createTestingModule()` app, so without this every consumer that adopts
|
|
39
|
+
* `ResponseCacheModule.forRoot({ gateway: { entrypoint } })` would have their
|
|
40
|
+
* whole suite fail on the first request: the gateway's boot verification
|
|
41
|
+
* throws `ResponseCacheConfigError` the moment it cannot reach the configured
|
|
42
|
+
* entrypoint. That is the same usability defect {@link TestWorkersCache} fixes
|
|
43
|
+
* for `ctx.cache`, and it is fixed the same way — installed by default, opt out
|
|
44
|
+
* with `Test.createTestingModule({ cache: false })`.
|
|
45
|
+
*
|
|
46
|
+
* The stub is not inert. A dispatched request is re-run through the *same*
|
|
47
|
+
* Hono app with an unmarked execution context carrying the gateway's chosen
|
|
48
|
+
* `props` — exactly what the real cached entrypoint does — so a partitioned
|
|
49
|
+
* route is genuinely exercised end to end rather than short-circuited. Every
|
|
50
|
+
* call is recorded, in order, for assertion via `module.gateway.loopbacks`,
|
|
51
|
+
* mirroring `module.cache.purges`:
|
|
52
|
+
*
|
|
53
|
+
* ```typescript
|
|
54
|
+
* await module.http.get('/dashboard').withHeaders({ ... }).send()
|
|
55
|
+
* expect(module.gateway.loopbacks).toEqual([
|
|
56
|
+
* { entrypoint: 'Cached', method: 'GET', url: '...', props: { user: 'u-1' } },
|
|
57
|
+
* ])
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* An empty `loopbacks` after a request to a partitioned route is itself the
|
|
61
|
+
* assertion for the fail-closed cases — an unresolved partition, a non-GET, a
|
|
62
|
+
* route with no `partitionBy` — all of which must run inline.
|
|
63
|
+
*
|
|
64
|
+
* Purges forwarded over RPC (`ctx.exports.<Name>.purge(spec)`) land on the same
|
|
65
|
+
* {@link TestWorkersCache} as a direct `ctx.cache.purge()`, so
|
|
66
|
+
* `module.cache.purges` reads identically whether or not a gateway is
|
|
67
|
+
* configured.
|
|
68
|
+
*/
|
|
69
|
+
var TestWorkerExports = class {
|
|
70
|
+
cache;
|
|
71
|
+
/** Every loopback dispatch, in call order. */
|
|
72
|
+
loopbacks = [];
|
|
73
|
+
handler;
|
|
74
|
+
stubs = /* @__PURE__ */ new Map();
|
|
75
|
+
constructor(cache) {
|
|
76
|
+
this.cache = cache;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Install the function that actually serves a loopback.
|
|
80
|
+
*
|
|
81
|
+
* Set after the `Application` exists, because the handler needs its Hono
|
|
82
|
+
* app — while the execution context carrying this stub has to be built
|
|
83
|
+
* before it, to be passed to the constructor.
|
|
84
|
+
*
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
setHandler(handler) {
|
|
88
|
+
this.handler = handler;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The object to install as `ctx.exports`.
|
|
92
|
+
*
|
|
93
|
+
* A `Proxy` rather than a fixed record because the export name is the
|
|
94
|
+
* consumer's to choose — the framework only ever sees the string in
|
|
95
|
+
* `gateway: { entrypoint }`. Answering for every name keeps a typo'd
|
|
96
|
+
* entrypoint from being caught here, in a stub, instead of in the
|
|
97
|
+
* consumer's real Wrangler config where it matters; that trade is
|
|
98
|
+
* deliberate, since a suite failing on a name the test never mentions is
|
|
99
|
+
* far more confusing than a deploy-time error.
|
|
100
|
+
*
|
|
101
|
+
* A typo is meant to be caught earlier still: `gateway.entrypoint` is typed
|
|
102
|
+
* as `CachedEntrypointName`, so once the consumer has run `wrangler types` a
|
|
103
|
+
* wrong name is a compile error at the config site — before this stub, and
|
|
104
|
+
* before deploy. This Proxy is the runtime fallback for projects without
|
|
105
|
+
* generated types, not the primary guard.
|
|
106
|
+
*/
|
|
107
|
+
get context() {
|
|
108
|
+
return new Proxy({}, {
|
|
109
|
+
get: (_target, property) => typeof property === "string" ? this.stubFor(property) : void 0,
|
|
110
|
+
has: () => true,
|
|
111
|
+
ownKeys: () => [...this.stubs.keys()],
|
|
112
|
+
getOwnPropertyDescriptor: () => ({
|
|
113
|
+
enumerable: true,
|
|
114
|
+
configurable: true
|
|
115
|
+
})
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A loopback binding, shaped like the real one:
|
|
120
|
+
* `Fetcher<T> & ((opts: { props }) => Fetcher<T>)`. Both the callable form
|
|
121
|
+
* (which is how `ctx.props` is chosen) and the direct form are supported,
|
|
122
|
+
* so whichever the framework uses is genuinely exercised.
|
|
123
|
+
*/
|
|
124
|
+
stubFor(entrypoint) {
|
|
125
|
+
const existing = this.stubs.get(entrypoint);
|
|
126
|
+
if (existing) return existing;
|
|
127
|
+
const bound = (props) => ({
|
|
128
|
+
fetch: (request) => this.dispatch(entrypoint, request, props),
|
|
129
|
+
purge: (spec) => this.purge(spec)
|
|
130
|
+
});
|
|
131
|
+
const stub = Object.assign((options) => bound(options?.props ?? {}), bound({}));
|
|
132
|
+
this.stubs.set(entrypoint, stub);
|
|
133
|
+
return stub;
|
|
134
|
+
}
|
|
135
|
+
async dispatch(entrypoint, request, props) {
|
|
136
|
+
this.loopbacks.push({
|
|
137
|
+
entrypoint,
|
|
138
|
+
method: request.method,
|
|
139
|
+
url: request.url,
|
|
140
|
+
props
|
|
141
|
+
});
|
|
142
|
+
if (!this.handler) throw new Error("[stratal:testing] A gateway loopback was dispatched before the testing module finished compiling. This is a bug in @stratal/testing — please report it.");
|
|
143
|
+
return this.handler(request, props);
|
|
144
|
+
}
|
|
145
|
+
async purge(spec) {
|
|
146
|
+
await this.cache?.purge(spec);
|
|
147
|
+
return { success: true };
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/mocks/test-workers-cache.ts
|
|
152
|
+
/**
|
|
153
|
+
* In-memory stand-in for Cloudflare Workers Caching's `ctx.cache`.
|
|
154
|
+
*
|
|
155
|
+
* Neither Miniflare nor workerd populates `ExecutionContext.cache` locally,
|
|
156
|
+
* which would otherwise leave every `@Cacheable` route in a test serving
|
|
157
|
+
* uncached and stamped `private, no-store` — so a spec asserting the headers
|
|
158
|
+
* the route declares would read the ones an unconfigured runtime produces. The
|
|
159
|
+
* testing module builder installs this by default so those routes are testable
|
|
160
|
+
* with zero configuration: `@Cacheable` responses carry real
|
|
161
|
+
* `Cache-Control`/`Cache-Tag` headers, and `@PurgesCache` purges are recorded
|
|
162
|
+
* rather than skipped.
|
|
163
|
+
*
|
|
164
|
+
* Every spec passed to `purge()` is recorded, in call order, for assertion
|
|
165
|
+
* via `module.cache.purges`. Purges always succeed — this stub only proves an
|
|
166
|
+
* app's cache configuration is correct, not Workers Caching's own failure
|
|
167
|
+
* modes (that's covered inside `stratal` itself, against a mocked
|
|
168
|
+
* `WorkersCache`). Opt out with `Test.createTestingModule({ cache: false })`
|
|
169
|
+
* to reproduce a runtime where Workers Caching is genuinely unconfigured —
|
|
170
|
+
* e.g. to test the `ResponseCacheConfigError` boot guard.
|
|
171
|
+
*/
|
|
172
|
+
var TestWorkersCache = class {
|
|
173
|
+
/** Every spec passed to `purge()`, in call order. */
|
|
174
|
+
purges = [];
|
|
175
|
+
purge(spec) {
|
|
176
|
+
this.purges.push(spec);
|
|
177
|
+
return Promise.resolve({ success: true });
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
//#endregion
|
|
181
|
+
export { TestWorkerExports as n, TestEmailProvider as r, TestWorkersCache as t };
|
|
182
|
+
|
|
183
|
+
//# sourceMappingURL=test-workers-cache-D02Pt_dE.mjs.map
|