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 +21 -0
- package/README.md +265 -0
- package/dist/axios-adapter.d.ts +11 -0
- package/dist/axios-adapter.d.ts.map +1 -0
- package/dist/axios-adapter.js +112 -0
- package/dist/build-request.d.ts +6 -0
- package/dist/build-request.d.ts.map +1 -0
- package/dist/build-request.js +32 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +12 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/match-endpoint.d.ts +17 -0
- package/dist/match-endpoint.d.ts.map +1 -0
- package/dist/match-endpoint.js +86 -0
- package/package.json +77 -0
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
|
+
[](https://www.npmjs.com/package/payload-test-api-route-handler)
|
|
8
|
+
[](https://www.npmjs.com/package/payload-test-api-route-handler)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](https://www.typescriptlang.org/)
|
|
11
|
+
[](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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|