lambder 7.2.4 → 7.3.1

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +4 -2
  3. package/dist/api/LambderApiIdempotency.d.ts +7 -0
  4. package/dist/api/LambderApiIdempotency.js +12 -0
  5. package/dist/api/LambderApiPipeline.d.ts +30 -1
  6. package/dist/api/LambderApiPipeline.js +14 -0
  7. package/dist/api/LambderApiPolicyEngine.d.ts +11 -0
  8. package/dist/api/LambderApiPolicyEngine.js +8 -0
  9. package/dist/api/LambderApiRateLimits.d.ts +7 -0
  10. package/dist/api/LambderApiRateLimits.js +12 -0
  11. package/dist/core/Lambder.d.ts +35 -1
  12. package/dist/core/Lambder.js +32 -1
  13. package/dist/core/LambderFiles.d.ts +7 -0
  14. package/dist/core/LambderFiles.js +12 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/invoke/LambderLambdaEvent.d.ts +23 -9
  17. package/dist/invoke/LambderLambdaEvent.js +41 -16
  18. package/dist/invoke/lambderHandlerTransport.d.ts +3 -0
  19. package/dist/invoke/lambderHandlerTransport.js +1 -1
  20. package/dist/mock.d.ts +2 -0
  21. package/dist/mock.js +3 -0
  22. package/dist/session/LambderSessionManager.d.ts +12 -1
  23. package/dist/session/LambderSessionManager.js +19 -3
  24. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  25. package/dist/shared/util/LambderTestingDoors.js +29 -0
  26. package/dist/shared/wire/LambderApiContract.d.ts +50 -0
  27. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +80 -0
  28. package/dist/shared/wire/LambderOutcomeAssertions.js +113 -0
  29. package/dist/testing/LambderTestApp.d.ts +178 -0
  30. package/dist/testing/LambderTestApp.js +206 -0
  31. package/dist/testing/LambderTestVisitor.d.ts +155 -0
  32. package/dist/testing/LambderTestVisitor.js +154 -0
  33. package/dist/testing.d.ts +26 -0
  34. package/dist/testing.js +23 -0
  35. package/package.json +9 -1
@@ -31,18 +31,7 @@ const randomRequestId = () => {
31
31
  return webCrypto.randomUUID();
32
32
  return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
33
33
  };
34
- /**
35
- * The payload-format-2.0 event API Gateway would deliver for this request.
36
- * `invoke: true` adds the invoke marker headers a server-to-server call
37
- * carries; a browser-shaped request (the handler transport) leaves them off.
38
- *
39
- * The client address is `clientIp` and reaches the callee as
40
- * requestContext.http.sourceIp only. Writing it as x-forwarded-for as well
41
- * would put the same fact on a channel a callee may be configured to trust
42
- * (trustedClientIpHeaders), and the header is the one the caller's own
43
- * `headers` could otherwise have set.
44
- */
45
- export const synthesizeLambdaHttpEvent = (request, options) => {
34
+ export function synthesizeLambdaHttpEvent(request, options) {
46
35
  // The caller's own headers go on first, so the ones this function owns
47
36
  // cannot be displaced by them. Forwarding an incoming browser request's
48
37
  // headers into `headers` is an ordinary gateway-lambda pattern, and with
@@ -72,7 +61,45 @@ export const synthesizeLambdaHttpEvent = (request, options) => {
72
61
  if (request.body !== undefined && !headers["content-type"]) {
73
62
  headers["content-type"] = isBinary ? "application/octet-stream" : "application/json";
74
63
  }
64
+ const body = request.body === undefined ? undefined : isBinary ? bytesToBase64(request.body) : request.body;
75
65
  const now = Date.now();
66
+ if (options.eventFormat === "v1") {
67
+ // A REST API has no cookies array: cookies ride in the Cookie header,
68
+ // and every header is delivered twice, once as its last value and
69
+ // once as the list of all of them.
70
+ if (request.cookies?.length)
71
+ headers.cookie = request.cookies.join("; ");
72
+ const query = request.query && Object.keys(request.query).length ? request.query : null;
73
+ return {
74
+ resource: "/{proxy+}",
75
+ path: request.path,
76
+ httpMethod: request.method,
77
+ headers,
78
+ multiValueHeaders: Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, [value]])),
79
+ queryStringParameters: query,
80
+ multiValueQueryStringParameters: query ? Object.fromEntries(Object.entries(query).map(([name, value]) => [name, [value]])) : null,
81
+ pathParameters: null,
82
+ stageVariables: null,
83
+ // The fields a handler might read, filled plausibly; the rest of
84
+ // a REST API's request context (authorizer, the API key, the
85
+ // Cognito identity) describes a deployment this event has none of.
86
+ requestContext: {
87
+ accountId: "",
88
+ apiId: "lambder-local",
89
+ domainName: request.host,
90
+ httpMethod: request.method,
91
+ identity: { sourceIp: request.clientIp ?? "", userAgent: "lambder-local" },
92
+ path: request.path,
93
+ protocol: "HTTP/1.1",
94
+ requestId: randomRequestId(),
95
+ requestTimeEpoch: now,
96
+ resourcePath: "/{proxy+}",
97
+ stage: "local",
98
+ },
99
+ body: body ?? null,
100
+ isBase64Encoded: isBinary,
101
+ };
102
+ }
76
103
  return {
77
104
  version: "2.0",
78
105
  routeKey: "$default",
@@ -98,12 +125,10 @@ export const synthesizeLambdaHttpEvent = (request, options) => {
98
125
  time: new Date(now).toISOString(),
99
126
  timeEpoch: now,
100
127
  },
101
- ...(request.body !== undefined
102
- ? { body: isBinary ? bytesToBase64(request.body) : request.body }
103
- : {}),
128
+ ...(body !== undefined ? { body } : {}),
104
129
  isBase64Encoded: isBinary,
105
130
  };
