lambder 7.2.5 → 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 (33) 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 +27 -0
  12. package/dist/core/Lambder.js +24 -0
  13. package/dist/core/LambderFiles.d.ts +7 -0
  14. package/dist/core/LambderFiles.js +12 -0
  15. package/dist/invoke/LambderLambdaEvent.d.ts +23 -9
  16. package/dist/invoke/LambderLambdaEvent.js +41 -16
  17. package/dist/invoke/lambderHandlerTransport.d.ts +3 -0
  18. package/dist/invoke/lambderHandlerTransport.js +1 -1
  19. package/dist/mock.d.ts +2 -0
  20. package/dist/mock.js +3 -0
  21. package/dist/session/LambderSessionManager.d.ts +12 -1
  22. package/dist/session/LambderSessionManager.js +19 -3
  23. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  24. package/dist/shared/util/LambderTestingDoors.js +29 -0
  25. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +80 -0
  26. package/dist/shared/wire/LambderOutcomeAssertions.js +113 -0
  27. package/dist/testing/LambderTestApp.d.ts +178 -0
  28. package/dist/testing/LambderTestApp.js +206 -0
  29. package/dist/testing/LambderTestVisitor.d.ts +155 -0
  30. package/dist/testing/LambderTestVisitor.js +154 -0
  31. package/dist/testing.d.ts +26 -0
  32. package/dist/testing.js +23 -0
  33. package/package.json +9 -1
