@contractkit/plugin-typescript 0.16.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.
- package/.turbo/turbo-build$colon$ci.log +35 -0
- package/.turbo/turbo-build.log +15 -0
- package/.turbo/turbo-test$colon$ci.log +81 -0
- package/.turbo/turbo-test.log +19 -0
- package/CHANGELOG.md +151 -0
- package/README.md +153 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +1882 -0
- package/coverage/coverage-final.json +9 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/codegen-contract.ts.html +3331 -0
- package/coverage/src/codegen-operation.ts.html +2530 -0
- package/coverage/src/codegen-plain-types.ts.html +901 -0
- package/coverage/src/codegen-sdk.ts.html +2797 -0
- package/coverage/src/index.html +206 -0
- package/coverage/src/index.ts.html +1360 -0
- package/coverage/src/path-utils.ts.html +649 -0
- package/coverage/src/ts-render.ts.html +592 -0
- package/coverage/tests/helpers.ts.html +826 -0
- package/coverage/tests/index.html +116 -0
- package/dist/codegen-contract.d.ts +56 -0
- package/dist/codegen-contract.d.ts.map +1 -0
- package/dist/codegen-operation.d.ts +25 -0
- package/dist/codegen-operation.d.ts.map +1 -0
- package/dist/codegen-plain-types.d.ts +10 -0
- package/dist/codegen-plain-types.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +38 -0
- package/dist/codegen-sdk.d.ts.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3162 -0
- package/dist/index.js.map +1 -0
- package/dist/path-utils.d.ts +15 -0
- package/dist/path-utils.d.ts.map +1 -0
- package/dist/ts-render.d.ts +20 -0
- package/dist/ts-render.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +43 -0
- package/src/codegen-contract.ts +1082 -0
- package/src/codegen-operation.ts +815 -0
- package/src/codegen-plain-types.ts +272 -0
- package/src/codegen-sdk.ts +904 -0
- package/src/index.ts +425 -0
- package/src/path-utils.ts +188 -0
- package/src/ts-render.ts +169 -0
- package/tests/codegen-contract.test.ts +1004 -0
- package/tests/codegen-operation.test.ts +939 -0
- package/tests/codegen-plain-types.test.ts +636 -0
- package/tests/codegen-sdk.test.ts +1500 -0
- package/tests/codegen-server.test.ts +192 -0
- package/tests/helpers.ts +247 -0
- package/tests/pipeline.test.ts +372 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateOp } from '../src/codegen-operation.js';
|
|
3
|
+
import { SECURITY_NONE } from '@contractkit/core';
|
|
4
|
+
import {
|
|
5
|
+
scalarType,
|
|
6
|
+
arrayType,
|
|
7
|
+
refType,
|
|
8
|
+
inlineObjectType,
|
|
9
|
+
field,
|
|
10
|
+
opParam,
|
|
11
|
+
opRequest,
|
|
12
|
+
opMultiRequest,
|
|
13
|
+
opResponse,
|
|
14
|
+
opOperation,
|
|
15
|
+
opRoute,
|
|
16
|
+
opRoot,
|
|
17
|
+
} from './helpers.js';
|
|
18
|
+
|
|
19
|
+
describe('generateOperation', () => {
|
|
20
|
+
// ─── Router name derivation ─────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe('router naming', () => {
|
|
23
|
+
it('derives router name from file path', () => {
|
|
24
|
+
const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
|
|
25
|
+
const output = generateOp(root);
|
|
26
|
+
expect(output).toContain('export const UsersRouter = ServerKitRouter();');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('derives PascalCase name from dotted file', () => {
|
|
30
|
+
const root = opRoot([opRoute('/categories', [opOperation('get')])], 'ledger.categories.op');
|
|
31
|
+
const output = generateOp(root);
|
|
32
|
+
expect(output).toContain('export const LedgerCategoriesRouter = ServerKitRouter();');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ─── Imports ───────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe('imports', () => {
|
|
39
|
+
it('generates zod import when inline params are used', () => {
|
|
40
|
+
const root = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
41
|
+
const output = generateOp(root);
|
|
42
|
+
expect(output).toContain("import { z } from 'zod';");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('omits zod import when no inline schemas are generated', () => {
|
|
46
|
+
const root = opRoot([opRoute('/x', [opOperation('get')])]);
|
|
47
|
+
const output = generateOp(root);
|
|
48
|
+
expect(output).not.toContain("import { z } from 'zod';");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('generates ServerKitRouter import', () => {
|
|
52
|
+
const root = opRoot([opRoute('/x', [opOperation('get')])]);
|
|
53
|
+
const output = generateOp(root);
|
|
54
|
+
expect(output).toContain('ServerKitRouter');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('generates type imports when response references models', () => {
|
|
58
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
59
|
+
const output = generateOp(root);
|
|
60
|
+
expect(output).toContain('User');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('generates parseAndValidate import when route has params', () => {
|
|
64
|
+
const root = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
65
|
+
const output = generateOp(root);
|
|
66
|
+
expect(output).toContain('parseAndValidate');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('generates parseAndValidate import when route has request body', () => {
|
|
70
|
+
const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
|
|
71
|
+
const output = generateOp(root);
|
|
72
|
+
expect(output).toContain('parseAndValidate');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('omits parseAndValidate when no validation needed', () => {
|
|
76
|
+
const root = opRoot([opRoute('/health', [opOperation('get')])]);
|
|
77
|
+
const output = generateOp(root);
|
|
78
|
+
expect(output).not.toContain('parseAndValidate');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('includes luxon DateTime import when query uses datetime type', () => {
|
|
82
|
+
const root = opRoot([
|
|
83
|
+
opRoute('/events', [
|
|
84
|
+
opOperation('get', {
|
|
85
|
+
query: [opParam('since', scalarType('datetime'))],
|
|
86
|
+
}),
|
|
87
|
+
]),
|
|
88
|
+
]);
|
|
89
|
+
const output = generateOp(root);
|
|
90
|
+
expect(output).toContain("import { DateTime } from 'luxon';");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('includes luxon DateTime import when inline param uses date type', () => {
|
|
94
|
+
const root = opRoot([opRoute('/events/{date}', [opOperation('get')], [opParam('date', scalarType('date'))])]);
|
|
95
|
+
const output = generateOp(root);
|
|
96
|
+
expect(output).toContain("import { DateTime } from 'luxon';");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('omits luxon DateTime import when no date/datetime types used', () => {
|
|
100
|
+
const root = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
101
|
+
const output = generateOp(root);
|
|
102
|
+
expect(output).not.toContain('luxon');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── Handler generation — GET ──────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe('GET handlers', () => {
|
|
109
|
+
it('generates list service method for GET without path params', () => {
|
|
110
|
+
const root = opRoot([opRoute('/users', [opOperation('get')])]);
|
|
111
|
+
const output = generateOp(root);
|
|
112
|
+
expect(output).toContain(".get('/users'");
|
|
113
|
+
expect(output).toContain('service.list(');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('generates getById service method for GET with path params', () => {
|
|
117
|
+
const root = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
118
|
+
const output = generateOp(root);
|
|
119
|
+
expect(output).toContain('service.getById(');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── Handler generation — POST ─────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('POST handlers', () => {
|
|
126
|
+
it('generates POST with body parser middleware', () => {
|
|
127
|
+
const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
|
|
128
|
+
const output = generateOp(root);
|
|
129
|
+
expect(output).toContain("bodyParserMiddleware(['json'])");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('generates body validation with parseAndValidate', () => {
|
|
133
|
+
const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
|
|
134
|
+
const output = generateOp(root);
|
|
135
|
+
expect(output).toContain('parseAndValidate(ctx.body, CreateUser)');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('generates create service method for POST', () => {
|
|
139
|
+
const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
|
|
140
|
+
const output = generateOp(root);
|
|
141
|
+
expect(output).toContain('service.create(');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ─── Multi-MIME request bodies ─────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe('multi-MIME request bodies', () => {
|
|
148
|
+
it('emits union of body parser tokens', () => {
|
|
149
|
+
const root = opRoot([
|
|
150
|
+
opRoute('/auth/token', [
|
|
151
|
+
opOperation('post', {
|
|
152
|
+
request: opMultiRequest([
|
|
153
|
+
['application/json', 'AuthRequest'],
|
|
154
|
+
['application/x-www-form-urlencoded', 'AuthRequest'],
|
|
155
|
+
]),
|
|
156
|
+
}),
|
|
157
|
+
]),
|
|
158
|
+
]);
|
|
159
|
+
const output = generateOp(root);
|
|
160
|
+
expect(output).toContain("bodyParserMiddleware(['json', 'urlencoded'])");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('collapses to a single parseAndValidate when body shapes are structurally equal', () => {
|
|
164
|
+
const root = opRoot([
|
|
165
|
+
opRoute('/auth/token', [
|
|
166
|
+
opOperation('post', {
|
|
167
|
+
request: opMultiRequest([
|
|
168
|
+
['application/json', 'AuthRequest'],
|
|
169
|
+
['application/x-www-form-urlencoded', 'AuthRequest'],
|
|
170
|
+
]),
|
|
171
|
+
}),
|
|
172
|
+
]),
|
|
173
|
+
]);
|
|
174
|
+
const output = generateOp(root);
|
|
175
|
+
expect(output).toContain('const body = await parseAndValidate(ctx.body, AuthRequest)');
|
|
176
|
+
expect(output).not.toContain('switch (ctx.request.type)');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('emits switch on ctx.request.type when body shapes differ', () => {
|
|
180
|
+
const root = opRoot([
|
|
181
|
+
opRoute('/uploads', [
|
|
182
|
+
opOperation('post', {
|
|
183
|
+
request: opMultiRequest([
|
|
184
|
+
['application/json', 'UploadMeta'],
|
|
185
|
+
['multipart/form-data', 'UploadFile'],
|
|
186
|
+
]),
|
|
187
|
+
}),
|
|
188
|
+
]),
|
|
189
|
+
]);
|
|
190
|
+
const output = generateOp(root);
|
|
191
|
+
expect(output).toContain('switch (ctx.request.type)');
|
|
192
|
+
expect(output).toContain("case 'application/json':");
|
|
193
|
+
expect(output).toContain("case 'multipart/form-data':");
|
|
194
|
+
expect(output).toContain('body = ctx.body as MultipartBody;');
|
|
195
|
+
expect(output).toContain('body = await parseAndValidate(ctx.body, UploadMeta)');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ─── Handler generation — PUT/PATCH/DELETE ─────────────────────
|
|
200
|
+
|
|
201
|
+
describe('PUT/PATCH/DELETE handlers', () => {
|
|
202
|
+
it('generates replace for PUT', () => {
|
|
203
|
+
const root = opRoot([opRoute('/users', [opOperation('put')])]);
|
|
204
|
+
const output = generateOp(root);
|
|
205
|
+
expect(output).toContain('service.replace(');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('generates update for PATCH', () => {
|
|
209
|
+
const root = opRoot([opRoute('/users', [opOperation('patch')])]);
|
|
210
|
+
const output = generateOp(root);
|
|
211
|
+
expect(output).toContain('service.update(');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('generates delete for DELETE', () => {
|
|
215
|
+
const root = opRoot([opRoute('/users', [opOperation('delete')])]);
|
|
216
|
+
const output = generateOp(root);
|
|
217
|
+
expect(output).toContain('service.delete(');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ─── Params validation ────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe('params validation', () => {
|
|
224
|
+
it('generates params validation block', () => {
|
|
225
|
+
const root = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
226
|
+
const output = generateOp(root);
|
|
227
|
+
expect(output).toContain('parseAndValidate(');
|
|
228
|
+
expect(output).toContain('ctx.params');
|
|
229
|
+
expect(output).toContain('z.strictObject({');
|
|
230
|
+
expect(output).toContain('id: z.uuid()');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('renders param types correctly', () => {
|
|
234
|
+
const root = opRoot([opRoute('/items/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
235
|
+
const output = generateOp(root);
|
|
236
|
+
expect(output).toContain('id: z.uuid()');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('generates type-reference params validation', () => {
|
|
240
|
+
const root = opRoot([opRoute('/users/{id}', [opOperation('get')], 'RouteParams')]);
|
|
241
|
+
const output = generateOp(root);
|
|
242
|
+
expect(output).toContain('parseAndValidate(ctx.params, RouteParams.strict())');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ─── Query validation ────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
describe('query validation', () => {
|
|
249
|
+
it('generates query validation block', () => {
|
|
250
|
+
const root = opRoot([
|
|
251
|
+
opRoute('/users', [
|
|
252
|
+
opOperation('get', {
|
|
253
|
+
query: [opParam('page', scalarType('int')), opParam('limit', scalarType('int'))],
|
|
254
|
+
}),
|
|
255
|
+
]),
|
|
256
|
+
]);
|
|
257
|
+
const output = generateOp(root);
|
|
258
|
+
expect(output).toContain('ctx.query');
|
|
259
|
+
expect(output).toContain('page: z.coerce.number().int()');
|
|
260
|
+
expect(output).toContain('limit: z.coerce.number().int()');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('generates parseAndValidate import when operation has query', () => {
|
|
264
|
+
const root = opRoot([
|
|
265
|
+
opRoute('/users', [
|
|
266
|
+
opOperation('get', {
|
|
267
|
+
query: [opParam('page', scalarType('int'))],
|
|
268
|
+
}),
|
|
269
|
+
]),
|
|
270
|
+
]);
|
|
271
|
+
const output = generateOp(root);
|
|
272
|
+
expect(output).toContain('parseAndValidate');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('generates type-reference query validation', () => {
|
|
276
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { query: 'Pagination' })])]);
|
|
277
|
+
const output = generateOp(root);
|
|
278
|
+
expect(output).toContain('parseAndValidate(ctx.query, Pagination.strict())');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('imports type-reference query type', () => {
|
|
282
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { query: 'Pagination' })])]);
|
|
283
|
+
const output = generateOp(root);
|
|
284
|
+
expect(output).toMatch(/import.*Pagination.*from/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('wraps inline array query params with z.preprocess for single-value coercion', () => {
|
|
288
|
+
const root = opRoot([
|
|
289
|
+
opRoute('/offers', [
|
|
290
|
+
opOperation('get', {
|
|
291
|
+
query: [opParam('status', arrayType(refType('OfferStatus'))), opParam('limit', scalarType('int'))],
|
|
292
|
+
}),
|
|
293
|
+
]),
|
|
294
|
+
]);
|
|
295
|
+
const output = generateOp(root);
|
|
296
|
+
expect(output).toContain('z.preprocess');
|
|
297
|
+
expect(output).toContain("typeof v === 'string' ? v.split(',') : v");
|
|
298
|
+
// Non-array params should not be wrapped
|
|
299
|
+
expect(output).toContain('limit: z.coerce.number().int()');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('imports Input variant for refs inside an intersection query', () => {
|
|
303
|
+
const root = opRoot([
|
|
304
|
+
opRoute('/audit/log', [
|
|
305
|
+
opOperation('get', {
|
|
306
|
+
query: {
|
|
307
|
+
kind: 'intersection',
|
|
308
|
+
members: [
|
|
309
|
+
{ kind: 'ref', name: 'Pagination' },
|
|
310
|
+
{
|
|
311
|
+
kind: 'inlineObject',
|
|
312
|
+
fields: [field('schemaName', scalarType('string'), { optional: true })],
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
316
|
+
} as any,
|
|
317
|
+
}),
|
|
318
|
+
]),
|
|
319
|
+
]);
|
|
320
|
+
const output = generateOp(root, { modelsWithInput: new Set(['Pagination']) });
|
|
321
|
+
expect(output).toContain('PaginationInput.extend({');
|
|
322
|
+
expect(output).toContain('PaginationInput');
|
|
323
|
+
// The import line must include PaginationInput, not just Pagination
|
|
324
|
+
expect(output).toMatch(/import \{[^}]*\bPaginationInput\b[^}]*\} from /);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('wraps ContractTypeNode intersection query with array fields using z.preprocess', () => {
|
|
328
|
+
const root = opRoot([
|
|
329
|
+
opRoute('/offers', [
|
|
330
|
+
opOperation('get', {
|
|
331
|
+
query: {
|
|
332
|
+
kind: 'intersection',
|
|
333
|
+
members: [
|
|
334
|
+
{ kind: 'ref', name: 'Pagination' },
|
|
335
|
+
{
|
|
336
|
+
kind: 'inlineObject',
|
|
337
|
+
fields: [field('status', arrayType(refType('OfferStatus')), { optional: true })],
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
} as any,
|
|
341
|
+
}),
|
|
342
|
+
]),
|
|
343
|
+
]);
|
|
344
|
+
const output = generateOp(root);
|
|
345
|
+
expect(output).toContain('Pagination.extend({');
|
|
346
|
+
expect(output).toContain('z.preprocess');
|
|
347
|
+
expect(output).toContain('.optional()');
|
|
348
|
+
});
|
|
349
|
+
it('coerces inline boolean query params with z.preprocess', () => {
|
|
350
|
+
const root = opRoot([
|
|
351
|
+
opRoute('/users', [
|
|
352
|
+
opOperation('get', {
|
|
353
|
+
query: [opParam('active', scalarType('boolean')), opParam('page', scalarType('int'))],
|
|
354
|
+
}),
|
|
355
|
+
]),
|
|
356
|
+
]);
|
|
357
|
+
const output = generateOp(root);
|
|
358
|
+
// Boolean should use preprocess for string coercion
|
|
359
|
+
expect(output).toContain("active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())");
|
|
360
|
+
// Int should still use z.coerce
|
|
361
|
+
expect(output).toContain('page: z.coerce.number().int()');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ─── Headers validation ─────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
describe('headers validation', () => {
|
|
368
|
+
it('generates headers validation block', () => {
|
|
369
|
+
const root = opRoot([
|
|
370
|
+
opRoute('/users', [
|
|
371
|
+
opOperation('get', {
|
|
372
|
+
headers: [opParam('authorization', scalarType('string'))],
|
|
373
|
+
}),
|
|
374
|
+
]),
|
|
375
|
+
]);
|
|
376
|
+
const output = generateOp(root);
|
|
377
|
+
expect(output).toContain('ctx.headers');
|
|
378
|
+
expect(output).toContain('authorization: z.string()');
|
|
379
|
+
expect(output).toContain('z.object({');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('generates parseAndValidate import when operation has headers', () => {
|
|
383
|
+
const root = opRoot([
|
|
384
|
+
opRoute('/users', [
|
|
385
|
+
opOperation('get', {
|
|
386
|
+
headers: [opParam('authorization', scalarType('string'))],
|
|
387
|
+
}),
|
|
388
|
+
]),
|
|
389
|
+
]);
|
|
390
|
+
const output = generateOp(root);
|
|
391
|
+
expect(output).toContain('parseAndValidate');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('generates type-reference headers validation', () => {
|
|
395
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { headers: 'CommonHeaders' })])]);
|
|
396
|
+
const output = generateOp(root);
|
|
397
|
+
expect(output).toContain('parseAndValidate(ctx.headers, CommonHeaders.strip())');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('uses strict mode for headers when specified', () => {
|
|
401
|
+
const root = opRoot([
|
|
402
|
+
opRoute('/users', [
|
|
403
|
+
opOperation('get', {
|
|
404
|
+
headers: [opParam('authorization', scalarType('string'))],
|
|
405
|
+
headersMode: 'strict',
|
|
406
|
+
}),
|
|
407
|
+
]),
|
|
408
|
+
]);
|
|
409
|
+
const output = generateOp(root);
|
|
410
|
+
expect(output).toContain('z.strictObject({');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('uses strip mode for headers when specified', () => {
|
|
414
|
+
const root = opRoot([
|
|
415
|
+
opRoute('/users', [
|
|
416
|
+
opOperation('get', {
|
|
417
|
+
headers: [opParam('authorization', scalarType('string'))],
|
|
418
|
+
headersMode: 'strip',
|
|
419
|
+
}),
|
|
420
|
+
]),
|
|
421
|
+
]);
|
|
422
|
+
const output = generateOp(root);
|
|
423
|
+
expect(output).toContain('z.object({');
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ─── Request handling ─────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
describe('request handling', () => {
|
|
430
|
+
it('handles multipart/form-data request', () => {
|
|
431
|
+
const root = opRoot([opRoute('/uploads', [opOperation('post', { request: opRequest('Upload', 'multipart/form-data') })])]);
|
|
432
|
+
const output = generateOp(root);
|
|
433
|
+
expect(output).toContain("bodyParserMiddleware(['multipart'])");
|
|
434
|
+
expect(output).toContain('ctx.body as MultipartBody');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ─── Response ─────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
describe('response', () => {
|
|
441
|
+
it('sets status code from response', () => {
|
|
442
|
+
const root = opRoot([
|
|
443
|
+
opRoute('/users', [
|
|
444
|
+
opOperation('post', {
|
|
445
|
+
request: opRequest('CreateUser'),
|
|
446
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
447
|
+
}),
|
|
448
|
+
]),
|
|
449
|
+
]);
|
|
450
|
+
const output = generateOp(root);
|
|
451
|
+
expect(output).toContain('ctx.status = 201');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('sets application/json content type when response has body', () => {
|
|
455
|
+
const root = opRoot([
|
|
456
|
+
opRoute('/users', [
|
|
457
|
+
opOperation('get', {
|
|
458
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
459
|
+
}),
|
|
460
|
+
]),
|
|
461
|
+
]);
|
|
462
|
+
const output = generateOp(root);
|
|
463
|
+
expect(output).toContain("ctx.type = 'application/json'");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('emits handlers for internal operations by default', () => {
|
|
467
|
+
const root = opRoot([opRoute('/secret', [opOperation('get', { responses: [opResponse(200, 'User')] })], undefined, ['internal'])]);
|
|
468
|
+
expect(generateOp(root)).toContain("'/secret'");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('skips internal operations when includeInternal is false', () => {
|
|
472
|
+
const root = opRoot([
|
|
473
|
+
opRoute('/public', [opOperation('get', { responses: [opResponse(200, 'User')] })]),
|
|
474
|
+
opRoute('/secret', [opOperation('get', { responses: [opResponse(200, 'User')] })], undefined, ['internal']),
|
|
475
|
+
]);
|
|
476
|
+
const out = generateOp(root, { includeInternal: false });
|
|
477
|
+
expect(out).toContain("'/public'");
|
|
478
|
+
expect(out).not.toContain("'/secret'");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("uses bodyParser 'text' token for text/* request mimes", () => {
|
|
482
|
+
const root = opRoot([
|
|
483
|
+
opRoute('/notes', [
|
|
484
|
+
opOperation('post', {
|
|
485
|
+
request: opRequest('Note', 'text/plain'),
|
|
486
|
+
responses: [opResponse(204)],
|
|
487
|
+
}),
|
|
488
|
+
]),
|
|
489
|
+
]);
|
|
490
|
+
const output = generateOp(root);
|
|
491
|
+
expect(output).toContain("bodyParserMiddleware(['text'])");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('emits the literal vendor JSON mime on ctx.type when declared', () => {
|
|
495
|
+
const root = opRoot([
|
|
496
|
+
opRoute('/users', [
|
|
497
|
+
opOperation('get', {
|
|
498
|
+
responses: [opResponse(200, 'User', 'application/vnd.api+json')],
|
|
499
|
+
}),
|
|
500
|
+
]),
|
|
501
|
+
]);
|
|
502
|
+
const output = generateOp(root);
|
|
503
|
+
expect(output).toContain("ctx.type = 'application/vnd.api+json'");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('formats array response type annotation', () => {
|
|
507
|
+
const root = opRoot([
|
|
508
|
+
opRoute('/users', [
|
|
509
|
+
opOperation('get', {
|
|
510
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
511
|
+
}),
|
|
512
|
+
]),
|
|
513
|
+
]);
|
|
514
|
+
const output = generateOp(root);
|
|
515
|
+
expect(output).toContain('User[]');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('annotates service result with { body, headers } when response declares headers', () => {
|
|
519
|
+
const root = opRoot([
|
|
520
|
+
opRoute(
|
|
521
|
+
'/transfers/{id}',
|
|
522
|
+
[
|
|
523
|
+
opOperation('get', {
|
|
524
|
+
responses: [
|
|
525
|
+
{
|
|
526
|
+
statusCode: 200,
|
|
527
|
+
contentType: 'application/json',
|
|
528
|
+
bodyType: { kind: 'ref', name: 'Transfer' },
|
|
529
|
+
headers: [
|
|
530
|
+
{ name: 'preference-applied', optional: true, type: { kind: 'scalar', name: 'string' } },
|
|
531
|
+
{ name: 'etag', optional: false, type: { kind: 'scalar', name: 'string' } },
|
|
532
|
+
],
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
}),
|
|
536
|
+
],
|
|
537
|
+
[opParam('id', { kind: 'scalar', name: 'uuid' })],
|
|
538
|
+
),
|
|
539
|
+
]);
|
|
540
|
+
const output = generateOp(root);
|
|
541
|
+
expect(output).toContain('{ body: Transfer; headers: { preferenceApplied?: string; etag: string } }');
|
|
542
|
+
expect(output).toContain('ctx.set(\'etag\', String(result.headers["etag"]))');
|
|
543
|
+
expect(output).toContain(
|
|
544
|
+
'if (result.headers["preferenceApplied"] !== undefined) ctx.set(\'preference-applied\', String(result.headers["preferenceApplied"]))',
|
|
545
|
+
);
|
|
546
|
+
expect(output).toContain('ctx.body = result.body;');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('annotates service result with { headers } for void ops with declared headers', () => {
|
|
550
|
+
const root = opRoot([
|
|
551
|
+
opRoute(
|
|
552
|
+
'/resources/{id}',
|
|
553
|
+
[
|
|
554
|
+
opOperation('delete', {
|
|
555
|
+
responses: [
|
|
556
|
+
{
|
|
557
|
+
statusCode: 204,
|
|
558
|
+
headers: [{ name: 'x-deleted-at', optional: false, type: { kind: 'scalar', name: 'string' } }],
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
}),
|
|
562
|
+
],
|
|
563
|
+
[opParam('id', { kind: 'scalar', name: 'uuid' })],
|
|
564
|
+
),
|
|
565
|
+
]);
|
|
566
|
+
const output = generateOp(root);
|
|
567
|
+
expect(output).toContain('{ headers: { xDeletedAt: string } }');
|
|
568
|
+
expect(output).toContain('ctx.set(\'x-deleted-at\', String(result.headers["xDeletedAt"]))');
|
|
569
|
+
expect(output).not.toContain('ctx.body =');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('defaults to status 200 when no response specified', () => {
|
|
573
|
+
const root = opRoot([opRoute('/users', [opOperation('get')])]);
|
|
574
|
+
const output = generateOp(root);
|
|
575
|
+
expect(output).toContain('ctx.status = 200');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('uses Output variant for result type when response model has format(output=...)', () => {
|
|
579
|
+
const root = opRoot([
|
|
580
|
+
opRoute('/auth/token', [
|
|
581
|
+
opOperation('post', {
|
|
582
|
+
responses: [opResponse(200, 'AuthToken', 'application/json')],
|
|
583
|
+
}),
|
|
584
|
+
]),
|
|
585
|
+
]);
|
|
586
|
+
const output = generateOp(root, { modelsWithOutput: new Set(['AuthToken']) });
|
|
587
|
+
expect(output).toContain('result: AuthTokenOutput');
|
|
588
|
+
expect(output).toContain('AuthTokenOutput');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('uses Output variant for array response when item model has format(output=...)', () => {
|
|
592
|
+
const root = opRoot([
|
|
593
|
+
opRoute('/auth/tokens', [
|
|
594
|
+
opOperation('get', {
|
|
595
|
+
responses: [opResponse(200, 'array(AuthToken)', 'application/json')],
|
|
596
|
+
}),
|
|
597
|
+
]),
|
|
598
|
+
]);
|
|
599
|
+
const output = generateOp(root, { modelsWithOutput: new Set(['AuthToken']) });
|
|
600
|
+
expect(output).toContain('result: AuthTokenOutput[]');
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// ─── Service inference ────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
describe('service inference', () => {
|
|
607
|
+
it('uses explicit service when declared', () => {
|
|
608
|
+
const root = opRoot([opRoute('/users', [opOperation('post', { service: 'LedgerService.updateNesting' })])]);
|
|
609
|
+
const output = generateOp(root);
|
|
610
|
+
expect(output).toContain('service.updateNesting(');
|
|
611
|
+
expect(output).toContain('LedgerService');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('infers service from file name when not declared', () => {
|
|
615
|
+
const root = opRoot([opRoute('/categories', [opOperation('get')])], 'ledger.categories.op');
|
|
616
|
+
const output = generateOp(root);
|
|
617
|
+
expect(output).toContain('LedgerCategoriesService');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('uses meta for service import path when declared', () => {
|
|
621
|
+
const root = opRoot([opRoute('/capital', [opOperation('post', { service: 'LedgerService.disburse' })])], 'capital.op', {
|
|
622
|
+
LedgerService: '#modules/ledger/ledger.service.js',
|
|
623
|
+
});
|
|
624
|
+
const output = generateOp(root);
|
|
625
|
+
expect(output).toContain("import { LedgerService } from '#modules/ledger/ledger.service.js';");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('falls back to deriveModulePath when meta has no entry for service', () => {
|
|
629
|
+
const root = opRoot([opRoute('/capital', [opOperation('get')])], 'capital.op', { SomeOtherService: '#other/path.js' });
|
|
630
|
+
const output = generateOp(root);
|
|
631
|
+
expect(output).toContain("import { CapitalService } from '#modules/capital/capital.service.js';");
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// ─── Type collection ──────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
describe('type collection', () => {
|
|
638
|
+
it('collects PascalCase type names from body types', () => {
|
|
639
|
+
const root = opRoot([
|
|
640
|
+
opRoute('/users', [
|
|
641
|
+
opOperation('post', {
|
|
642
|
+
request: opRequest('CreateUserInput'),
|
|
643
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
644
|
+
}),
|
|
645
|
+
]),
|
|
646
|
+
]);
|
|
647
|
+
const output = generateOp(root);
|
|
648
|
+
// Both types should be imported
|
|
649
|
+
expect(output).toContain('CreateUserInput');
|
|
650
|
+
expect(output).toContain('User');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('unwraps array() in body types for collection', () => {
|
|
654
|
+
const root = opRoot([
|
|
655
|
+
opRoute('/users', [
|
|
656
|
+
opOperation('get', {
|
|
657
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
658
|
+
}),
|
|
659
|
+
]),
|
|
660
|
+
]);
|
|
661
|
+
const output = generateOp(root);
|
|
662
|
+
// User should be in the type import
|
|
663
|
+
expect(output).toMatch(/import.*User.*from/);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// ─── Source line comments ──────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
describe('source line comments', () => {
|
|
670
|
+
it('includes source location in JSDoc above handler', () => {
|
|
671
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { loc: { file: 'users.op', line: 3 } })])], 'users.op');
|
|
672
|
+
const output = generateOp(root);
|
|
673
|
+
expect(output).toContain('file://./users.op#L3');
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// ─── JSDoc from descriptions ────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
describe('JSDoc from descriptions', () => {
|
|
680
|
+
it('generates JSDoc comment from operation description', () => {
|
|
681
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { description: 'List all users' })])]);
|
|
682
|
+
const output = generateOp(root);
|
|
683
|
+
expect(output).toContain('* List all users');
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('falls back to route description when operation has none', () => {
|
|
687
|
+
const root = opRoot([opRoute('/users', [opOperation('get')])]);
|
|
688
|
+
root.routes[0]!.description = 'User routes';
|
|
689
|
+
const output = generateOp(root);
|
|
690
|
+
expect(output).toContain('* User routes');
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('includes source link JSDoc for all handlers', () => {
|
|
694
|
+
const root = opRoot([opRoute('/users', [opOperation('get')])]);
|
|
695
|
+
const output = generateOp(root);
|
|
696
|
+
expect(output).toContain('/**');
|
|
697
|
+
expect(output).toContain('file://');
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// ─── Configurable paths ──────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
describe('configurable paths', () => {
|
|
704
|
+
it('uses custom service path template', () => {
|
|
705
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { service: 'UserService.list' })])]);
|
|
706
|
+
const output = generateOp(root, {
|
|
707
|
+
servicePathTemplate: '@services/{kebab}.service.js',
|
|
708
|
+
});
|
|
709
|
+
expect(output).toContain("from '@services/user.service.js'");
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('uses custom type import path template', () => {
|
|
713
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
714
|
+
const output = generateOp(root, {
|
|
715
|
+
typeImportPathTemplate: '@types/{module}/index.js',
|
|
716
|
+
});
|
|
717
|
+
expect(output).toContain("from '@types/users/index.js'");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('falls back to default paths when no template provided', () => {
|
|
721
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { service: 'UserService.list' })])]);
|
|
722
|
+
const output = generateOp(root);
|
|
723
|
+
expect(output).toContain("from '#modules/user/user.service.js'");
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
describe('generateOp — route modifiers JSDoc', () => {
|
|
729
|
+
it('adds @internal to JSDoc for internal operation', () => {
|
|
730
|
+
const root = opRoot([opRoute('/admin/users', [opOperation('get', { modifiers: ['internal'] })])]);
|
|
731
|
+
const out = generateOp(root);
|
|
732
|
+
expect(out).toContain('* @internal');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('adds @deprecated to JSDoc for deprecated operation', () => {
|
|
736
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { modifiers: ['deprecated'] })])]);
|
|
737
|
+
const out = generateOp(root);
|
|
738
|
+
expect(out).toContain('* @deprecated');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('inherits route-level internal modifier for JSDoc', () => {
|
|
742
|
+
const root = opRoot([opRoute('/admin', [opOperation('get')], undefined, ['internal'])]);
|
|
743
|
+
const out = generateOp(root);
|
|
744
|
+
expect(out).toContain('* @internal');
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('operation modifier overrides route modifier in JSDoc', () => {
|
|
748
|
+
const root = opRoot([opRoute('/admin', [opOperation('get', { modifiers: ['deprecated'] })], undefined, ['internal'])]);
|
|
749
|
+
const out = generateOp(root);
|
|
750
|
+
expect(out).toContain('* @deprecated');
|
|
751
|
+
expect(out).not.toContain('* @internal');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('still generates router handler for internal operations', () => {
|
|
755
|
+
const root = opRoot([opRoute('/admin/users', [opOperation('get', { modifiers: ['internal'] })])]);
|
|
756
|
+
const out = generateOp(root);
|
|
757
|
+
// Handler is always generated (internal only affects SDK/docs)
|
|
758
|
+
expect(out).toContain("UsersRouter.get('/admin/users'");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// ─── Security JSDoc ────────────────────────────────────────────
|
|
762
|
+
|
|
763
|
+
describe('security JSDoc', () => {
|
|
764
|
+
it('emits anonymous access, no security required for security: none', () => {
|
|
765
|
+
const op = opOperation('get', { security: SECURITY_NONE });
|
|
766
|
+
const root = opRoot([opRoute('/health', [op])]);
|
|
767
|
+
const out = generateOp(root);
|
|
768
|
+
expect(out).toContain('anonymous access, no security required');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('emits no annotation for security with roles', () => {
|
|
772
|
+
const op = opOperation('get', {
|
|
773
|
+
security: { roles: ['admin'], loc: { file: 'test.op', line: 1 } },
|
|
774
|
+
});
|
|
775
|
+
const root = opRoot([opRoute('/users', [op])]);
|
|
776
|
+
const out = generateOp(root);
|
|
777
|
+
expect(out).not.toContain('@authenticated');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('emits no annotation for operation with signature', () => {
|
|
781
|
+
const op = opOperation('post', {
|
|
782
|
+
signature: 'hmac-sha256',
|
|
783
|
+
request: opRequest('Payload'),
|
|
784
|
+
});
|
|
785
|
+
const root = opRoot([opRoute('/webhooks', [op])]);
|
|
786
|
+
const out = generateOp(root);
|
|
787
|
+
expect(out).not.toContain('@authenticated');
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('emits no annotation when security is not set', () => {
|
|
791
|
+
const op = opOperation('get');
|
|
792
|
+
const root = opRoot([opRoute('/users', [op])]);
|
|
793
|
+
const out = generateOp(root);
|
|
794
|
+
expect(out).not.toContain('anonymous access, no security required');
|
|
795
|
+
expect(out).not.toContain('@authenticated');
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// ─── Signature middleware ───────────────────────────────────────
|
|
800
|
+
|
|
801
|
+
describe('signature middleware', () => {
|
|
802
|
+
it('injects requireSignature middleware and imports it when signature is set', () => {
|
|
803
|
+
const op = opOperation('post', {
|
|
804
|
+
signature: 'MODERN_TREASURY_WEBHOOK',
|
|
805
|
+
request: opRequest('Payload'),
|
|
806
|
+
});
|
|
807
|
+
const root = opRoot([opRoute('/webhooks', [op])]);
|
|
808
|
+
const out = generateOp(root);
|
|
809
|
+
expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity, requireSignature }`);
|
|
810
|
+
expect(out).toContain(`requireSignature('MODERN_TREASURY_WEBHOOK')`);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('places requireSignature after bodyParserMiddleware in the route line', () => {
|
|
814
|
+
const op = opOperation('post', {
|
|
815
|
+
signature: 'MY_KEY',
|
|
816
|
+
request: opRequest('Payload'),
|
|
817
|
+
});
|
|
818
|
+
const root = opRoot([opRoute('/webhooks', [op])]);
|
|
819
|
+
const routeLine = generateOp(root)
|
|
820
|
+
.split('\n')
|
|
821
|
+
.find(l => l.includes('.post('));
|
|
822
|
+
expect(routeLine).toBeDefined();
|
|
823
|
+
const sigIdx = routeLine!.indexOf(`requireSignature('MY_KEY')`);
|
|
824
|
+
const bodyIdx = routeLine!.indexOf(`bodyParserMiddleware`);
|
|
825
|
+
expect(sigIdx).toBeGreaterThan(-1);
|
|
826
|
+
expect(sigIdx).toBeGreaterThan(bodyIdx);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('does not import requireSignature when no signature is set', () => {
|
|
830
|
+
const op = opOperation('get', {
|
|
831
|
+
security: { roles: ['admin'], loc: { file: 'test.op', line: 1 } },
|
|
832
|
+
});
|
|
833
|
+
const root = opRoot([opRoute('/users', [op])]);
|
|
834
|
+
const out = generateOp(root);
|
|
835
|
+
expect(out).not.toContain('requireSignature');
|
|
836
|
+
expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity }`);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// ─── Security (roles) middleware ────────────────────────────────
|
|
841
|
+
|
|
842
|
+
describe('security middleware', () => {
|
|
843
|
+
it('injects requireSecurity() with no args for unannotated routes', () => {
|
|
844
|
+
const op = opOperation('get');
|
|
845
|
+
const root = opRoot([opRoute('/users', [op])]);
|
|
846
|
+
const out = generateOp(root);
|
|
847
|
+
expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity }`);
|
|
848
|
+
expect(out).toContain(`requireSecurity({ })`);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('injects requireSecurity with roles when roles are set', () => {
|
|
852
|
+
const op = opOperation('get', {
|
|
853
|
+
security: { roles: ['admin'], loc: { file: 'test.op', line: 1 } },
|
|
854
|
+
});
|
|
855
|
+
const root = opRoot([opRoute('/users', [op])]);
|
|
856
|
+
const out = generateOp(root);
|
|
857
|
+
expect(out).toContain(`requireSecurity({ roles: ['admin'] })`);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it('passes multiple roles as an array', () => {
|
|
861
|
+
const op = opOperation('get', {
|
|
862
|
+
security: { roles: ['admin', 'support'], loc: { file: 'test.op', line: 1 } },
|
|
863
|
+
});
|
|
864
|
+
const root = opRoot([opRoute('/users', [op])]);
|
|
865
|
+
const routeLine = generateOp(root)
|
|
866
|
+
.split('\n')
|
|
867
|
+
.find(l => l.includes('.get('));
|
|
868
|
+
expect(routeLine).toContain(`requireSecurity({ roles: ['admin', 'support'] })`);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('does not inject requireSecurity for public (security: none) routes', () => {
|
|
872
|
+
const op = opOperation('get', { security: SECURITY_NONE });
|
|
873
|
+
const root = opRoot([opRoute('/health', [op])]);
|
|
874
|
+
const out = generateOp(root);
|
|
875
|
+
expect(out).not.toContain('requireSecurity');
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('does not import requireSecurity when all routes are public', () => {
|
|
879
|
+
const op = opOperation('get', { security: SECURITY_NONE });
|
|
880
|
+
const root = opRoot([opRoute('/health', [op])]);
|
|
881
|
+
const out = generateOp(root);
|
|
882
|
+
expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware }`);
|
|
883
|
+
expect(out).not.toContain('requireSecurity');
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('places requireSecurity before bodyParserMiddleware in the route line', () => {
|
|
887
|
+
const op = opOperation('post', {
|
|
888
|
+
security: { roles: ['admin'], loc: { file: 'test.op', line: 1 } },
|
|
889
|
+
request: opRequest('Payload'),
|
|
890
|
+
});
|
|
891
|
+
const root = opRoot([opRoute('/users', [op])]);
|
|
892
|
+
const routeLine = generateOp(root)
|
|
893
|
+
.split('\n')
|
|
894
|
+
.find(l => l.includes('.post('));
|
|
895
|
+
expect(routeLine).toBeDefined();
|
|
896
|
+
const secIdx = routeLine!.indexOf(`requireSecurity`);
|
|
897
|
+
const bodyIdx = routeLine!.indexOf(`bodyParserMiddleware`);
|
|
898
|
+
expect(secIdx).toBeGreaterThan(-1);
|
|
899
|
+
expect(bodyIdx).toBeGreaterThan(secIdx);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('places requireSecurity before requireSignature when both are set', () => {
|
|
903
|
+
const op = opOperation('post', {
|
|
904
|
+
signature: 'MY_KEY',
|
|
905
|
+
security: { roles: ['admin'], loc: { file: 'test.op', line: 1 } },
|
|
906
|
+
request: opRequest('Payload'),
|
|
907
|
+
});
|
|
908
|
+
const root = opRoot([opRoute('/webhooks', [op])]);
|
|
909
|
+
const routeLine = generateOp(root)
|
|
910
|
+
.split('\n')
|
|
911
|
+
.find(l => l.includes('.post('));
|
|
912
|
+
expect(routeLine).toBeDefined();
|
|
913
|
+
const secIdx = routeLine!.indexOf(`requireSecurity`);
|
|
914
|
+
const sigIdx = routeLine!.indexOf(`requireSignature`);
|
|
915
|
+
expect(secIdx).toBeGreaterThan(-1);
|
|
916
|
+
expect(sigIdx).toBeGreaterThan(secIdx);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it('imports both requireSecurity and requireSignature when both are set', () => {
|
|
920
|
+
const op = opOperation('post', {
|
|
921
|
+
signature: 'MY_KEY',
|
|
922
|
+
security: { roles: ['admin'], loc: { file: 'test.op', line: 1 } },
|
|
923
|
+
request: opRequest('Payload'),
|
|
924
|
+
});
|
|
925
|
+
const root = opRoot([opRoute('/webhooks', [op])]);
|
|
926
|
+
const out = generateOp(root);
|
|
927
|
+
expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity, requireSignature }`);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('works with route-level roles security', () => {
|
|
931
|
+
const op = opOperation('get');
|
|
932
|
+
const route = opRoute('/users', [op]);
|
|
933
|
+
route.security = { roles: ['admin'], loc: { file: 'test.op', line: 1 } };
|
|
934
|
+
const root = opRoot([route]);
|
|
935
|
+
const out = generateOp(root);
|
|
936
|
+
expect(out).toContain(`requireSecurity({ roles: ['admin'] })`);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
});
|