106
- };
131
+ }
107
132
  /**
108
133
  * The body envelope LambderCaller sends, minus the fields only a browser has
109
134
  * a value for, as JSON. A plain payload arrives already serialized (the
@@ -1,6 +1,7 @@
1
1
  import type { Context } from "aws-lambda";
2
2
  import type { LambderApiTransport } from "../shared/transport/LambderApiTransport.js";
3
3
  import type { LambderHandler } from "../core/LambderCreateOptions.js";
4
+ import type { LambderHttpEventFormat } from "../core/LambderContext.js";
4
5
  export type LambderHandlerTransportOptions = {
5
6
  /** The Host the handler sees (ctx.host), and the siteHost the envelope carries when the caller has none. Default: the apiPath's own host when it is absolute, otherwise "localhost". */
6
7
  host?: string;
@@ -10,6 +11,8 @@ export type LambderHandlerTransportOptions = {
10
11
  maxResponseBytes?: number;
11
12
  /** Fields of the Lambda context the handler receives. */
12
13
  context?: Partial<Context>;
14
+ /** The gateway shape the handler is called with: "v2" (an HTTP API, a Function URL) or "v1" (a REST API). Default: "v2". A handler answers both alike; name the one your deployment delivers when the difference is what you are testing. */
15
+ eventFormat?: LambderHttpEventFormat;
13
16
  };