@@ -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");
@@ -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
+ }
@@ -0,0 +1,178 @@
1
+ import type { Context } from "aws-lambda";
2
+ import type Lambder from "../core/Lambder.js";
3
+ import { type LambderHttpEventFormat } from "../core/LambderContext.js";
4
+ import type { LambderHandler } from "../core/LambderCreateOptions.js";
5
+ import type LambderSessionManager from "../session/LambderSessionManager.js";
6
+ import type { LambderFileSource } from "../shared/contracts/LambderFileSource.js";
7
+ import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
8
+ import type { LambderRateLimiter } from "../shared/contracts/LambderRateLimiter.js";
9
+ import type { LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
10
+ import type { LambderApiContractShape } from "../shared/wire/LambderApiContract.js";
11
+ import { LambderTestVisitor, type LambderTestVisitorArgs, type LambderTestVisitorOptions } from "./LambderTestVisitor.js";
12
+ /**
13
+ * What a test app may be told. Nothing is required: `lambderTestApp(lambder)`
14
+ * is a working test app over memory stores.
15
+ *
16
+ * The stores sit where create() takes them (`session.store`,
17
+ * `rateLimits.limiter`, `idempotency.store`, `files`), naming only the part a
18
+ * test replaces; everything else the app configured there stays in force.
19
+ */
20
+ export type LambderTestAppOptions = {
21
+ /** The host visitors browse unless they name their own. Default: "localhost". An app that scopes its session cookie to a domain needs a host under it. */
22
+ host?: string;
23
+ /**
24
+ * The gateway shape the handler is called with: "v2" (an HTTP API, a
25
+ * Function URL) or "v1" (a REST API). Default: "v2". A handler answers
26
+ * both alike through its context, so this matters to code that reads the
27
+ * raw `ctx.event`, and to anyone who wants the suite to run on exactly
28
+ * what production delivers.
29
+ */
30
+ eventFormat?: LambderHttpEventFormat;
31
+ /** Default: a fresh LambderMemorySessionStore. Pass your own to run the suite over another store (DynamoDB Local, say). */
32
+ session?: {
33
+ store?: LambderSessionStore<any>;
34
+ };
35
+ /** Default: a fresh LambderMemoryRateLimiter. */
36
+ rateLimits?: {
37
+ limiter?: LambderRateLimiter;
38
+ };
39
+ /** Default: a fresh LambderMemoryIdempotencyStore. */
40
+ idempotency?: {
41
+ store?: LambderIdempotencyStore;
42
+ };
43
+ /** Default: the app's own source, which suits one that reads a local folder. Pass a LambderLocalFileSource over fixtures for an app whose production source is S3 or HTTP. */
44
+ files?: LambderFileSource;
45
+ };
46
+ /**
47
+ * A Lambder instance as a test app takes it: any instance, read for its
48
+ * session data type and, through the ApiContract property rather than the
49
+ * class parameter, for its contract. The property is what lets a large app
50
+ * name its flattened contract interface explicitly
51
+ * (`lambderTestApp<SessionData, ApiContractType>(lambder)`) and keep the
52
+ * cheap type check that interface exists for.
53
+ */
54
+ export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any> & {
55
+ readonly ApiContract: TContract;
56
+ };
57
+ /**
58
+ * A real Lambder app under test: the instance an app already has, with memory
59
+ * stores put under it in place, and as many simulated browsers in front of it
60
+ * as a test needs. No HTTP, no AWS, and nothing in the app restructured.
61
+ *
62
+ * The app's own declarations all run as written: its guards, its named
63
+ * rate-limit policies, its idempotency settings, its session salt, cookie
64
+ * options and dataRefresh, its hooks and error handlers. Only where things
65
+ * rest is replaced, and from the moment this is created the stores the app
66
+ * was configured with are out of the instance's reach, so a test cannot touch
67
+ * a production table even by mistake. What the app reaches on its own (its
68
+ * database, a mailer) is the app's to replace.
69
+ *
70
+ * The sibling of LambderMockApp, which serves a contract from mock handlers:
71
+ * the same verbs (`signIn`, `signOut`, `expireSessionData`, `reset`) over the
72
+ * real handlers instead.
73
+ *
74
+ * Time is not this class's: fake `Date` with the test runner
75
+ * (`vi.useFakeTimers({ toFake: ["Date"] })`), which moves the framework, the
76
+ * memory stores and the app's own handlers together.
77
+ *
78
+ * One test app per instance: a second one puts its own stores under the same
79
+ * instance and takes over its crash watch, so the first stops seeing either.
80
+ */
81
+ export declare class LambderTestApp<TContract extends LambderApiContractShape = any, TSessionData = any> {
82
+ /** The instance's handler: what `event()` and every visitor call. */
83
+ readonly handler: LambderHandler;
84
+ /** The host visitors browse unless they name their own. */
85
+ readonly host: string;
86
+ /** The session store now under the instance, for assertions; null when the app has no sessions. A LambderMemorySessionStore unless one was given. */
87
+ readonly sessionStore: LambderSessionStore<TSessionData> | null;
88
+ /** The rate limiter now under the instance; null when the app declares no rate limits. */
89
+ readonly rateLimiter: LambderRateLimiter | null;
90
+ /** The idempotency store now under the instance; null when the app has no idempotency. */
91
+ readonly idempotencyStore: LambderIdempotencyStore | null;
92
+ private readonly lambder;
93
+ private readonly wiring;
94
+ /** The memory stores this test app made itself, which reset() empties. One the test supplied is the test's: nothing here knows what else holds it. */
95
+ private readonly ownStores;
96
+ private visitorCount;
97
+ private resetCount;
98
+ private readonly crashList;
99
+ /**
100
+ * The call a crash happened under. The app answers a crash with a 500
101
+ * that says nothing about it, so the error has to travel beside the
102
+ * answer, and with calls running concurrently (a duplicate sent while
103
+ * the original is in flight is an ordinary idempotency test) only the
104
+ * async context says which call a crash belongs to.
105
+ */
106
+ private readonly crashScope;
107
+ constructor(lambder: LambderTestedInstance<TSessionData, TContract>, options?: LambderTestAppOptions);
108
+ /**
109
+ * Every error the app threw while answering a request since the last
110
+ * reset, in order: what reached its global error handler, or the
111
+ * framework's last-resort 500. The answers themselves say nothing about
112
+ * what was thrown, so this is where a test reads it, and
113
+ * `expect(app.crashes).toEqual([])` is how one says nothing crashed.
114
+ * A refusal is not a crash, and neither is an error an `event()` rejects
115
+ * with, which the test already holds.
116
+ */
117
+ get crashes(): readonly Error[];
118
+ /** The session manager, for tests that inspect or manipulate sessions directly. Throws when the app has no sessions. */
119
+ get sessionManager(): LambderSessionManager<TSessionData>;
120
+ /**
121
+ * A new simulated browser: its own cookie jar, and its own address unless
122
+ * one is given. A stranger until it signs in, through the app's own login
123
+ * API or through `signIn`.
124
+ */
125
+ visitor<TProvidedGuards extends string = never>(...[options]: LambderTestVisitorArgs<LambderTestVisitorOptions<TContract, TProvidedGuards>, TProvidedGuards>): LambderTestVisitor<TContract, TSessionData, TProvidedGuards>;
126
+ /**
127
+ * A new visitor, already signed in: `visitor()` followed by its
128
+ * `signIn()`. The session is minted by the app's own session model, so
129
+ * no login endpoint has to exist or be called.
130
+ *
131
+ * ```typescript
132
+ * const admin = await app.signIn("user:ada", { userId: "ada", role: "admin" });
133
+ * expect(await admin.api("org.rename", { name })).toEqual({ ok: true });
134
+ * ```
135
+ */
136
+ signIn<TProvidedGuards extends string = never>(sessionKey: string, data: TSessionData, ...[options]: LambderTestVisitorArgs<LambderTestVisitorOptions<TContract, TProvidedGuards> & {
137
+ ttlSeconds?: number;
138
+ }, TProvidedGuards>): Promise<LambderTestVisitor<TContract, TSessionData, TProvidedGuards>>;
139
+ /**
140
+ * Ends every session of the subject, the way "log out everywhere" does.
141
+ * A visitor signed in as that subject keeps its cookies, as a browser
142
+ * would, so its next call is what a real one's would be: answered
143
+ * sessionExpired, with the stale cookies evicted.
144
+ */
145
+ signOut(sessionKey: string): Promise<void>;
146
+ /** Marks the subject's session data stale, so the next read renews it through the app's dataRefresh. */
147
+ expireSessionData(sessionKey: string): Promise<void>;
148
+ /**
149
+ * Hands the handler an event that is not an HTTP request (a schedule, an
150
+ * SNS or SQS delivery), which is how an addAction handler runs, with a
151
+ * Lambda context filled in. Resolves to whatever the action returned.
152
+ *
153
+ * ```typescript
154
+ * await app.event({ source: "aws.events", "detail-type": "Scheduled Event" });
155
+ * ```
156
+ */
157
+ event(event: unknown, context?: Partial<Context>): Promise<unknown>;
158
+ /**
159
+ * Rewinds what accumulated: sessions, rate-limit counters and replay
160
+ * records in the stores this test app made, the crashes it recorded, and
161
+ * the cookies of every visitor it created (each empties its jar the next
162
+ * time it is used). For a beforeEach. The app's own data (its database)
163
+ * is the app's to rewind.
164
+ */
165
+ reset(): void;
166
+ }
167
+ /**
168
+ * Puts a built Lambder instance under test. See LambderTestApp.
169
+ *
170
+ * ```typescript
171
+ * import { lambderTestApp } from "lambder/testing";
172
+ * import { lambder } from "../src/index.js";
173
+ *
174
+ * const app = lambderTestApp(lambder);
175
+ * beforeEach(() => app.reset());
176
+ * ```
177
+ */
178
+ export declare const lambderTestApp: <TSessionData = any, TContract extends LambderApiContractShape = any>(lambder: LambderTestedInstance<TSessionData, TContract>, options?: LambderTestAppOptions) => LambderTestApp<TContract, TSessionData>;