payload-test-api-route-handler 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MaDz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,265 @@
1
+ <div align="center">
2
+
3
+ # payload-test-api-route-handler
4
+
5
+ **Test Payload CMS API routes without a running server.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/payload-test-api-route-handler.svg?style=flat-square&color=cb3837)](https://www.npmjs.com/package/payload-test-api-route-handler)
8
+ [![npm downloads](https://img.shields.io/npm/dm/payload-test-api-route-handler.svg?style=flat-square&color=blue)](https://www.npmjs.com/package/payload-test-api-route-handler)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg?style=flat-square)](LICENSE)
10
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7+-3178c6.svg?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
11
+ [![Payload CMS](https://img.shields.io/badge/Payload_CMS-v3-000000.svg?style=flat-square)](https://payloadcms.com/)
12
+
13
+ An in-process test utility for [Payload CMS](https://payloadcms.com) v3+ that lets you exercise API route handlers, authentication, database transactions, and custom endpoints using a standard [Axios](https://axios-http.com) client — **without starting a live HTTP server**.
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## Why?
20
+
21
+ Testing Payload CMS endpoints typically requires spinning up a full Next.js dev server. This is **slow**, **flaky**, and makes CI pipelines painful.
22
+
23
+ This package gives you a drop-in Axios client that routes requests **directly** into Payload's handler pipeline in-process. Your tests run in milliseconds, not seconds.
24
+
25
+ ```
26
+ Traditional approach This package
27
+ ┌──────────┐ ┌──────────┐
28
+ │ Test │ ── HTTP ──► │ Test │
29
+ │ Suite │ │ Suite │
30
+ └──────────┘ └─────┬────┘
31
+ │ │
32
+ ▼ ▼ (in-process)
33
+ ┌──────────┐ ┌──────────┐
34
+ │ HTTP │ │ Payload │
35
+ │ Server │ │ Handler │
36
+ └─────┬────┘ └──────────┘
37
+ │ No network. No server.
38
+ ▼ Just fast tests.
39
+ ┌──────────┐
40
+ │ Payload │
41
+ │ Handler │
42
+ └──────────┘
43
+ ```
44
+
45
+ ## Features
46
+
47
+ - 🚀 **Zero Server** — No HTTP server, no port conflicts, no startup delay
48
+ - 🔐 **Full Auth Pipeline** — JWT login, token resolution, `req.user` — it all works
49
+ - 🗄️ **Real Transactions** — Database transaction context flows through hooks exactly like production
50
+ - 🧬 **Request Patching** — Inject custom user sessions, override route params, mock anything
51
+ - 📦 **Standard Axios** — Interceptors, defaults, `client.defaults.headers` — everything you expect
52
+ - 🧪 **Framework Agnostic** — Works with Vitest, Jest, or any test runner
53
+ - 🎯 **Full Endpoint Coverage** — Config endpoints, collection endpoints, global endpoints, and built-in CRUD
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ # npm
59
+ npm install --save-dev payload-test-api-route-handler
60
+
61
+ # yarn
62
+ yarn add -D payload-test-api-route-handler
63
+
64
+ # pnpm
65
+ pnpm add -D payload-test-api-route-handler
66
+ ```
67
+
68
+ > **Peer dependency:** Requires `payload` v3.85.1 or later.
69
+
70
+ ## Quick Start
71
+
72
+ ```typescript
73
+ import { describe, it, expect, beforeAll } from 'vitest';
74
+ import { getPayload } from 'payload';
75
+ import { buildConfig } from 'payload';
76
+ import { sqliteAdapter } from '@payloadcms/db-sqlite';
77
+ import { lexicalEditor } from '@payloadcms/richtext-lexical';
78
+ import { createTesterAxios } from 'payload-test-api-route-handler';
79
+
80
+ describe('My API', () => {
81
+ let config;
82
+
83
+ beforeAll(async () => {
84
+ config = await buildConfig({
85
+ db: sqliteAdapter({ client: { url: 'file::memory:' } }),
86
+ editor: lexicalEditor({}),
87
+ secret: 'test-secret-at-least-32-chars-long!!',
88
+ collections: [
89
+ {
90
+ slug: 'posts',
91
+ fields: [{ name: 'title', type: 'text', required: true }],
92
+ endpoints: [
93
+ {
94
+ path: '/featured',
95
+ method: 'get',
96
+ handler: async () =>
97
+ Response.json({ featured: true }),
98
+ },
99
+ ],
100
+ },
101
+ ],
102
+ });
103
+
104
+ await getPayload({ config });
105
+ });
106
+
107
+ it('hits a custom endpoint', async () => {
108
+ const client = createTesterAxios(config);
109
+ const res = await client.get('/api/posts/featured');
110
+
111
+ expect(res.status).toBe(200);
112
+ expect(res.data).toEqual({ featured: true });
113
+ });
114
+
115
+ it('creates a post via built-in CRUD', async () => {
116
+ const client = createTesterAxios(config);
117
+ const res = await client.post('/api/posts', {
118
+ title: 'Hello World',
119
+ });
120
+
121
+ expect(res.status).toBe(201);
122
+ expect(res.data.doc.title).toBe('Hello World');
123
+ });
124
+ });
125
+ ```
126
+
127
+ ## Usage Recipes
128
+
129
+ ### Authentication Flow
130
+
131
+ ```typescript
132
+ const client = createTesterAxios(config);
133
+
134
+ // Login
135
+ const { data } = await client.post('/api/users/login', {
136
+ email: 'admin@example.com',
137
+ password: 'password',
138
+ });
139
+
140
+ // Set token for all subsequent requests
141
+ client.defaults.headers.common['Authorization'] = `JWT ${data.token}`;
142
+
143
+ // Now all requests are authenticated
144
+ const res = await client.get('/api/users/me');
145
+ expect(res.data.user.email).toBe('admin@example.com');
146
+ ```
147
+
148
+ ### Request Patching
149
+
150
+ Inject a mock user, override route parameters, or modify the request before it hits the handler:
151
+
152
+ ```typescript
153
+ const client = createTesterAxios(config, {
154
+ requestPatcher: (req) => {
155
+ // Force a specific user for all requests
156
+ req.user = { id: '1', email: 'mock@test.com' };
157
+ return req;
158
+ },
159
+ });
160
+ ```
161
+
162
+ ### Custom Axios Configuration
163
+
164
+ ```typescript
165
+ const client = createTesterAxios(config, {
166
+ axiosConfig: {
167
+ timeout: 5000,
168
+ // Don't throw on 4xx/5xx — useful for testing error responses
169
+ validateStatus: () => true,
170
+ },
171
+ });
172
+
173
+ const res = await client.get('/api/nonexistent');
174
+ expect(res.status).toBe(404);
175
+ ```
176
+
177
+ ## API Reference
178
+
179
+ ### `createTesterAxios(config, options?)`
180
+
181
+ Creates an Axios instance that routes requests in-process to Payload CMS handlers.
182
+
183
+ **Parameters:**
184
+
185
+ | Parameter | Type | Description |
186
+ |-----------|------|-------------|
187
+ | `config` | `SanitizedConfig` | The sanitized config returned by `buildConfig()` |
188
+ | `options.requestPatcher` | `(req: PayloadRequest) => PayloadRequest \| Promise<PayloadRequest>` | Mutate the request before handler execution |
189
+ | `options.axiosConfig` | `AxiosRequestConfig` | Default Axios configuration for the instance |
190
+
191
+ **Returns:** `AxiosInstance` — a standard Axios client.
192
+
193
+ ---
194
+
195
+ ### Exported Utilities
196
+
197
+ | Export | Description |
198
+ |--------|-------------|
199
+ | `createTesterAxios` | Main entry point — creates the test Axios client |
200
+ | `createAxiosAdapter` | Lower-level adapter if you need custom Axios setup |
201
+ | `matchEndpoint` | Manually match a path/method to a Payload endpoint |
202
+ | `matchRoute` | Pattern matching utility for Express-style `:param` routes |
203
+ | `buildPayloadRequest` | Construct a `PayloadRequest` from raw URL, method, headers |
204
+ | `EndpointNotFoundError` | Thrown when no matching endpoint is found |
205
+ | `HandlerExecutionError` | Thrown when a handler throws during execution |
206
+
207
+ ## How It Works
208
+
209
+ ```
210
+ createTesterAxios(config)
211
+
212
+
213
+ Axios Instance
214
+ (custom adapter)
215
+
216
+ client.get('/api/posts/featured')
217
+
218
+
219
+ ┌─────────────────┐
220
+ │ matchEndpoint() │ ← Resolves handler + route params
221
+ └────────┬────────┘
222
+
223
+ ┌─────────────────┐
224
+ │ buildPayload │ ← Creates PayloadRequest with
225
+ │ Request() │ auth, headers, body, transaction
226
+ └────────┬────────┘
227
+
228
+ ┌─────────────────┐
229
+ │ requestPatcher() │ ← Optional mutation hook
230
+ └────────┬────────┘
231
+
232
+ ┌─────────────────┐
233
+ │ handler(req) │ ← Your endpoint handler runs
234
+ └────────┬────────┘
235
+
236
+ ┌─────────────────┐
237
+ │ Response → │ ← Converted to AxiosResponse
238
+ │ AxiosResponse │
239
+ └─────────────────┘
240
+ ```
241
+
242
+ ## Contributing
243
+
244
+ Contributions are welcome! Please feel free to open an issue or submit a pull request.
245
+
246
+ ```bash
247
+ # Clone and install
248
+ git clone https://github.com/000MaDz000/payload-test-api-route-handler.git
249
+ cd payload-test-api-route-handler
250
+ pnpm install
251
+
252
+ # Run tests
253
+ pnpm test
254
+
255
+ # Build
256
+ pnpm build
257
+
258
+ # Lint & format
259
+ pnpm lint
260
+ pnpm format
261
+ ```
262
+
263
+ ## License
264
+
265
+ [MIT](LICENSE) © MaDz
@@ -0,0 +1,11 @@
1
+ import type { AxiosRequestConfig, AxiosResponse } from 'axios';
2
+ import type { SanitizedConfig, PayloadRequest } from 'payload';
3
+ export interface AxiosAdapterOptions {
4
+ config: SanitizedConfig;
5
+ requestPatcher?: (req: PayloadRequest) => PayloadRequest | Promise<PayloadRequest>;
6
+ }
7
+ /**
8
+ * Creates an axios-compatible adapter that dynamically matches and routes requests to Payload CMS handlers
9
+ */
10
+ export declare function createAxiosAdapter(options: AxiosAdapterOptions): (axiosConfig: AxiosRequestConfig) => Promise<AxiosResponse>;
11
+ //# sourceMappingURL=axios-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"axios-adapter.d.ts","sourceRoot":"","sources":["../src/axios-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAK/D,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,eAAe,CAAC;IACxB,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CACpF;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,iBAClC,kBAAkB,KAAG,OAAO,CAAC,aAAa,CAAC,CAwIvE"}
@@ -0,0 +1,112 @@
1
+ import { matchEndpoint } from './match-endpoint.js';
2
+ import { buildPayloadRequest } from './build-request.js';
3
+ import { HandlerExecutionError } from './errors.js';
4
+ /**
5
+ * Creates an axios-compatible adapter that dynamically matches and routes requests to Payload CMS handlers
6
+ */
7
+ export function createAxiosAdapter(options) {
8
+ return async (axiosConfig) => {
9
+ // 1. Build the full URL
10
+ const urlStr = axiosConfig.url ?? '';
11
+ const urlObj = new URL(urlStr, 'http://localhost');
12
+ if (axiosConfig.params) {
13
+ for (const [key, value] of Object.entries(axiosConfig.params)) {
14
+ if (value !== undefined && value !== null) {
15
+ urlObj.searchParams.append(key, typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
16
+ ? String(value)
17
+ : JSON.stringify(value));
18
+ }
19
+ }
20
+ }
21
+ // 2. Resolve request method and headers
22
+ const method = axiosConfig.method ?? 'GET';
23
+ const headersMap = {};
24
+ if (axiosConfig.headers) {
25
+ const headersObj = typeof axiosConfig.headers.toJSON === 'function'
26
+ ? axiosConfig.headers.toJSON()
27
+ : axiosConfig.headers;
28
+ for (const [key, value] of Object.entries(headersObj)) {
29
+ if (value !== undefined &&
30
+ value !== null &&
31
+ (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) {
32
+ headersMap[key] = String(value);
33
+ }
34
+ }
35
+ }
36
+ // 3. Resolve the endpoint handler and matching route parameters dynamically
37
+ const match = matchEndpoint(options.config, urlObj.pathname, method);
38
+ const activeHandler = match.endpoint.handler;
39
+ const matchedParams = match.params;
40
+ let collectionSlug = undefined;
41
+ let globalSlug = undefined;
42
+ if (match.source === 'collection' && match.sourceName) {
43
+ collectionSlug = match.sourceName;
44
+ }
45
+ else if (match.source === 'global' && match.sourceName) {
46
+ globalSlug = match.sourceName;
47
+ }
48
+ // 4. Build standard PayloadRequest
49
+ let req = await buildPayloadRequest(options.config, urlObj.toString(), method, headersMap, axiosConfig.data);
50
+ // 5. Populate route parameters
51
+ req.routeParams = {
52
+ ...matchedParams,
53
+ };
54
+ if (collectionSlug) {
55
+ req.routeParams.collection = collectionSlug;
56
+ req.collection =
57
+ req.payload.collections[collectionSlug];
58
+ }
59
+ if (globalSlug) {
60
+ req.routeParams.global = globalSlug;
61
+ }
62
+ // 6. Run patcher if provided
63
+ if (options.requestPatcher) {
64
+ req = await options.requestPatcher(req);
65
+ }
66
+ // 7. Execute the handler
67
+ let response;
68
+ try {
69
+ response = await activeHandler(req);
70
+ }
71
+ catch (err) {
72
+ throw new HandlerExecutionError(err instanceof Error ? err.message : String(err));
73
+ }
74
+ // 8. Convert response headers
75
+ const responseHeaders = {};
76
+ response.headers.forEach((value, key) => {
77
+ responseHeaders[key] = value;
78
+ });
79
+ // 9. Convert response body
80
+ let responseData;
81
+ const contentType = response.headers.get('content-type');
82
+ if (contentType?.includes('application/json')) {
83
+ const text = await response.text();
84
+ try {
85
+ responseData = JSON.parse(text);
86
+ }
87
+ catch {
88
+ responseData = text;
89
+ }
90
+ }
91
+ else {
92
+ responseData = await response.text();
93
+ }
94
+ const axiosResponse = {
95
+ data: responseData,
96
+ status: response.status,
97
+ statusText: response.statusText,
98
+ headers: responseHeaders,
99
+ config: axiosConfig,
100
+ };
101
+ // 10. Validate status to reject promise (simulating Axios's validateStatus behavior)
102
+ const validateStatus = axiosConfig.validateStatus ?? ((status) => status >= 200 && status < 300);
103
+ if (!validateStatus(response.status)) {
104
+ const error = new Error(`Request failed with status code ${String(response.status)}`);
105
+ error.config = axiosConfig;
106
+ error.response = axiosResponse;
107
+ error.isAxiosError = true;
108
+ throw error;
109
+ }
110
+ return axiosResponse;
111
+ };
112
+ }
@@ -0,0 +1,6 @@
1
+ import type { PayloadRequest, SanitizedConfig } from 'payload';
2
+ /**
3
+ * Build a PayloadRequest from standard URL, method, headers and body
4
+ */
5
+ export declare function buildPayloadRequest(config: SanitizedConfig, url: string, method: string, headersMap: Record<string, string>, body?: unknown): Promise<PayloadRequest>;
6
+ //# sourceMappingURL=build-request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-request.d.ts","sourceRoot":"","sources":["../src/build-request.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/D;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,eAAe,EACvB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,IAAI,CAAC,EAAE,OAAO,GACb,OAAO,CAAC,cAAc,CAAC,CA6BzB"}
@@ -0,0 +1,32 @@
1
+ import { createPayloadRequest } from 'payload';
2
+ /**
3
+ * Build a PayloadRequest from standard URL, method, headers and body
4
+ */
5
+ export async function buildPayloadRequest(config, url, method, headersMap, body) {
6
+ const headers = new Headers();
7
+ for (const [key, value] of Object.entries(headersMap)) {
8
+ headers.set(key, value);
9
+ }
10
+ let bodyInit = undefined;
11
+ if (body !== undefined && body !== null) {
12
+ if (typeof body === 'string' || typeof body === 'number' || typeof body === 'boolean') {
13
+ bodyInit = String(body);
14
+ }
15
+ else {
16
+ bodyInit = JSON.stringify(body);
17
+ if (!headers.has('content-type')) {
18
+ headers.set('content-type', 'application/json');
19
+ }
20
+ }
21
+ }
22
+ // Create standard web Request
23
+ const webRequest = new Request(url, {
24
+ method: method.toUpperCase(),
25
+ headers,
26
+ body: bodyInit,
27
+ });
28
+ return await createPayloadRequest({
29
+ config,
30
+ request: webRequest,
31
+ });
32
+ }
@@ -0,0 +1,7 @@
1
+ export declare class EndpointNotFoundError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare class HandlerExecutionError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B"}
package/dist/errors.js ADDED
@@ -0,0 +1,12 @@
1
+ export class EndpointNotFoundError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'EndpointNotFoundError';
5
+ }
6
+ }
7
+ export class HandlerExecutionError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'HandlerExecutionError';
11
+ }
12
+ }
@@ -0,0 +1,21 @@
1
+ import type { AxiosInstance, AxiosRequestConfig } from 'axios';
2
+ import type { SanitizedConfig, PayloadRequest } from 'payload';
3
+ export * from './errors.js';
4
+ export * from './match-endpoint.js';
5
+ export * from './build-request.js';
6
+ export * from './axios-adapter.js';
7
+ export interface CreateTesterAxiosOptions {
8
+ /**
9
+ * Optional patcher function to mutate standard PayloadRequest before handler execution
10
+ */
11
+ requestPatcher?: (req: PayloadRequest) => PayloadRequest | Promise<PayloadRequest>;
12
+ /**
13
+ * Optional default configuration for the created Axios instance
14
+ */
15
+ axiosConfig?: AxiosRequestConfig;
16
+ }
17
+ /**
18
+ * Creates an AxiosInstance routed directly to Payload CMS handlers in-process
19
+ */
20
+ export declare function createTesterAxios(config: SanitizedConfig, options?: CreateTesterAxiosOptions): AxiosInstance;
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,OAAO,CAAC;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAG/D,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AAEnC,MAAM,WAAW,wBAAwB;IACvC;;OAEG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAEnF;;OAEG;IACH,WAAW,CAAC,EAAE,kBAAkB,CAAC;CAClC;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,eAAe,EACvB,OAAO,CAAC,EAAE,wBAAwB,GACjC,aAAa,CAUf"}
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import axios from 'axios';
2
+ import { createAxiosAdapter } from './axios-adapter.js';
3
+ export * from './errors.js';
4
+ export * from './match-endpoint.js';
5
+ export * from './build-request.js';
6
+ export * from './axios-adapter.js';
7
+ /**
8
+ * Creates an AxiosInstance routed directly to Payload CMS handlers in-process
9
+ */
10
+ export function createTesterAxios(config, options) {
11
+ const adapter = createAxiosAdapter({
12
+ config,
13
+ requestPatcher: options?.requestPatcher,
14
+ });
15
+ return axios.create({
16
+ adapter,
17
+ ...options?.axiosConfig,
18
+ });
19
+ }
@@ -0,0 +1,17 @@
1
+ import type { SanitizedConfig, Endpoint } from 'payload';
2
+ export interface MatchResult {
3
+ endpoint: Endpoint;
4
+ params: Record<string, string>;
5
+ source: 'config' | 'collection' | 'global';
6
+ sourceName?: string;
7
+ }
8
+ /**
9
+ * Match a pattern path with a pathname and extract parameters
10
+ * e.g. pattern="/api/users/:id", pathname="/api/users/123" -> { id: "123" }
11
+ */
12
+ export declare function matchRoute(pattern: string, pathname: string): Record<string, string> | null;
13
+ /**
14
+ * Traverse Payload config endpoints to find a match for pathname and method
15
+ */
16
+ export declare function matchEndpoint(config: SanitizedConfig, pathname: string, method: string): MatchResult;
17
+ //# sourceMappingURL=match-endpoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"match-endpoint.d.ts","sourceRoot":"","sources":["../src/match-endpoint.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAGzD,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,MAAM,EAAE,QAAQ,GAAG,YAAY,GAAG,QAAQ,CAAC;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAuB3F;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,eAAe,EACvB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,WAAW,CA+Db"}
@@ -0,0 +1,86 @@
1
+ import { EndpointNotFoundError } from './errors.js';
2
+ /**
3
+ * Match a pattern path with a pathname and extract parameters
4
+ * e.g. pattern="/api/users/:id", pathname="/api/users/123" -> { id: "123" }
5
+ */
6
+ export function matchRoute(pattern, pathname) {
7
+ const patternParts = pattern.replace(/^\/+/, '').replace(/\/+$/, '').split('/');
8
+ const pathParts = pathname.replace(/^\/+/, '').replace(/\/+$/, '').split('/');
9
+ if (patternParts.length !== pathParts.length) {
10
+ return null;
11
+ }
12
+ const params = {};
13
+ for (let i = 0; i < patternParts.length; i++) {
14
+ const patternPart = patternParts[i];
15
+ const pathPart = pathParts[i];
16
+ if (patternPart.startsWith(':')) {
17
+ const paramName = patternPart.slice(1);
18
+ params[paramName] = decodeURIComponent(pathPart);
19
+ }
20
+ else if (patternPart.toLowerCase() !== pathPart.toLowerCase()) {
21
+ return null;
22
+ }
23
+ }
24
+ return params;
25
+ }
26
+ /**
27
+ * Traverse Payload config endpoints to find a match for pathname and method
28
+ */
29
+ export function matchEndpoint(config, pathname, method) {
30
+ const apiRoute = config.routes.api;
31
+ const reqMethod = method.toLowerCase();
32
+ // 1. Check Global Config Endpoints
33
+ for (const endpoint of config.endpoints) {
34
+ if (endpoint.method.toLowerCase() === reqMethod) {
35
+ let cleanPath = endpoint.path;
36
+ if (cleanPath !== '/' && !cleanPath.startsWith('/')) {
37
+ cleanPath = `/${cleanPath}`;
38
+ }
39
+ const endPath = cleanPath === '/' ? '' : cleanPath;
40
+ const pattern = `${apiRoute}${endPath}`;
41
+ const params = matchRoute(pattern, pathname);
42
+ if (params) {
43
+ return { endpoint, params, source: 'config' };
44
+ }
45
+ }
46
+ }
47
+ // 2. Check Collection Endpoints
48
+ for (const collection of config.collections) {
49
+ if (collection.endpoints) {
50
+ for (const endpoint of collection.endpoints) {
51
+ if (endpoint.method.toLowerCase() === reqMethod) {
52
+ let cleanPath = endpoint.path;
53
+ if (cleanPath !== '/' && !cleanPath.startsWith('/')) {
54
+ cleanPath = `/${cleanPath}`;
55
+ }
56
+ const endPath = cleanPath === '/' ? '' : cleanPath;
57
+ const pattern = `${apiRoute}/${collection.slug}${endPath}`;
58
+ const params = matchRoute(pattern, pathname);
59
+ if (params) {
60
+ return { endpoint, params, source: 'collection', sourceName: collection.slug };
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ // 3. Check Global Config Collection/Global Endpoints
67
+ for (const globalConfig of config.globals) {
68
+ if (globalConfig.endpoints) {
69
+ for (const endpoint of globalConfig.endpoints) {
70
+ if (endpoint.method.toLowerCase() === reqMethod) {
71
+ let cleanPath = endpoint.path;
72
+ if (cleanPath !== '/' && !cleanPath.startsWith('/')) {
73
+ cleanPath = `/${cleanPath}`;
74
+ }
75
+ const endPath = cleanPath === '/' ? '' : cleanPath;
76
+ const pattern = `${apiRoute}/globals/${globalConfig.slug}${endPath}`;
77
+ const params = matchRoute(pattern, pathname);
78
+ if (params) {
79
+ return { endpoint, params, source: 'global', sourceName: globalConfig.slug };
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ throw new EndpointNotFoundError(`No matching endpoint found for pathname "${pathname}" with method "${method.toUpperCase()}"`);
86
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "payload-test-api-route-handler",
3
+ "version": "1.0.0",
4
+ "description": "Test Payload CMS v3+ API route handlers in-process using Axios — no HTTP server needed. Supports auth, transactions, request patching, built-in CRUD endpoints, and custom endpoints.",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "files": [
16
+ "dist",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
20
+ "keywords": [
21
+ "payload",
22
+ "payloadcms",
23
+ "payload-cms",
24
+ "test",
25
+ "testing",
26
+ "api",
27
+ "route-handler",
28
+ "in-process",
29
+ "axios",
30
+ "vitest",
31
+ "jest",
32
+ "integration-test",
33
+ "endpoint",
34
+ "headless-cms"
35
+ ],
36
+ "author": "MaDz",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/000MaDz000/payload-test-api-route-handler.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/000MaDz000/payload-test-api-route-handler/issues"
43
+ },
44
+ "homepage": "https://github.com/000MaDz000/payload-test-api-route-handler#readme",
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ },
48
+ "scripts": {
49
+ "build": "pnpm lint && tsc",
50
+ "dev": "tsc --watch",
51
+ "lint": "pnpm format:check && pnpm lint:eslint && tsc --noEmit",
52
+ "lint:eslint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings=0",
53
+ "format": "prettier --write \"src/**/*.{ts,tsx}\" \"tests/**/*.ts\"",
54
+ "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"tests/**/*.ts\"",
55
+ "test": "vitest run",
56
+ "prepublishOnly": "pnpm build && pnpm test"
57
+ },
58
+ "dependencies": {
59
+ "axios": "^1.17.0"
60
+ },
61
+ "peerDependencies": {
62
+ "payload": "^3.85.1"
63
+ },
64
+ "devDependencies": {
65
+ "@payloadcms/db-sqlite": "^3.85.1",
66
+ "@payloadcms/richtext-lexical": "^3.85.1",
67
+ "@types/node": "^22.5.4",
68
+ "@typescript-eslint/eslint-plugin": "^8.59.3",
69
+ "@typescript-eslint/parser": "^8.59.3",
70
+ "eslint": "^8.57.1",
71
+ "eslint-config-prettier": "^10.1.8",
72
+ "payload": "^3.85.1",
73
+ "prettier": "^3.7.4",
74
+ "typescript": "5.7.3",
75
+ "vitest": "4.1.8"
76
+ }
77
+ }