14
17
  /**
15
18
  * A transport that calls a Lambder handler in this process, the way a
@@ -50,7 +50,7 @@ export const lambderHandlerTransport = (handler, options = {}) => {
50
50
  clientIp: request.clientIp ?? clientIp,
51
51
  cookies: request.cookies,
52
52
  body: JSON.stringify(buildTransportEnvelope({ ...request, siteHost: request.siteHost || host })),
53
- }, { invoke: false });
53
+ }, { invoke: false, eventFormat: options.eventFormat });
54
54
  let result;
55
55
  try {
56
56
  result = await stopWaitingWhenAborted(handler(event, localLambdaContext("lambder-local", options.context)), request.signal);
package/dist/mock.d.ts CHANGED
@@ -26,6 +26,8 @@ export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSe
26
26
  export type { LambderSessionCrypto } from "./session/LambderSessionCrypto.js";
27
27
  export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
28
28
  export type { LambderRefusalMessage } from "./shared/wire/LambderApiRefusal.js";
29
+ export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
30
+ export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
29
31
  export type { LambderApiRequest } from "./api/LambderApiRequest.js";
30
32
  export type { LambderApiAnswer } from "./api/LambderApiAnswer.js";
31
33
  export type { LambderSessionRecord } from "./shared/contracts/LambderSessionStore.js";
package/dist/mock.js CHANGED
@@ -25,3 +25,6 @@ export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
25
25
  export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
26
26
  export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSessionCrypto.js";
27
27
  export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
28
+ // The outcome assertions a test over the mock app narrows with; the same two
29
+ // `lambder/testing` exports for a test over the real server.
30
+ export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
@@ -1,5 +1,6 @@
1
1
  import type { LambderSessionCrypto } from "./LambderSessionCrypto.js";
2
2
  import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
3
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
3
4
  /**
4
5
  * A freshly created (or regenerated) session: the persisted record plus the
5
6
  * RAW cookie secrets, which exist only here and in the cookies the caller
@@ -92,13 +93,23 @@ export declare const isMintedSessionToken: (token: string) => boolean;
92
93
  * stored too.
93
94
  */
94
95
  export default class LambderSessionManager<SessionData = any> {
95
- private readonly store;
96
+ /** Replaceable through the backend swap alone; see LAMBDER_BACKEND_SWAP. */
97
+ private store;
96
98
  private readonly sessionSalt;
97
99
  private readonly enableSlidingExpiration;
98
100
  private readonly slidingWriteIntervalSeconds;
99
101
  private readonly dataRefresh;
100
102
  private readonly crypto;
101
103
  constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, crypto, }: LambderSessionManagerOptions<SessionData>);
104
+ /** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
105
+ private assertCryptoFitsStore;
106
+ /**
107
+ * Puts the manager over another store, for `lambder/testing`. The model
108
+ * (salt, tokens, expiry, dataRefresh) stays this manager's own, so a test
109
+ * runs the app's sessions as configured over a store that dies with the
110
+ * process. Sessions held by the store it leaves are simply out of reach.
111
+ */
112
+ [LAMBDER_BACKEND_SWAP](store: LambderSessionStore<SessionData>): void;
102
113
  /**
103
114
  * The salted partition hash of a sessionKey: sha256 of the key followed
104
115
  * by the salt, with NO separator between them.
@@ -1,6 +1,7 @@
1
1
  import { LambderWebCrypto } from "./LambderSessionCrypto.js";
2
2
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
3
3
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
4
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
4
5
  /**
5
6
  * Wraps errors thrown by the dataRefresh callback so they stay
6
7
  * distinguishable from "no session": fetchSessionIfExists() swallows missing
@@ -79,6 +80,7 @@ export const isMintedSessionToken = (token) => {
79
80
  * stored too.
80
81
  */
81
82
  export default class LambderSessionManager {
83
+ /** Replaceable through the backend swap alone; see LAMBDER_BACKEND_SWAP. */
82
84
  store;
83
85
  sessionSalt;
84
86
  enableSlidingExpiration;
@@ -96,14 +98,28 @@ export default class LambderSessionManager {
96
98
  assertPositiveInteger(dataRefresh.ttlSeconds, "session.dataRefresh.ttlSeconds");
97
99
  this.dataRefresh = dataRefresh ?? null;
98
100
  this.crypto = crypto ?? new LambderWebCrypto();
101
+ this.assertCryptoFitsStore(store);
102
+ if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
103
+ throw new Error("Lambder: session sessionSalt is empty. It salts the hash that partitions the store, so it has to be a real, stable secret.");
104
+ }
105
+ }
106
+ /** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
107
+ assertCryptoFitsStore(store) {
99
108
  if (!this.crypto.isCryptographic && !store.isMemoryOnly) {
100
109
  throw new Error("Lambder: this session crypto does not hash and does not draw cryptographically random bytes, " +
101
110
  "so it may only sit in front of a store that dies with the process. Over a persistent store every " +
102
111
  "record would be a usable credential and the sessionSalt would be readable from it.");
103
112
  }
104
- if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
105
- throw new Error("Lambder: session sessionSalt is empty. It salts the hash that partitions the store, so it has to be a real, stable secret.");
106
- }
113
+ }
114
+ /**
115
+ * Puts the manager over another store, for `lambder/testing`. The model
116
+ * (salt, tokens, expiry, dataRefresh) stays this manager's own, so a test
117
+ * runs the app's sessions as configured over a store that dies with the
118
+ * process. Sessions held by the store it leaves are simply out of reach.
119
+ */
120
+ [LAMBDER_BACKEND_SWAP](store) {
121
+ this.assertCryptoFitsStore(store);
122
+ this.store = store;
107
123
  }
