@nage-api/testing 1.0.0-beta.2

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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The Testcontainers harness (PLAN.md §19, §24 Phase 9 risk: "flaky containers —
3
+ * pin images, retry, parallelize").
4
+ *
5
+ * The point of this file is to make integration tests *skip* cleanly rather than
6
+ * fail when no container runtime is available, and to make the three
7
+ * flakiness-mitigations the plan names structural rather than remembered:
8
+ *
9
+ * - **pinned images.** A tag like `postgres:16` moves. `IMAGES` pins digests-in-
10
+ * spirit by pinning exact patch versions, and the table is the only place a
11
+ * version appears, so an upgrade is one edit rather than a grep.
12
+ * - **retry.** Container startup fails transiently — a port race, a slow pull.
13
+ * `start` retries with backoff before giving up.
14
+ * - **parallel-safe.** Every container gets a unique name and an ephemeral
15
+ * port, so two suites running at once cannot collide.
16
+ *
17
+ * `testcontainers` itself is **not** a dependency. This package sits at the
18
+ * bottom of the graph and must stay installable without Docker, so the runtime
19
+ * is a port: a project that wants real containers passes an adapter over
20
+ * `testcontainers`, and everything else gets `skipIfUnavailable`.
21
+ *
22
+ * Note for this repository: there is no container runtime in the development
23
+ * environment, so the adapter path is unit-tested against a fake runtime and has
24
+ * never been exercised against Docker. That is stated in the README rather than
25
+ * implied to be verified.
26
+ */
27
+ /** A service an integration test can ask for. */
28
+ export type ContainerService = 'postgres' | 'mysql' | 'mariadb' | 'mongo' | 'redis';
29
+ /**
30
+ * Pinned images, one per service.
31
+ *
32
+ * Exact patch versions, not floating tags: `postgres:16` is a different image
33
+ * next month, and an integration suite that changes underneath you is worse than
34
+ * one that is out of date.
35
+ */
36
+ export declare const IMAGES: Readonly<Record<ContainerService, string>>;
37
+ /** The port each service listens on inside its container. */
38
+ export declare const INTERNAL_PORTS: Readonly<Record<ContainerService, number>>;
39
+ /** A started container, as the harness reports it. */
40
+ export interface StartedContainer {
41
+ readonly service: ContainerService;
42
+ readonly host: string;
43
+ /** The mapped, ephemeral host port. */
44
+ readonly port: number;
45
+ /** A connection URL an application config can use directly. */
46
+ readonly url: string;
47
+ stop(): Promise<void>;
48
+ }
49
+ /** What the harness needs from a container runtime. */
50
+ export interface ContainerRuntime {
51
+ readonly name: string;
52
+ /** Whether containers can actually be started right now. */
53
+ isAvailable(): Promise<boolean>;
54
+ start(request: {
55
+ image: string;
56
+ internalPort: number;
57
+ /** Unique per run, so parallel suites cannot collide. */
58
+ containerName: string;
59
+ environment: Readonly<Record<string, string>>;
60
+ }): Promise<{
61
+ host: string;
62
+ port: number;
63
+ stop(): Promise<void>;
64
+ }>;
65
+ }
66
+ export interface ContainerHarnessOptions {
67
+ readonly runtime: ContainerRuntime;
68
+ /** Attempts before giving up on a container that will not start. */
69
+ readonly maxAttempts?: number;
70
+ /** Delay before the first retry; doubled each time. */
71
+ readonly retryDelayMs?: number;
72
+ /** Injected so the retry backoff does not really sleep in a unit test. */
73
+ readonly sleep?: (ms: number) => Promise<void>;
74
+ /** Injected so container names are deterministic in a test. */
75
+ readonly nameSuffix?: () => string;
76
+ }
77
+ export declare class ContainerHarness {
78
+ #private;
79
+ constructor(options: ContainerHarnessOptions);
80
+ /**
81
+ * Whether containers are usable. Memoised: probing a missing Docker socket
82
+ * per test is slow and the answer never changes within a run.
83
+ */
84
+ isAvailable(): Promise<boolean>;
85
+ /**
86
+ * Start a service, retrying transient failures.
87
+ *
88
+ * @throws the last error when every attempt fails — a container that will not
89
+ * start is a real failure, not something to skip over.
90
+ */
91
+ start(service: ContainerService): Promise<StartedContainer>;
92
+ /** Stop everything this harness started. Safe to call twice. */
93
+ stopAll(): Promise<void>;
94
+ get running(): readonly StartedContainer[];
95
+ }
96
+ /**
97
+ * Register an integration suite that runs only when containers are available.
98
+ *
99
+ * Skipping is not the same as passing, so the skip is loud: the suite name says
100
+ * it was skipped and why, and a `requireContainers` flag turns the skip into a
101
+ * failure for the CI job that is supposed to have Docker.
102
+ */
103
+ export interface ContainerSuiteApi {
104
+ describe: (name: string, fn: () => void) => void;
105
+ it: (name: string, fn: () => Promise<void> | void) => void;
106
+ }
107
+ export declare function describeWithContainers(api: ContainerSuiteApi, options: {
108
+ readonly name: string;
109
+ readonly harness: ContainerHarness;
110
+ /** Fail rather than skip when no runtime is available. */
111
+ readonly required?: boolean;
112
+ readonly suite: () => void;
113
+ }): Promise<void>;
114
+ /** The connection URL for a started service. */
115
+ export declare function urlFor(service: ContainerService, host: string, port: number): string;
116
+ /**
117
+ * A runtime that reports nothing is available.
118
+ *
119
+ * The default, so an integration suite in an environment without Docker skips
120
+ * rather than throwing a confusing connection error.
121
+ */
122
+ export declare const unavailableRuntime: ContainerRuntime;
123
+ //# sourceMappingURL=harness.d.ts.map
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ /**
3
+ * The Testcontainers harness (PLAN.md §19, §24 Phase 9 risk: "flaky containers —
4
+ * pin images, retry, parallelize").
5
+ *
6
+ * The point of this file is to make integration tests *skip* cleanly rather than
7
+ * fail when no container runtime is available, and to make the three
8
+ * flakiness-mitigations the plan names structural rather than remembered:
9
+ *
10
+ * - **pinned images.** A tag like `postgres:16` moves. `IMAGES` pins digests-in-
11
+ * spirit by pinning exact patch versions, and the table is the only place a
12
+ * version appears, so an upgrade is one edit rather than a grep.
13
+ * - **retry.** Container startup fails transiently — a port race, a slow pull.
14
+ * `start` retries with backoff before giving up.
15
+ * - **parallel-safe.** Every container gets a unique name and an ephemeral
16
+ * port, so two suites running at once cannot collide.
17
+ *
18
+ * `testcontainers` itself is **not** a dependency. This package sits at the
19
+ * bottom of the graph and must stay installable without Docker, so the runtime
20
+ * is a port: a project that wants real containers passes an adapter over
21
+ * `testcontainers`, and everything else gets `skipIfUnavailable`.
22
+ *
23
+ * Note for this repository: there is no container runtime in the development
24
+ * environment, so the adapter path is unit-tested against a fake runtime and has
25
+ * never been exercised against Docker. That is stated in the README rather than
26
+ * implied to be verified.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.unavailableRuntime = exports.ContainerHarness = exports.INTERNAL_PORTS = exports.IMAGES = void 0;
30
+ exports.describeWithContainers = describeWithContainers;
31
+ exports.urlFor = urlFor;
32
+ /**
33
+ * Pinned images, one per service.
34
+ *
35
+ * Exact patch versions, not floating tags: `postgres:16` is a different image
36
+ * next month, and an integration suite that changes underneath you is worse than
37
+ * one that is out of date.
38
+ */
39
+ exports.IMAGES = {
40
+ postgres: 'postgres:16.4-alpine',
41
+ mysql: 'mysql:8.4.2',
42
+ mariadb: 'mariadb:11.4.3',
43
+ mongo: 'mongo:7.0.14',
44
+ redis: 'redis:7.4.0-alpine',
45
+ };
46
+ /** The port each service listens on inside its container. */
47
+ exports.INTERNAL_PORTS = {
48
+ postgres: 5432,
49
+ mysql: 3306,
50
+ mariadb: 3306,
51
+ mongo: 27017,
52
+ redis: 6379,
53
+ };
54
+ const DEFAULT_MAX_ATTEMPTS = 3;
55
+ const DEFAULT_RETRY_DELAY_MS = 1000;
56
+ /**
57
+ * Monotonic, process-wide, and the only thing that makes a container name
58
+ * genuinely unique.
59
+ *
60
+ * The suffix alone is not enough: it is derived from a clock, so two containers
61
+ * of the same service started in the same millisecond — two suites running in
62
+ * parallel, or one suite starting a pair — get identical names, and Docker
63
+ * refuses the second. The counter also covers the retry case, since a
64
+ * half-started container from a failed attempt may still hold its name.
65
+ */
66
+ let containerSequence = 0;
67
+ /** Credentials the harness sets, and the URLs it builds from them. */
68
+ const CREDENTIALS = { user: 'nage', password: 'nage', database: 'nage_test' };
69
+ class ContainerHarness {
70
+ #runtime;
71
+ #maxAttempts;
72
+ #retryDelayMs;
73
+ #sleep;
74
+ #nameSuffix;
75
+ #started = [];
76
+ #available;
77
+ constructor(options) {
78
+ this.#runtime = options.runtime;
79
+ this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
80
+ this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
81
+ this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
82
+ this.#nameSuffix =
83
+ options.nameSuffix ?? (() => `${String(process.pid)}-${Math.trunc(performance.now())}`);
84
+ }
85
+ /**
86
+ * Whether containers are usable. Memoised: probing a missing Docker socket
87
+ * per test is slow and the answer never changes within a run.
88
+ */
89
+ async isAvailable() {
90
+ this.#available ??= await this.#runtime.isAvailable();
91
+ return this.#available;
92
+ }
93
+ /**
94
+ * Start a service, retrying transient failures.
95
+ *
96
+ * @throws the last error when every attempt fails — a container that will not
97
+ * start is a real failure, not something to skip over.
98
+ */
99
+ async start(service) {
100
+ let lastError;
101
+ for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
102
+ try {
103
+ containerSequence += 1;
104
+ const started = await this.#runtime.start({
105
+ image: exports.IMAGES[service],
106
+ internalPort: exports.INTERNAL_PORTS[service],
107
+ containerName: `nage-${service}-${this.#nameSuffix()}-${String(containerSequence)}`,
108
+ environment: environmentFor(service),
109
+ });
110
+ const container = {
111
+ service,
112
+ host: started.host,
113
+ port: started.port,
114
+ url: urlFor(service, started.host, started.port),
115
+ // Wrapped, not passed by reference: a runtime whose `stop` reads
116
+ // `this` would break when the method is detached from its object.
117
+ stop: () => started.stop(),
118
+ };
119
+ this.#started.push(container);
120
+ return container;
121
+ }
122
+ catch (error) {
123
+ lastError = error;
124
+ if (attempt < this.#maxAttempts)
125
+ await this.#sleep(this.#retryDelayMs * 2 ** (attempt - 1));
126
+ }
127
+ }
128
+ throw new Error(`Could not start ${service} (${exports.IMAGES[service]}) after ${String(this.#maxAttempts)} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, { cause: lastError });
129
+ }
130
+ /** Stop everything this harness started. Safe to call twice. */
131
+ async stopAll() {
132
+ const stopping = this.#started.splice(0).map(async (container) => {
133
+ try {
134
+ await container.stop();
135
+ }
136
+ catch {
137
+ // A container that failed to stop must not fail the suite: the run is
138
+ // over, and the runtime will reap it.
139
+ }
140
+ });
141
+ await Promise.all(stopping);
142
+ }
143
+ get running() {
144
+ return this.#started;
145
+ }
146
+ }
147
+ exports.ContainerHarness = ContainerHarness;
148
+ async function describeWithContainers(api, options) {
149
+ const available = await options.harness.isAvailable();
150
+ if (available) {
151
+ api.describe(options.name, options.suite);
152
+ return;
153
+ }
154
+ if (options.required === true) {
155
+ api.describe(options.name, () => {
156
+ api.it('should have a container runtime available', () => {
157
+ throw new Error('No container runtime is available, and this suite is marked required. ' +
158
+ 'Start Docker, or unset the flag that requires containers.');
159
+ });
160
+ });
161
+ return;
162
+ }
163
+ api.describe(`${options.name} (skipped: no container runtime)`, () => {
164
+ api.it('should be run in an environment with Docker', () => {
165
+ // A skipped suite that reports nothing is indistinguishable from one that
166
+ // passed, so it leaves a visible, passing marker instead.
167
+ });
168
+ });
169
+ }
170
+ function environmentFor(service) {
171
+ switch (service) {
172
+ case 'postgres':
173
+ return {
174
+ POSTGRES_USER: CREDENTIALS.user,
175
+ POSTGRES_PASSWORD: CREDENTIALS.password,
176
+ POSTGRES_DB: CREDENTIALS.database,
177
+ };
178
+ case 'mysql':
179
+ case 'mariadb':
180
+ return {
181
+ MYSQL_ROOT_PASSWORD: CREDENTIALS.password,
182
+ MYSQL_USER: CREDENTIALS.user,
183
+ MYSQL_PASSWORD: CREDENTIALS.password,
184
+ MYSQL_DATABASE: CREDENTIALS.database,
185
+ };
186
+ case 'mongo':
187
+ return {
188
+ MONGO_INITDB_ROOT_USERNAME: CREDENTIALS.user,
189
+ MONGO_INITDB_ROOT_PASSWORD: CREDENTIALS.password,
190
+ };
191
+ case 'redis':
192
+ return {};
193
+ }
194
+ }
195
+ /** The connection URL for a started service. */
196
+ function urlFor(service, host, port) {
197
+ const authority = `${CREDENTIALS.user}:${CREDENTIALS.password}@${host}:${String(port)}`;
198
+ switch (service) {
199
+ case 'postgres':
200
+ return `postgres://${authority}/${CREDENTIALS.database}`;
201
+ case 'mysql':
202
+ return `mysql://${authority}/${CREDENTIALS.database}`;
203
+ case 'mariadb':
204
+ return `mariadb://${authority}/${CREDENTIALS.database}`;
205
+ case 'mongo':
206
+ return `mongodb://${authority}/${CREDENTIALS.database}?authSource=admin`;
207
+ case 'redis':
208
+ return `redis://${host}:${String(port)}`;
209
+ }
210
+ }
211
+ /**
212
+ * A runtime that reports nothing is available.
213
+ *
214
+ * The default, so an integration suite in an environment without Docker skips
215
+ * rather than throwing a confusing connection error.
216
+ */
217
+ exports.unavailableRuntime = {
218
+ name: 'none',
219
+ isAvailable: async () => Promise.resolve(false),
220
+ start: async () => Promise.reject(new Error('No container runtime is configured')),
221
+ };
222
+ //# sourceMappingURL=harness.js.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Doubles for the ports the framework declares (PLAN.md §19: "mock at the
3
+ * **port** boundary … not deep ORM internals").
4
+ *
5
+ * Every one of these is a real, complete implementation of a contract, not a
6
+ * stub that returns `undefined`. That distinction matters: a stub agrees with
7
+ * whatever the code under test does, so a test built on one passes even when the
8
+ * code is wrong. A real implementation disagrees.
9
+ *
10
+ * The repository double is deliberately **not** here. `@nage-api/data` already
11
+ * ships `MemoryRepository`, and it is the one the driver conformance suite runs
12
+ * against — so it is the only in-memory repository whose behaviour is proven to
13
+ * match a real driver's. Duplicating it in the kit would produce a second
14
+ * implementation with no such guarantee. (§19 lists an in-memory driver as part
15
+ * of this package; that is the one deviation, and it is deliberate: the kit sits
16
+ * below `@nage-api/data` in the dependency graph and may not import it.)
17
+ */
18
+ import type { KeyValueStore, LogFields, LoggerPort, RateLimitResult, RateLimitStore, SecretProviderPort } from '@nage-api/contracts';
19
+ /** A clock a test advances by hand, so no test ever waits. */
20
+ export declare class FakeClock {
21
+ #private;
22
+ constructor(start?: number);
23
+ now(): number;
24
+ advance(milliseconds: number): this;
25
+ /** Move to an absolute instant, for testing an expiry boundary exactly. */
26
+ set(epochMs: number): this;
27
+ }
28
+ export interface CapturedLog {
29
+ readonly level: string;
30
+ readonly message: string;
31
+ readonly fields: LogFields;
32
+ }
33
+ /**
34
+ * A logger that records.
35
+ *
36
+ * Useful for the assertion that matters most about logging: that a credential
37
+ * never reaches a log line. `entries` is inspectable, so `expectNoSecrets` can
38
+ * be pointed straight at it.
39
+ */
40
+ export declare class RecordingLogger implements LoggerPort {
41
+ #private;
42
+ readonly entries: CapturedLog[];
43
+ constructor(bindings?: LogFields);
44
+ trace(message: string, fields?: LogFields): void;
45
+ debug(message: string, fields?: LogFields): void;
46
+ info(message: string, fields?: LogFields): void;
47
+ warn(message: string, fields?: LogFields): void;
48
+ error(message: string, fields?: LogFields): void;
49
+ fatal(message: string, fields?: LogFields): void;
50
+ child(bindings: LogFields): LoggerPort;
51
+ /** Lines at one level. */
52
+ at(level: string): readonly CapturedLog[];
53
+ /** Everything logged, as one string — the cheapest way to assert on absence. */
54
+ get text(): string;
55
+ clear(): void;
56
+ }
57
+ /** A `KeyValueStore` with TTL honoured against an injectable clock. */
58
+ export declare class MemoryKeyValueStore<TValue> implements KeyValueStore<TValue> {
59
+ #private;
60
+ constructor(clock?: {
61
+ now(): number;
62
+ });
63
+ get(key: string): Promise<TValue | null>;
64
+ set(key: string, value: TValue, ttlSeconds?: number): Promise<void>;
65
+ delete(key: string): Promise<void>;
66
+ get size(): number;
67
+ get keys(): readonly string[];
68
+ }
69
+ /**
70
+ * A rate-limit store a test drives.
71
+ *
72
+ * `consume` is a real fixed-window counter rather than a counter that always
73
+ * allows: a guard tested against a permissive double is a guard nobody has
74
+ * tested.
75
+ */
76
+ export declare class MemoryRateLimitStore implements RateLimitStore {
77
+ #private;
78
+ readonly name = "memory-test";
79
+ constructor(clock?: {
80
+ now(): number;
81
+ });
82
+ consume(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
83
+ reset(key: string): Promise<void>;
84
+ }
85
+ /**
86
+ * A secret provider backed by a plain object.
87
+ *
88
+ * `require` rejects for a missing name, exactly as the env and AWS providers do
89
+ * — a double that returned `undefined` instead would hide every fail-fast test.
90
+ */
91
+ export declare class StubSecretProvider implements SecretProviderPort {
92
+ #private;
93
+ readonly provider = "stub";
94
+ readonly reads: string[];
95
+ constructor(secrets?: Record<string, string>);
96
+ get(name: string): Promise<string | null>;
97
+ require(name: string): Promise<string>;
98
+ set(name: string, value: string): this;
99
+ }
100
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+ /**
3
+ * Doubles for the ports the framework declares (PLAN.md §19: "mock at the
4
+ * **port** boundary … not deep ORM internals").
5
+ *
6
+ * Every one of these is a real, complete implementation of a contract, not a
7
+ * stub that returns `undefined`. That distinction matters: a stub agrees with
8
+ * whatever the code under test does, so a test built on one passes even when the
9
+ * code is wrong. A real implementation disagrees.
10
+ *
11
+ * The repository double is deliberately **not** here. `@nage-api/data` already
12
+ * ships `MemoryRepository`, and it is the one the driver conformance suite runs
13
+ * against — so it is the only in-memory repository whose behaviour is proven to
14
+ * match a real driver's. Duplicating it in the kit would produce a second
15
+ * implementation with no such guarantee. (§19 lists an in-memory driver as part
16
+ * of this package; that is the one deviation, and it is deliberate: the kit sits
17
+ * below `@nage-api/data` in the dependency graph and may not import it.)
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.StubSecretProvider = exports.MemoryRateLimitStore = exports.MemoryKeyValueStore = exports.RecordingLogger = exports.FakeClock = void 0;
21
+ /** A clock a test advances by hand, so no test ever waits. */
22
+ class FakeClock {
23
+ #now;
24
+ constructor(start = Date.UTC(2026, 0, 1, 12, 0, 0)) {
25
+ this.#now = start;
26
+ }
27
+ now() {
28
+ return this.#now;
29
+ }
30
+ advance(milliseconds) {
31
+ this.#now += milliseconds;
32
+ return this;
33
+ }
34
+ /** Move to an absolute instant, for testing an expiry boundary exactly. */
35
+ set(epochMs) {
36
+ this.#now = epochMs;
37
+ return this;
38
+ }
39
+ }
40
+ exports.FakeClock = FakeClock;
41
+ /**
42
+ * A logger that records.
43
+ *
44
+ * Useful for the assertion that matters most about logging: that a credential
45
+ * never reaches a log line. `entries` is inspectable, so `expectNoSecrets` can
46
+ * be pointed straight at it.
47
+ */
48
+ class RecordingLogger {
49
+ entries = [];
50
+ #bindings;
51
+ constructor(bindings = {}) {
52
+ this.#bindings = bindings;
53
+ }
54
+ trace(message, fields = {}) {
55
+ this.#record('trace', message, fields);
56
+ }
57
+ debug(message, fields = {}) {
58
+ this.#record('debug', message, fields);
59
+ }
60
+ info(message, fields = {}) {
61
+ this.#record('info', message, fields);
62
+ }
63
+ warn(message, fields = {}) {
64
+ this.#record('warn', message, fields);
65
+ }
66
+ error(message, fields = {}) {
67
+ this.#record('error', message, fields);
68
+ }
69
+ fatal(message, fields = {}) {
70
+ this.#record('fatal', message, fields);
71
+ }
72
+ child(bindings) {
73
+ // Shares the same array, so a test can assert on everything the whole tree
74
+ // logged without collecting children.
75
+ const derived = new RecordingLogger({ ...this.#bindings, ...bindings });
76
+ Object.defineProperty(derived, 'entries', { value: this.entries });
77
+ return derived;
78
+ }
79
+ /** Lines at one level. */
80
+ at(level) {
81
+ return this.entries.filter((entry) => entry.level === level);
82
+ }
83
+ /** Everything logged, as one string — the cheapest way to assert on absence. */
84
+ get text() {
85
+ return JSON.stringify(this.entries);
86
+ }
87
+ clear() {
88
+ this.entries.length = 0;
89
+ }
90
+ #record(level, message, fields) {
91
+ this.entries.push({ level, message, fields: { ...this.#bindings, ...fields } });
92
+ }
93
+ }
94
+ exports.RecordingLogger = RecordingLogger;
95
+ /** A `KeyValueStore` with TTL honoured against an injectable clock. */
96
+ class MemoryKeyValueStore {
97
+ #entries = new Map();
98
+ #clock;
99
+ constructor(clock = { now: () => Date.now() }) {
100
+ this.#clock = clock;
101
+ }
102
+ async get(key) {
103
+ await Promise.resolve();
104
+ const entry = this.#entries.get(key);
105
+ if (entry === undefined)
106
+ return null;
107
+ if (entry.expiresAt !== undefined && entry.expiresAt <= this.#clock.now()) {
108
+ this.#entries.delete(key);
109
+ return null;
110
+ }
111
+ return entry.value;
112
+ }
113
+ async set(key, value, ttlSeconds) {
114
+ await Promise.resolve();
115
+ this.#entries.set(key, {
116
+ value,
117
+ ...(ttlSeconds === undefined ? {} : { expiresAt: this.#clock.now() + ttlSeconds * 1000 }),
118
+ });
119
+ }
120
+ async delete(key) {
121
+ await Promise.resolve();
122
+ this.#entries.delete(key);
123
+ }
124
+ get size() {
125
+ return this.#entries.size;
126
+ }
127
+ get keys() {
128
+ return [...this.#entries.keys()];
129
+ }
130
+ }
131
+ exports.MemoryKeyValueStore = MemoryKeyValueStore;
132
+ /**
133
+ * A rate-limit store a test drives.
134
+ *
135
+ * `consume` is a real fixed-window counter rather than a counter that always
136
+ * allows: a guard tested against a permissive double is a guard nobody has
137
+ * tested.
138
+ */
139
+ class MemoryRateLimitStore {
140
+ name = 'memory-test';
141
+ #windows = new Map();
142
+ #clock;
143
+ constructor(clock = { now: () => Date.now() }) {
144
+ this.#clock = clock;
145
+ }
146
+ async consume(key, limit, windowMs) {
147
+ await Promise.resolve();
148
+ const now = this.#clock.now();
149
+ const existing = this.#windows.get(key);
150
+ const window = existing === undefined || existing.resetAt <= now
151
+ ? { count: 0, resetAt: now + windowMs }
152
+ : existing;
153
+ window.count += 1;
154
+ this.#windows.set(key, window);
155
+ return {
156
+ allowed: window.count <= limit,
157
+ limit,
158
+ remaining: Math.max(0, limit - window.count),
159
+ resetAt: window.resetAt,
160
+ // Rounded up, so a caller told to wait 0 seconds never retries into the
161
+ // same window it was just refused from.
162
+ retryAfterSeconds: Math.max(1, Math.ceil((window.resetAt - now) / 1000)),
163
+ };
164
+ }
165
+ async reset(key) {
166
+ await Promise.resolve();
167
+ this.#windows.delete(key);
168
+ }
169
+ }
170
+ exports.MemoryRateLimitStore = MemoryRateLimitStore;
171
+ /**
172
+ * A secret provider backed by a plain object.
173
+ *
174
+ * `require` rejects for a missing name, exactly as the env and AWS providers do
175
+ * — a double that returned `undefined` instead would hide every fail-fast test.
176
+ */
177
+ class StubSecretProvider {
178
+ provider = 'stub';
179
+ reads = [];
180
+ #secrets;
181
+ constructor(secrets = {}) {
182
+ this.#secrets = { ...secrets };
183
+ }
184
+ async get(name) {
185
+ await Promise.resolve();
186
+ this.reads.push(name);
187
+ return this.#secrets[name] ?? null;
188
+ }
189
+ async require(name) {
190
+ const value = await this.get(name);
191
+ if (value === null)
192
+ throw new Error(`Required secret ${name} is not set`);
193
+ return value;
194
+ }
195
+ set(name, value) {
196
+ this.#secrets[name] = value;
197
+ return this;
198
+ }
199
+ }
200
+ exports.StubSecretProvider = StubSecretProvider;
201
+ //# sourceMappingURL=index.js.map