108
124
  /**
109
125
  * The salted partition hash of a sessionKey: sha256 of the key followed
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The keys of the two doors `lambder/testing` opens on a built instance.
3
+ *
4
+ * Symbols rather than named methods, and exported by no entry point: the
5
+ * package's exports map is what a consumer can import, so only
6
+ * `lambder/testing` can name a key, and nothing on the typed surface of an
7
+ * instance offers either door to a serving app.
8
+ */
9
+ /**
10
+ * Puts other stores under the instance.
11
+ *
12
+ * An app is one module-level instance whose handlers and guards close over it
13
+ * (`app.getSessionController(ctx)`), so a test cannot be handed a copy over
14
+ * other stores: the copy's closures would still reach the original and the
15
+ * production table under it. The stores are therefore replaced in place, and
16
+ * every class that holds one answers to this key with a method that takes the
17
+ * replacement.
18
+ */
19
+ export declare const LAMBDER_BACKEND_SWAP: unique symbol;
20
+ /**
21
+ * Watches what the instance throws while answering a request.
22
+ *
23
+ * A crash is answered by the app's global error handler, or by the framework's
24
+ * own 500, and either way the answer deliberately says nothing about what was
25
+ * thrown: that is right for a client and useless to a test, whose author needs
26
+ * the error and its stack. The watcher is handed the error and changes nothing
27
+ * about the answer, so what an app does with a crash stays testable.
28
+ */
29
+ export declare const LAMBDER_CRASH_WATCH: unique symbol;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The keys of the two doors `lambder/testing` opens on a built instance.
3
+ *
4
+ * Symbols rather than named methods, and exported by no entry point: the
5
+ * package's exports map is what a consumer can import, so only
6
+ * `lambder/testing` can name a key, and nothing on the typed surface of an
7
+ * instance offers either door to a serving app.
8
+ */
9
+ /**
10
+ * Puts other stores under the instance.
11
+ *
12
+ * An app is one module-level instance whose handlers and guards close over it
13
+ * (`app.getSessionController(ctx)`), so a test cannot be handed a copy over
14
+ * other stores: the copy's closures would still reach the original and the
15
+ * production table under it. The stores are therefore replaced in place, and
16
+ * every class that holds one answers to this key with a method that takes the
17
+ * replacement.
18
+ */
19
+ export const LAMBDER_BACKEND_SWAP = Symbol("lambder.backendSwap");
20
+ /**
21
+ * Watches what the instance throws while answering a request.
22
+ *
23
+ * A crash is answered by the app's global error handler, or by the framework's
24
+ * own 500, and either way the answer deliberately says nothing about what was
25
+ * thrown: that is right for a client and useless to a test, whose author needs
26
+ * the error and its stack. The watcher is handed the error and changes nothing
27
+ * about the answer, so what an app does with a crash stays testable.
28
+ */
29
+ export const LAMBDER_CRASH_WATCH = Symbol("lambder.crashWatch");
@@ -85,6 +85,56 @@ export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInpu
85
85
  export type LambderMergeContract<Old, Name extends string, Entry> = Old & {
86
86
  [K in Name]: Entry;
87
87
  };
88
+ /**
89
+ * The contract as one object type, for the `export interface` a consuming
90
+ * app declares its contract through:
91
+ *
92
+ * ```ts
93
+ * export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
94
+ * ```
95
+ *
96
+ * Chaining leaves the contract an intersection one member deep per endpoint
97
+ * (LambderMergeContract above), and every `C[K]` written against a type
98
+ * parameter then resolves the property across all of them. That lookup is
99
+ * the atom the reading helpers below are built from, so its cost is paid
100
+ * again by each of them, per endpoint, in every app that registers a mock,
101
+ * declares a needs map, or otherwise reads the contract generically: in a
102
+ * 182-endpoint app one indexed access measured ~3,000 type instantiations
103
+ * and one mock registration ~18,000.
104
+ *
105
+ * Extending an interface is what collapses it. An interface's members are
106
+ * declared, so they are resolved once for the whole declaration rather than
107
+ * per lookup, and the same access measured ~6 instantiations after the
108
+ * change: a 182-endpoint app's frontend type check went from 27.8M
109
+ * instantiations to 7.0M and from 20.2s to 10.6s of check time. The alias
110
+ * form (`type C = LambderFlattenContract<...>`) does NOT do this: a mapped
111
+ * type stays deferred and each lookup pays the full cost again, so the
112
+ * `interface ... extends` spelling is the point.
113
+ *
114
+ * Diagnostics are the same ones, and they read better: a message naming the
115
+ * contract prints the interface by name, where the intersection is printed
116
+ * as a truncated spill of entries.
117
+ *
118
+ * Every endpoint name must be a string literal for an interface to extend
119
+ * the result, which registration through addApi/addSessionApi guarantees.
120
+ *
121
+ * Two things quietly undo it, both of which look like tidying:
122
+ *
123
+ * - `@typescript-eslint/no-empty-object-type` reports the empty body as
124
+ * "equivalent to its supertype" and its fix is a type alias, which is the
125
+ * one spelling that does not collapse anything. Disable the rule on the
126
+ * line rather than taking the fix.
127
+ * - Extending anything but a mapped type loses the inferable index signature.
128
+ * An interface has none of its own, so a hand-written `interface C { ... }`
129
+ * is not assignable to LambderApiContractShape and is rejected by
130
+ * initLambderMock<C>, LambderCaller<C> and LambderInvokeCaller<C>;
131
+ * extending this mapped type is what keeps it. api-contract.test.ts pins
132
+ * that, along with the flattened contract being the same type member for
133
+ * member.
134
+ */
135
+ export type LambderFlattenContract<C> = {
136
+ [K in keyof C]: C[K];
137
+ };
88
138
  /** Guard names referenced by a guards option, whichever of its three forms is used. */
89
139
  export type LambderGuardNamesIn<TOpt> = TOpt extends string ? TOpt : TOpt extends readonly (infer N extends string)[] ? N : TOpt extends object ? keyof TOpt & string : never;
90
140
  /** The endpoint's mode; a contract written without one admits either. */
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Two assertions over a call's outcome, for tests.
3
+ *
4
+ * An outcome is a discriminated union, so a test that expects a refusal has
5
+ * to narrow before it can read what the refusal carries, and the narrowing is
6
+ * the same three lines every time: check `ok`, branch on it, check `reason`.
7
+ * Written by hand, the failing case prints "expected false to be true" and
8
+ * says nothing about what actually came back, which is the one thing worth
9
+ * knowing when a call that should have been refused went through, or crashed
10
+ * instead.
11
+ *
12
+ * These narrow through an `asserts` signature, so the lines after one read
13
+ * the arm it proved, and they throw a plain Error naming what the outcome
14
+ * was. No test runner is imported: the same two functions serve vitest, jest
15
+ * and node:test, from `lambder/testing` over a real server and from
16
+ * `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
17
+ * vocabulary they read.
18
+ *
19
+ * Typed structurally over `ok` and `reason` rather than over
20
+ * LambderApiOutcome, so a LambderInvokeOutcome, whose failure side names
21
+ * other reasons, is narrowed by the same functions and a misspelled reason is
22
+ * a compile error against whichever union was passed.
23
+ */
24
+ /** What both callers' outcomes have in common: the discriminant, and a reason on the failure side. */
25
+ type LambderOutcomeShape = {
26
+ ok: true;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /** Every reason the failure side of an outcome union can carry. */
32
+ type LambderFailureReasonOf<TOutcome> = TOutcome extends {
33
+ ok: false;
34
+ reason: infer TReason;
35
+ } ? TReason : never;
36
+ /**
37
+ * The failure arms that can carry one of the given reasons, each narrowed to
38
+ * it. Per arm rather than through Extract: one arm may carry several reasons
39
+ * (`network`, `timeout`, `server` and `unknown` share theirs), and Extract
40
+ * would drop that arm for any single one of them.
41
+ */
42
+ type LambderFailureWithReason<TOutcome, TReason> = TOutcome extends {
43
+ ok: false;
44
+ reason: infer TArmReason;
45
+ } ? [TReason & TArmReason] extends [never] ? never : TOutcome & {
46
+ reason: TReason & TArmReason;
47
+ } : never;
48
+ /** What else a failure is expected to carry, beside its reason. */
49
+ export type LambderExpectedFailure = {
50
+ /** The refusal's machine-readable code (`errorMessage.code`), e.g. a LAMBDER_REFUSAL_CODES value or the app's own. */
51
+ code?: string;
52
+ /** The HTTP status the answer came with. */
53
+ status?: number;
54
+ };
55
+ /**
56
+ * Asserts that a call succeeded, and narrows the outcome to its success arm,
57
+ * so `outcome.payload` reads directly on the next line.
58
+ *
59
+ * ```typescript
60
+ * const outcome = await visitor.apiOutcome("order.create", { sku });
61
+ * assertApiSuccess(outcome);
62
+ * expect(outcome.payload?.orderId).toBeDefined();
63
+ * ```
64
+ */
65
+ export declare function assertApiSuccess<TOutcome extends LambderOutcomeShape>(outcome: TOutcome): asserts outcome is Extract<TOutcome, {
66
+ ok: true;
67
+ }>;
68
+ /**
69
+ * Asserts that a call failed, with the given reason when one is named, and
70
+ * narrows the outcome to the arms that reason can be, so what it carries
71
+ * (`zodError` after "validation", `response` after an envelope reason,
72
+ * `error` after the rest) reads directly on the next line.
73
+ *
74
+ * ```typescript
75
+ * assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
76
+ * assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
77
+ * ```
78
+ */
79
+ export declare function assertApiFailure<TOutcome extends LambderOutcomeShape, TReason extends LambderFailureReasonOf<TOutcome> = LambderFailureReasonOf<TOutcome>>(outcome: TOutcome, reason?: TReason, expected?: LambderExpectedFailure): asserts outcome is LambderFailureWithReason<TOutcome, TReason>;
80
+ export {};
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Two assertions over a call's outcome, for tests.
3
+ *
4
+ * An outcome is a discriminated union, so a test that expects a refusal has
5
+ * to narrow before it can read what the refusal carries, and the narrowing is
6
+ * the same three lines every time: check `ok`, branch on it, check `reason`.
7
+ * Written by hand, the failing case prints "expected false to be true" and
8
+ * says nothing about what actually came back, which is the one thing worth
9
+ * knowing when a call that should have been refused went through, or crashed
10
+ * instead.
11
+ *
12
+ * These narrow through an `asserts` signature, so the lines after one read
13
+ * the arm it proved, and they throw a plain Error naming what the outcome
14
+ * was. No test runner is imported: the same two functions serve vitest, jest
15
+ * and node:test, from `lambder/testing` over a real server and from
16
+ * `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
17
+ * vocabulary they read.
18
+ *
19
+ * Typed structurally over `ok` and `reason` rather than over
20
+ * LambderApiOutcome, so a LambderInvokeOutcome, whose failure side names
21
+ * other reasons, is narrowed by the same functions and a misspelled reason is
22
+ * a compile error against whichever union was passed.
23
+ */
24
+ const MAX_DESCRIBED_VALUE_LENGTH = 300;
25
+ /** A value as it can be printed in an assertion message: JSON, cut short, never throwing over a cyclic one. */
26
+ const describeValue = (value) => {
27
+ let text;
28
+ try {
29
+ text = JSON.stringify(value) ?? String(value);
30
+ }
31
+ catch {
32
+ text = String(value);
33
+ }
34
+ return text.length > MAX_DESCRIBED_VALUE_LENGTH ? `${text.slice(0, MAX_DESCRIBED_VALUE_LENGTH)}...` : text;
35
+ };
36
+ /**
37
+ * The error a failure carries, which becomes the cause of the assertion's
38
+ * own: a test runner prints the chain, so an app that crashed under
39
+ * `lambder/testing` shows the handler's stack under the failed assertion.
40
+ */
41
+ const errorOf = (outcome) => {
42
+ const error = outcome.error;
43
+ return error instanceof Error ? error : undefined;
44
+ };
45
+ /** One line saying what an outcome was, for the message of an assertion it failed. */
46
+ const describeOutcome = (outcome) => {
47
+ if (outcome.ok)
48
+ return `a success carrying ${describeValue(outcome.payload)}`;
49
+ const failure = outcome;
50
+ const details = [];
51
+ if (failure.status !== undefined)
52
+ details.push(`status ${failure.status}`);
53
+ if (failure.errorMessage !== undefined)
54
+ details.push(`errorMessage ${describeValue(failure.errorMessage)}`);
55
+ if (failure.zodError !== undefined)
56
+ details.push(`zodError ${describeValue(failure.zodError.message)}`);
57
+ // The error's own message, and its cause when it has one: an in-process
58
+ // transport reports a handler that threw as a failure whose cause is
59
+ // what actually threw, and that is the line a test author needs.
60
+ if (failure.error instanceof Error) {
61
+ const cause = failure.error.cause instanceof Error ? ` (cause: ${failure.error.cause.message})` : "";
62
+ details.push(`error "${failure.error.message}"${cause}`);
63
+ }
64
+ return `a failure with reason "${failure.reason}"${details.length ? `, ${details.join(", ")}` : ""}`;
65
+ };
66
+ /**
67
+ * Asserts that a call succeeded, and narrows the outcome to its success arm,
68
+ * so `outcome.payload` reads directly on the next line.
69
+ *
70
+ * ```typescript
71
+ * const outcome = await visitor.apiOutcome("order.create", { sku });
72
+ * assertApiSuccess(outcome);
73
+ * expect(outcome.payload?.orderId).toBeDefined();
74
+ * ```
75
+ */
76
+ export function assertApiSuccess(outcome) {
77
+ if (!outcome.ok)
78
+ throw new Error(`Expected the call to succeed, but it was ${describeOutcome(outcome)}.`, { cause: errorOf(outcome) });
79
+ }
80
+ /**
81
+ * Asserts that a call failed, with the given reason when one is named, and
82
+ * narrows the outcome to the arms that reason can be, so what it carries
83
+ * (`zodError` after "validation", `response` after an envelope reason,
84
+ * `error` after the rest) reads directly on the next line.
85
+ *
86
+ * ```typescript
87
+ * assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
88
+ * assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
89
+ * ```
90
+ */
91
+ export function assertApiFailure(outcome, reason, expected = {}) {
92
+ const wanted = [
93
+ reason !== undefined ? `reason "${String(reason)}"` : null,
94
+ expected.code !== undefined ? `code "${expected.code}"` : null,
95
+ expected.status !== undefined ? `status ${expected.status}` : null,
96
+ ].filter((part) => part !== null).join(", ");
97
+ const refuse = () => {
98
+ throw new Error(`Expected the call to fail${wanted ? ` with ${wanted}` : ""}, but it was ${describeOutcome(outcome)}.`, { cause: errorOf(outcome) });
99
+ };
100
+ if (outcome.ok)
101
+ return refuse();
102
+ if (reason !== undefined && outcome.reason !== reason)
103
+ return refuse();
104
+ if (expected.code !== undefined) {
105
+ // Only the structured errorMessage carries a code; a plain string has none to match.
106
+ const errorMessage = outcome.errorMessage;
107
+ const code = errorMessage && typeof errorMessage === "object" ? errorMessage.code : undefined;
108
+ if (code !== expected.code)
109
+ return refuse();
110
+ }
111
+ if (expected.status !== undefined && outcome.status !== expected.status)
112
+ return refuse();
113
+ }