@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,1004 @@
|
|
|
1
|
+
import { generateContract, renderType } from '../src/codegen-contract.js';
|
|
2
|
+
import type { ContractCodegenContext } from '../src/codegen-contract.js';
|
|
3
|
+
import {
|
|
4
|
+
scalarType,
|
|
5
|
+
arrayType,
|
|
6
|
+
tupleType,
|
|
7
|
+
recordType,
|
|
8
|
+
enumType,
|
|
9
|
+
literalType,
|
|
10
|
+
unionType,
|
|
11
|
+
discriminatedUnionType,
|
|
12
|
+
refType,
|
|
13
|
+
lazyType,
|
|
14
|
+
inlineObjectType,
|
|
15
|
+
field,
|
|
16
|
+
model,
|
|
17
|
+
contractRoot,
|
|
18
|
+
} from './helpers.js';
|
|
19
|
+
import type { ScalarTypeNode } from '@contractkit/core';
|
|
20
|
+
|
|
21
|
+
describe('renderType', () => {
|
|
22
|
+
// ─── Scalar types ───────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('scalar types', () => {
|
|
25
|
+
it('renders z.string()', () => {
|
|
26
|
+
expect(renderType(scalarType('string'))).toBe('z.string()');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders z.string() with min/max', () => {
|
|
30
|
+
expect(renderType(scalarType('string', { min: 1, max: 100 }))).toBe('z.string().min(1).max(100)');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders z.string() with min only', () => {
|
|
34
|
+
expect(renderType(scalarType('string', { min: 5 }))).toBe('z.string().min(5)');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders z.string() with max only', () => {
|
|
38
|
+
expect(renderType(scalarType('string', { max: 50 }))).toBe('z.string().max(50)');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders z.string() with length', () => {
|
|
42
|
+
expect(renderType(scalarType('string', { len: 6 }))).toBe('z.string().length(6)');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders z.string() with regex', () => {
|
|
46
|
+
expect(renderType(scalarType('string', { regex: '[A-Z]+' }))).toBe('z.string().regex(/^[A-Z]+$/)');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders z.string() with regex containing forward slashes', () => {
|
|
50
|
+
expect(renderType(scalarType('string', { regex: 'https?://[^/]+/path' }))).toBe('z.string().regex(/^https?:\\/\\/[^\\/]+\\/path$/)');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('preserves user-supplied ^ and $ anchors instead of double-wrapping', () => {
|
|
54
|
+
expect(renderType(scalarType('string', { regex: '^\\+[1-9]\\d{1,14}$' }))).toBe('z.string().regex(/^\\+[1-9]\\d{1,14}$/)');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('preserves a leading-only ^ anchor', () => {
|
|
58
|
+
expect(renderType(scalarType('string', { regex: '^foo' }))).toBe('z.string().regex(/^foo/)');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('preserves a trailing-only $ anchor', () => {
|
|
62
|
+
expect(renderType(scalarType('string', { regex: 'foo$' }))).toBe('z.string().regex(/foo$/)');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('treats an escaped trailing \\$ as a literal and still auto-anchors', () => {
|
|
66
|
+
expect(renderType(scalarType('string', { regex: 'price:\\$' }))).toBe('z.string().regex(/^price:\\$$/)');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders z.coerce.number()', () => {
|
|
70
|
+
expect(renderType(scalarType('number'))).toBe('z.coerce.number()');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders z.coerce.number() with min', () => {
|
|
74
|
+
expect(renderType(scalarType('number', { min: 0 }))).toBe('z.coerce.number().min(0)');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders z.coerce.number() with min and max', () => {
|
|
78
|
+
expect(renderType(scalarType('number', { min: 0, max: 100 }))).toBe('z.coerce.number().min(0).max(100)');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders z.coerce.number().int()', () => {
|
|
82
|
+
expect(renderType(scalarType('int'))).toBe('z.coerce.number().int()');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders z.coerce.number().int() with constraints', () => {
|
|
86
|
+
expect(renderType(scalarType('int', { min: 1, max: 10 }))).toBe('z.coerce.number().int().min(1).max(10)');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('renders z.bigint() with preprocess coercion from string or bigint', () => {
|
|
90
|
+
expect(renderType(scalarType('bigint'))).toBe(
|
|
91
|
+
`z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, z.bigint())`,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders z.bigint() with constraints using n suffix', () => {
|
|
96
|
+
const result = renderType(scalarType('bigint', { min: 0n, max: 100n }));
|
|
97
|
+
expect(result).toBe(`z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, z.bigint().min(0n).max(100n))`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('renders z.boolean() with string coercion preprocess', () => {
|
|
101
|
+
expect(renderType(scalarType('boolean'))).toBe(`z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('renders DateTime preprocess coercion for date (default format)', () => {
|
|
105
|
+
const result = renderType(scalarType('date'));
|
|
106
|
+
expect(result).toBe(
|
|
107
|
+
`z.preprocess((val) => typeof val === 'string' ? DateTime.fromFormat(val, 'yyyy-MM-dd') : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be a date in format yyyy-MM-dd' }))`,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders DateTime preprocess coercion for date with custom format', () => {
|
|
112
|
+
const result = renderType({ kind: 'scalar', name: 'date', format: 'MM/dd/yyyy' });
|
|
113
|
+
expect(result).toBe(
|
|
114
|
+
`z.preprocess((val) => typeof val === 'string' ? DateTime.fromFormat(val, 'MM/dd/yyyy') : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be a date in format MM/dd/yyyy' }))`,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('renders DateTime preprocess coercion for time (default format)', () => {
|
|
119
|
+
const result = renderType(scalarType('time'));
|
|
120
|
+
expect(result).toBe(
|
|
121
|
+
`z.preprocess((val) => typeof val === 'string' ? DateTime.fromFormat(val, 'HH:mm:ss') : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be a time in format HH:mm:ss' }))`,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('renders DateTime preprocess coercion for time with format', () => {
|
|
126
|
+
const result = renderType({ kind: 'scalar', name: 'time', format: 'HH:mm' });
|
|
127
|
+
expect(result).toBe(
|
|
128
|
+
`z.preprocess((val) => typeof val === 'string' ? DateTime.fromFormat(val, 'HH:mm') : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be a time in format HH:mm' }))`,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders DateTime preprocess coercion for datetime', () => {
|
|
133
|
+
const result = renderType(scalarType('datetime'));
|
|
134
|
+
expect(result).toBe('_ZodDatetime');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('renders Duration preprocess coercion for duration', () => {
|
|
138
|
+
expect(renderType(scalarType('duration'))).toBe(
|
|
139
|
+
`z.preprocess((val) => typeof val === 'string' ? Duration.fromISO(val) : val, z.custom<Duration>((val) => val instanceof Duration && val.isValid, { message: 'Must be an ISO 8601 duration' }))`,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders duration with min constraint', () => {
|
|
144
|
+
expect(renderType(scalarType('duration', { min: 'PT1M' }))).toBe(
|
|
145
|
+
`z.preprocess((val) => typeof val === 'string' ? Duration.fromISO(val) : val, z.custom<Duration>((val) => val instanceof Duration && val.isValid && val.toMillis() >= Duration.fromISO('PT1M').toMillis(), { message: 'Must be an ISO 8601 duration of at least PT1M' }))`,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('renders duration with max constraint', () => {
|
|
150
|
+
expect(renderType(scalarType('duration', { max: 'PT1H' }))).toBe(
|
|
151
|
+
`z.preprocess((val) => typeof val === 'string' ? Duration.fromISO(val) : val, z.custom<Duration>((val) => val instanceof Duration && val.isValid && val.toMillis() <= Duration.fromISO('PT1H').toMillis(), { message: 'Must be an ISO 8601 duration of at most PT1H' }))`,
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('renders duration with min and max constraints', () => {
|
|
156
|
+
expect(renderType(scalarType('duration', { min: 'PT1M', max: 'PT1H' }))).toBe(
|
|
157
|
+
`z.preprocess((val) => typeof val === 'string' ? Duration.fromISO(val) : val, z.custom<Duration>((val) => val instanceof Duration && val.isValid && val.toMillis() >= Duration.fromISO('PT1M').toMillis() && val.toMillis() <= Duration.fromISO('PT1H').toMillis(), { message: 'Must be an ISO 8601 duration between PT1M and PT1H' }))`,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('renders Interval preprocess coercion for interval', () => {
|
|
162
|
+
expect(renderType(scalarType('interval'))).toBe(`_ZodInterval`);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders z.email()', () => {
|
|
166
|
+
expect(renderType(scalarType('email'))).toBe('z.email()');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('renders z.url()', () => {
|
|
170
|
+
expect(renderType(scalarType('url'))).toBe('z.url()');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('renders z.uuid()', () => {
|
|
174
|
+
expect(renderType(scalarType('uuid'))).toBe('z.uuid()');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('renders z.unknown()', () => {
|
|
178
|
+
expect(renderType(scalarType('unknown'))).toBe('z.unknown()');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('renders z.null()', () => {
|
|
182
|
+
expect(renderType(scalarType('null'))).toBe('z.null()');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('renders z.record for object', () => {
|
|
186
|
+
expect(renderType(scalarType('object'))).toBe('z.record(z.string(), z.unknown())');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('renders Buffer custom validator for binary', () => {
|
|
190
|
+
const result = renderType(scalarType('binary'));
|
|
191
|
+
expect(result).toBe('_ZodBinary');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ─── Compound types ─────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe('compound types', () => {
|
|
198
|
+
it('renders array type', () => {
|
|
199
|
+
expect(renderType(arrayType(scalarType('string')))).toBe('z.array(z.string())');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('renders array with constraints', () => {
|
|
203
|
+
expect(renderType(arrayType(scalarType('string'), { min: 1, max: 10 }))).toBe('z.array(z.string()).min(1).max(10)');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('renders tuple type', () => {
|
|
207
|
+
expect(renderType(tupleType(scalarType('number'), scalarType('string')))).toBe('z.tuple([z.coerce.number(), z.string()])');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('renders record type', () => {
|
|
211
|
+
expect(renderType(recordType(scalarType('string'), scalarType('number')))).toBe('z.record(z.string(), z.coerce.number())');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('renders enum type', () => {
|
|
215
|
+
expect(renderType(enumType('a', 'b', 'c'))).toBe('z.enum(["a", "b", "c"])');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('renders literal string', () => {
|
|
219
|
+
expect(renderType(literalType('hello'))).toBe('z.literal("hello")');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('renders literal string with quotes escaped', () => {
|
|
223
|
+
expect(renderType(literalType('say "hi"'))).toBe('z.literal("say \\"hi\\"")');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('renders literal number', () => {
|
|
227
|
+
expect(renderType(literalType(42))).toBe('z.literal(42)');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('renders literal boolean', () => {
|
|
231
|
+
expect(renderType(literalType(true))).toBe('z.literal(true)');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('renders union type', () => {
|
|
235
|
+
expect(renderType(unionType(scalarType('string'), scalarType('number')))).toBe('z.union([z.string(), z.coerce.number()])');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('renders discriminated union as z.discriminatedUnion', () => {
|
|
239
|
+
const result = renderType(discriminatedUnionType('kind', refType('Card'), refType('Bank'), refType('Wire')));
|
|
240
|
+
expect(result).toBe('z.discriminatedUnion("kind", [Card, Bank, Wire])');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('renders model reference as bare name', () => {
|
|
244
|
+
expect(renderType(refType('User'))).toBe('User');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('renders lazy type', () => {
|
|
248
|
+
expect(renderType(lazyType(refType('TreeNode')))).toBe('z.lazy(() => TreeNode)');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('renders inline object type', () => {
|
|
252
|
+
const result = renderType(inlineObjectType([field('key', scalarType('string')), field('value', scalarType('number'))]));
|
|
253
|
+
expect(result).toContain('z.strictObject({');
|
|
254
|
+
expect(result).toContain('key: z.string(),');
|
|
255
|
+
expect(result).toContain('value: z.coerce.number(),');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('generateContract', () => {
|
|
261
|
+
// ─── Simple model ──────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe('simple model', () => {
|
|
264
|
+
it('generates z.strictObject with fields', () => {
|
|
265
|
+
const root = contractRoot([model('User', [field('name', scalarType('string')), field('age', scalarType('number'))])]);
|
|
266
|
+
const output = generateContract(root);
|
|
267
|
+
expect(output).toContain('export const User = z.strictObject({');
|
|
268
|
+
expect(output).toContain('name: z.string(),');
|
|
269
|
+
expect(output).toContain('age: z.coerce.number(),');
|
|
270
|
+
expect(output).toContain('export type User = z.infer<typeof User>;');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('includes zod import', () => {
|
|
274
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'))])]);
|
|
275
|
+
const output = generateContract(root);
|
|
276
|
+
expect(output).toContain("import { z } from 'zod';");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('includes luxon import when DateTime fields exist', () => {
|
|
280
|
+
const root = contractRoot([model('M', [field('d', scalarType('date'))])]);
|
|
281
|
+
const output = generateContract(root);
|
|
282
|
+
expect(output).toContain("import { DateTime } from 'luxon';");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('omits luxon import when no DateTime fields', () => {
|
|
286
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'))])]);
|
|
287
|
+
const output = generateContract(root);
|
|
288
|
+
expect(output).not.toContain('luxon');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('includes Duration import for duration fields', () => {
|
|
292
|
+
const root = contractRoot([model('M', [field('d', scalarType('duration'))])]);
|
|
293
|
+
const output = generateContract(root);
|
|
294
|
+
expect(output).toContain("import { Duration } from 'luxon';");
|
|
295
|
+
expect(output).not.toContain('DateTime');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('includes both DateTime and Duration when both are used', () => {
|
|
299
|
+
const root = contractRoot([model('M', [field('d', scalarType('datetime')), field('t', scalarType('duration'))])]);
|
|
300
|
+
const output = generateContract(root);
|
|
301
|
+
expect(output).toContain("import { DateTime, Duration } from 'luxon';");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('includes Interval import for interval fields', () => {
|
|
305
|
+
const root = contractRoot([model('M', [field('i', scalarType('interval'))])]);
|
|
306
|
+
const output = generateContract(root);
|
|
307
|
+
expect(output).toContain("import { Interval } from 'luxon';");
|
|
308
|
+
expect(output).not.toContain('DateTime');
|
|
309
|
+
expect(output).not.toContain('Duration');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('includes Interval alongside DateTime and Duration when all are used', () => {
|
|
313
|
+
const root = contractRoot([
|
|
314
|
+
model('M', [field('d', scalarType('datetime')), field('t', scalarType('duration')), field('i', scalarType('interval'))]),
|
|
315
|
+
]);
|
|
316
|
+
const output = generateContract(root);
|
|
317
|
+
expect(output).toContain("import { DateTime, Duration, Interval } from 'luxon';");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('emits _ZodInterval helper when interval field present', () => {
|
|
321
|
+
const root = contractRoot([model('M', [field('i', scalarType('interval'))])]);
|
|
322
|
+
const output = generateContract(root);
|
|
323
|
+
expect(output).toContain(
|
|
324
|
+
`const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('detects DateTime in nested array types', () => {
|
|
329
|
+
const root = contractRoot([model('M', [field('d', arrayType(scalarType('datetime')))])]);
|
|
330
|
+
const output = generateContract(root);
|
|
331
|
+
expect(output).toContain("import { DateTime } from 'luxon';");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('emits _ZodBinary helper when binary field present', () => {
|
|
335
|
+
const root = contractRoot([model('M', [field('f', scalarType('binary'))])]);
|
|
336
|
+
const output = generateContract(root);
|
|
337
|
+
expect(output).toContain('const _ZodBinary = z.custom<Buffer>');
|
|
338
|
+
expect(output).toContain('_ZodBinary,');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('omits _ZodBinary helper when no binary fields', () => {
|
|
342
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'))])]);
|
|
343
|
+
const output = generateContract(root);
|
|
344
|
+
expect(output).not.toContain('_ZodBinary');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('emits _ZodDatetime helper when datetime field present', () => {
|
|
348
|
+
const root = contractRoot([model('M', [field('f', scalarType('datetime'))])]);
|
|
349
|
+
const output = generateContract(root);
|
|
350
|
+
expect(output).toContain('const _ZodDatetime =');
|
|
351
|
+
expect(output).toContain('_ZodDatetime,');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('emits _ZodJson helper when json field present', () => {
|
|
355
|
+
const root = contractRoot([model('M', [field('f', scalarType('json'))])]);
|
|
356
|
+
const output = generateContract(root);
|
|
357
|
+
expect(output).toContain('type _JsonValue =');
|
|
358
|
+
expect(output).toContain('const _ZodJson:');
|
|
359
|
+
expect(output).toContain('_ZodJson,');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('omits _ZodJson helper when no json fields', () => {
|
|
363
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'))])]);
|
|
364
|
+
const output = generateContract(root);
|
|
365
|
+
expect(output).not.toContain('_ZodJson');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('renders _ZodJson for json scalar type', () => {
|
|
369
|
+
expect(renderType(scalarType('json'))).toBe('_ZodJson');
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ─── Field rendering ───────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
describe('field rendering', () => {
|
|
376
|
+
it('renders nullable field with .nullable()', () => {
|
|
377
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { nullable: true })])]);
|
|
378
|
+
const output = generateContract(root);
|
|
379
|
+
expect(output).toContain('.nullable()');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('renders optional field with .optional()', () => {
|
|
383
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { optional: true })])]);
|
|
384
|
+
const output = generateContract(root);
|
|
385
|
+
expect(output).toContain('.optional()');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('renders default string value with .default()', () => {
|
|
389
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { default: 'user' })])]);
|
|
390
|
+
const output = generateContract(root);
|
|
391
|
+
expect(output).toContain('.default("user")');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('renders default number value with .default()', () => {
|
|
395
|
+
const root = contractRoot([model('M', [field('f', scalarType('number'), { default: 0 })])]);
|
|
396
|
+
const output = generateContract(root);
|
|
397
|
+
expect(output).toContain('.default(0)');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('renders description with .describe()', () => {
|
|
401
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { description: 'A name' })])]);
|
|
402
|
+
const output = generateContract(root);
|
|
403
|
+
expect(output).toContain('.describe("A name")');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('escapes quotes in default string values', () => {
|
|
407
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { default: 'he said "hello"' })])]);
|
|
408
|
+
const output = generateContract(root);
|
|
409
|
+
expect(output).toContain('.default("he said \\"hello\\"")');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('escapes backslashes in default string values', () => {
|
|
413
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { default: 'path\\to\\file' })])]);
|
|
414
|
+
const output = generateContract(root);
|
|
415
|
+
expect(output).toContain('.default("path\\\\to\\\\file")');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('escapes quotes in field descriptions', () => {
|
|
419
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { description: 'A "quoted" desc' })])]);
|
|
420
|
+
const output = generateContract(root);
|
|
421
|
+
expect(output).toContain('.describe("A \\"quoted\\" desc")');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('escapes newlines in field descriptions', () => {
|
|
425
|
+
const root = contractRoot([model('M', [field('f', scalarType('string'), { description: 'line1\nline2' })])]);
|
|
426
|
+
const output = generateContract(root);
|
|
427
|
+
expect(output).toContain('.describe("line1\\nline2")');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('prefers .default() over .optional() when default is set', () => {
|
|
431
|
+
const root = contractRoot([model('M', [field('f', scalarType('boolean'), { optional: true, default: true })])]);
|
|
432
|
+
const output = generateContract(root);
|
|
433
|
+
expect(output).toContain('.default(true)');
|
|
434
|
+
// .optional() should not appear for this field since default is set
|
|
435
|
+
const fieldLine = output.split('\n').find(l => l.includes('f:'))!;
|
|
436
|
+
expect(fieldLine).not.toContain('.optional()');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ─── Three-schema pattern (visibility) ─────────────────────────
|
|
441
|
+
|
|
442
|
+
describe('three-schema pattern', () => {
|
|
443
|
+
it('generates Base, Read, and Write schemas when visibility fields exist', () => {
|
|
444
|
+
const root = contractRoot([
|
|
445
|
+
model('User', [
|
|
446
|
+
field('id', scalarType('uuid'), { visibility: 'readonly' }),
|
|
447
|
+
field('name', scalarType('string')),
|
|
448
|
+
field('password', scalarType('string'), { visibility: 'writeonly' }),
|
|
449
|
+
]),
|
|
450
|
+
]);
|
|
451
|
+
const output = generateContract(root);
|
|
452
|
+
expect(output).toContain('const UserBase = z.strictObject({');
|
|
453
|
+
expect(output).toContain('export const User = z.strictObject({');
|
|
454
|
+
expect(output).toContain('export const UserInput = z.strictObject({');
|
|
455
|
+
expect(output).toContain('export type User = z.infer<typeof User>;');
|
|
456
|
+
expect(output).toContain('export type UserInput = z.infer<typeof UserInput>;');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('read schema omits writeonly fields', () => {
|
|
460
|
+
const root = contractRoot([
|
|
461
|
+
model('User', [field('name', scalarType('string')), field('password', scalarType('string'), { visibility: 'writeonly' })]),
|
|
462
|
+
]);
|
|
463
|
+
const output = generateContract(root);
|
|
464
|
+
// Find the exported User (read) schema section
|
|
465
|
+
const userSection = output.split('export const User =')[1]!.split('});')[0]!;
|
|
466
|
+
expect(userSection).toContain('name:');
|
|
467
|
+
expect(userSection).not.toContain('password:');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('write schema omits readonly fields', () => {
|
|
471
|
+
const root = contractRoot([
|
|
472
|
+
model('User', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('name', scalarType('string'))]),
|
|
473
|
+
]);
|
|
474
|
+
const output = generateContract(root);
|
|
475
|
+
// Find the UserInput (write) schema section
|
|
476
|
+
const inputSection = output.split('export const UserInput =')[1]!.split('});')[0]!;
|
|
477
|
+
expect(inputSection).toContain('name:');
|
|
478
|
+
expect(inputSection).not.toContain('id:');
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ─── Transitive Input variants ─────────────────────────────────
|
|
483
|
+
|
|
484
|
+
describe('transitive Input variants', () => {
|
|
485
|
+
it('generates Input variant for model that references a visibility model (local)', () => {
|
|
486
|
+
const root = contractRoot([
|
|
487
|
+
model('Entry', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('amount', scalarType('bigint'))]),
|
|
488
|
+
model('Transaction', [field('entries', arrayType(refType('Entry')))]),
|
|
489
|
+
]);
|
|
490
|
+
const output = generateContract(root);
|
|
491
|
+
// Transaction references Entry (which has readonly → EntryInput exists)
|
|
492
|
+
// so Transaction must also get an Input variant
|
|
493
|
+
expect(output).toContain('export const TransactionInput = z.strictObject({');
|
|
494
|
+
expect(output).toContain('export type TransactionInput = z.infer<typeof TransactionInput>;');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('write schema of parent uses Input variant of referenced child', () => {
|
|
498
|
+
const root = contractRoot([
|
|
499
|
+
model('Entry', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('amount', scalarType('bigint'))]),
|
|
500
|
+
model('Transaction', [field('entries', arrayType(refType('Entry')))]),
|
|
501
|
+
]);
|
|
502
|
+
const output = generateContract(root);
|
|
503
|
+
const inputSection = output.split('export const TransactionInput =')[1]!.split('});')[0]!;
|
|
504
|
+
expect(inputSection).toContain('EntryInput');
|
|
505
|
+
expect(inputSection).not.toContain('Entry,');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('handles multi-level transitive chain', () => {
|
|
509
|
+
const root = contractRoot([
|
|
510
|
+
model('Leaf', [field('id', scalarType('uuid'), { visibility: 'readonly' })]),
|
|
511
|
+
model('Middle', [field('leaf', refType('Leaf'))]),
|
|
512
|
+
model('Top', [field('middle', refType('Middle'))]),
|
|
513
|
+
]);
|
|
514
|
+
const output = generateContract(root);
|
|
515
|
+
expect(output).toContain('export const MiddleInput = z.strictObject({');
|
|
516
|
+
expect(output).toContain('export const TopInput = z.strictObject({');
|
|
517
|
+
// TopInput should use MiddleInput; MiddleInput should use LeafInput
|
|
518
|
+
const middleInputSection = output.split('export const MiddleInput =')[1]!.split('});')[0]!;
|
|
519
|
+
expect(middleInputSection).toContain('LeafInput');
|
|
520
|
+
const topInputSection = output.split('export const TopInput =')[1]!.split('});')[0]!;
|
|
521
|
+
expect(topInputSection).toContain('MiddleInput');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('handles transitive ref through union type', () => {
|
|
525
|
+
const root = contractRoot([
|
|
526
|
+
model('Child', [field('id', scalarType('uuid'), { visibility: 'readonly' })]),
|
|
527
|
+
model('Parent', [field('data', unionType(refType('Child'), scalarType('null')))]),
|
|
528
|
+
]);
|
|
529
|
+
const output = generateContract(root);
|
|
530
|
+
expect(output).toContain('export const ParentInput = z.strictObject({');
|
|
531
|
+
const inputSection = output.split('export const ParentInput =')[1]!.split('});')[0]!;
|
|
532
|
+
expect(inputSection).toContain('ChildInput');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('handles transitive ref from external context', () => {
|
|
536
|
+
const root = contractRoot([model('Transaction', [field('entries', arrayType(refType('ExternalEntry')))])]);
|
|
537
|
+
const context: ContractCodegenContext = {
|
|
538
|
+
currentOutPath: '/out/transaction.ts',
|
|
539
|
+
modelOutPaths: new Map([
|
|
540
|
+
['ExternalEntry', '/out/entry.ts'],
|
|
541
|
+
['ExternalEntryInput', '/out/entry.ts'],
|
|
542
|
+
]),
|
|
543
|
+
modelsWithInput: new Set(['ExternalEntry']),
|
|
544
|
+
};
|
|
545
|
+
const output = generateContract(root, context);
|
|
546
|
+
// Transaction should get an Input variant referencing ExternalEntryInput
|
|
547
|
+
expect(output).toContain('export const TransactionInput = z.strictObject({');
|
|
548
|
+
const inputSection = output.split('export const TransactionInput =')[1]!.split('});')[0]!;
|
|
549
|
+
expect(inputSection).toContain('ExternalEntryInput');
|
|
550
|
+
// Should import ExternalEntryInput from the correct path
|
|
551
|
+
expect(output).toContain("import { ExternalEntryInput } from './entry.js';");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('model without visibility that only refs plain models stays simple', () => {
|
|
555
|
+
const root = contractRoot([
|
|
556
|
+
model('PlainChild', [field('name', scalarType('string'))]),
|
|
557
|
+
model('Parent', [field('child', refType('PlainChild'))]),
|
|
558
|
+
]);
|
|
559
|
+
const output = generateContract(root);
|
|
560
|
+
// Neither model has visibility or transitive Input deps
|
|
561
|
+
expect(output).not.toContain('ParentInput');
|
|
562
|
+
expect(output).not.toContain('PlainChildInput');
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// ─── Inheritance ───────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
describe('inheritance', () => {
|
|
569
|
+
it('generates .extend() for models with a base', () => {
|
|
570
|
+
const root = contractRoot([model('Admin', [field('role', scalarType('string'))], { bases: ['User'] })]);
|
|
571
|
+
const output = generateContract(root);
|
|
572
|
+
expect(output).toContain('User.extend({');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('child extends parent in same file: both get three-schema when parent has visibility', () => {
|
|
576
|
+
const root = contractRoot([
|
|
577
|
+
model('User', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('name', scalarType('string'))]),
|
|
578
|
+
model('Admin', [field('role', scalarType('string'))], { bases: ['User'] }),
|
|
579
|
+
]);
|
|
580
|
+
const output = generateContract(root);
|
|
581
|
+
// User has only readonly (no writeonly) — Base === Read, so no UserBase emitted
|
|
582
|
+
expect(output).not.toContain('UserBase');
|
|
583
|
+
expect(output).toContain('export const User =');
|
|
584
|
+
expect(output).toContain('export const UserInput =');
|
|
585
|
+
// Admin inherits from User — also gets three-schema; no writeonly so no AdminBase
|
|
586
|
+
expect(output).not.toContain('AdminBase');
|
|
587
|
+
expect(output).toContain('export const Admin = User.extend({');
|
|
588
|
+
expect(output).toContain('export const AdminInput = UserInput.extend({');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('child with visibility extending parent without visibility: uses .extend() for base, read, and write', () => {
|
|
592
|
+
const root = contractRoot([
|
|
593
|
+
model('User', [field('name', scalarType('string'))]),
|
|
594
|
+
model('Admin', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('role', scalarType('string'))], {
|
|
595
|
+
bases: ['User'],
|
|
596
|
+
}),
|
|
597
|
+
]);
|
|
598
|
+
const output = generateContract(root);
|
|
599
|
+
// User has no visibility — simple schema
|
|
600
|
+
expect(output).not.toContain('UserBase');
|
|
601
|
+
expect(output).not.toContain('UserInput');
|
|
602
|
+
// Admin has only readonly (no writeonly) — Base === Read, so no AdminBase emitted
|
|
603
|
+
expect(output).not.toContain('AdminBase');
|
|
604
|
+
expect(output).toContain('export const Admin = User.extend({');
|
|
605
|
+
expect(output).toContain('export const AdminInput = User.extend({');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('parent with writeonly fields generates Base; child Base extends ParentBase', () => {
|
|
609
|
+
const root = contractRoot([
|
|
610
|
+
model('User', [field('password', scalarType('string'), { visibility: 'writeonly' }), field('name', scalarType('string'))]),
|
|
611
|
+
model('Admin', [field('role', scalarType('string'))], { bases: ['User'] }),
|
|
612
|
+
]);
|
|
613
|
+
const output = generateContract(root);
|
|
614
|
+
// User has writeonly — Base !== Read, so UserBase is emitted
|
|
615
|
+
expect(output).toContain('const UserBase =');
|
|
616
|
+
expect(output).toContain('export const User =');
|
|
617
|
+
expect(output).toContain('export const UserInput =');
|
|
618
|
+
// Admin has no writeonly — no AdminBase; but its Input still extends UserInput
|
|
619
|
+
expect(output).not.toContain('AdminBase');
|
|
620
|
+
expect(output).toContain('export const Admin = User.extend({');
|
|
621
|
+
expect(output).toContain('export const AdminInput = UserInput.extend({');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('child inheriting from external parent with Input variant uses ParentInput.extend()', () => {
|
|
625
|
+
const root = contractRoot([
|
|
626
|
+
model('Admin', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('role', scalarType('string'))], {
|
|
627
|
+
bases: ['User'],
|
|
628
|
+
}),
|
|
629
|
+
]);
|
|
630
|
+
const output = generateContract(root, {
|
|
631
|
+
modelsWithInput: new Set(['User']),
|
|
632
|
+
currentOutPath: '/out/admin.ts',
|
|
633
|
+
modelOutPaths: new Map(),
|
|
634
|
+
});
|
|
635
|
+
expect(output).toContain('export const AdminInput = UserInput.extend({');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('chains .extend() for multi-base inheritance', () => {
|
|
639
|
+
const root = contractRoot([
|
|
640
|
+
model('A', [field('a', scalarType('string'))]),
|
|
641
|
+
model('B', [field('b', scalarType('int'))]),
|
|
642
|
+
model('C', [field('c', scalarType('boolean'))]),
|
|
643
|
+
model('D', [field('d', scalarType('string'))]),
|
|
644
|
+
model('Test5', [field('e', scalarType('string'))], { bases: ['A', 'B', 'C', 'D'] }),
|
|
645
|
+
]);
|
|
646
|
+
const output = generateContract(root);
|
|
647
|
+
expect(output).toContain('export const Test5 = A.extend(B.shape).extend(C.shape).extend(D.shape).extend({');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('chains Input variants for multi-base when bases have Input variants', () => {
|
|
651
|
+
const root = contractRoot([
|
|
652
|
+
model('A', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('a', scalarType('string'))]),
|
|
653
|
+
model('B', [field('b', scalarType('int'))]),
|
|
654
|
+
model('Test', [field('e', scalarType('string'))], { bases: ['A', 'B'] }),
|
|
655
|
+
]);
|
|
656
|
+
const output = generateContract(root);
|
|
657
|
+
// A has Input variant (because of readonly), B doesn't.
|
|
658
|
+
expect(output).toContain('export const TestInput = AInput.extend(B.shape).extend({');
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// ─── Type alias Input variants ────────────────────────────────
|
|
663
|
+
|
|
664
|
+
describe('type alias Input variants', () => {
|
|
665
|
+
it('type alias referencing a model with Input variant gets its own Input variant', () => {
|
|
666
|
+
const root = contractRoot([
|
|
667
|
+
model('Pagination', [field('page', scalarType('int')), field('total', scalarType('int'), { visibility: 'readonly' })]),
|
|
668
|
+
model('ListQuery', [], {
|
|
669
|
+
type: {
|
|
670
|
+
kind: 'intersection',
|
|
671
|
+
members: [
|
|
672
|
+
{ kind: 'ref', name: 'Pagination' },
|
|
673
|
+
{ kind: 'inlineObject', fields: [field('status', scalarType('string'), { optional: true })] },
|
|
674
|
+
],
|
|
675
|
+
},
|
|
676
|
+
}),
|
|
677
|
+
]);
|
|
678
|
+
const output = generateContract(root);
|
|
679
|
+
// ListQuery itself is a type alias — read schema
|
|
680
|
+
expect(output).toContain('export const ListQuery = Pagination.extend({');
|
|
681
|
+
// Input variant uses PaginationInput
|
|
682
|
+
expect(output).toContain('export const ListQueryInput = PaginationInput.extend({');
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('imports PaginationInput when type alias references external Pagination with Input variant', () => {
|
|
686
|
+
const root = contractRoot([
|
|
687
|
+
model('ListQuery', [], {
|
|
688
|
+
type: {
|
|
689
|
+
kind: 'intersection',
|
|
690
|
+
members: [
|
|
691
|
+
{ kind: 'ref', name: 'Pagination' },
|
|
692
|
+
{ kind: 'inlineObject', fields: [field('status', scalarType('string'), { optional: true })] },
|
|
693
|
+
],
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
]);
|
|
697
|
+
const output = generateContract(root, {
|
|
698
|
+
modelsWithInput: new Set(['Pagination']),
|
|
699
|
+
currentOutPath: '/out/list.query.ts',
|
|
700
|
+
modelOutPaths: new Map([
|
|
701
|
+
['Pagination', '/out/pagination.ts'],
|
|
702
|
+
['PaginationInput', '/out/pagination.ts'],
|
|
703
|
+
]),
|
|
704
|
+
});
|
|
705
|
+
expect(output).toContain("import { Pagination } from './pagination.js';");
|
|
706
|
+
expect(output).toContain("import { PaginationInput } from './pagination.js';");
|
|
707
|
+
expect(output).toContain('export const ListQueryInput = PaginationInput.extend({');
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('type alias NOT referencing any model with Input stays simple', () => {
|
|
711
|
+
const root = contractRoot([model('UserId', [], { type: { kind: 'scalar', name: 'uuid' } })]);
|
|
712
|
+
const output = generateContract(root);
|
|
713
|
+
expect(output).toContain('export const UserId = z.uuid()');
|
|
714
|
+
expect(output).not.toContain('UserIdInput');
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// ─── Description ──────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
describe('model description', () => {
|
|
721
|
+
it('generates JSDoc comment for model description', () => {
|
|
722
|
+
const root = contractRoot([model('User', [field('name', scalarType('string'))], { description: 'A user' })]);
|
|
723
|
+
const output = generateContract(root);
|
|
724
|
+
expect(output).toContain('* A user');
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// ─── Source line comments ──────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
describe('source line comments', () => {
|
|
731
|
+
it('includes source location comment above schema', () => {
|
|
732
|
+
const root = contractRoot([model('User', [field('name', scalarType('string'))], { loc: { file: 'user.ck', line: 5 } })]);
|
|
733
|
+
const output = generateContract(root);
|
|
734
|
+
expect(output).toContain('file://./user.ck#L5');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('includes source location for three-schema models', () => {
|
|
738
|
+
const root = contractRoot([
|
|
739
|
+
model('User', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('name', scalarType('string'))], {
|
|
740
|
+
loc: { file: 'user.ck', line: 1 },
|
|
741
|
+
}),
|
|
742
|
+
]);
|
|
743
|
+
const output = generateContract(root);
|
|
744
|
+
expect(output).toContain('file://./user.ck#L1');
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// ─── Model reference imports ──────────────────────────────────
|
|
749
|
+
|
|
750
|
+
describe('model reference imports', () => {
|
|
751
|
+
it('imports externally referenced model types', () => {
|
|
752
|
+
const root = contractRoot([model('Counterparty', [field('accounts', arrayType(refType('CounterpartyAccount')))])]);
|
|
753
|
+
const output = generateContract(root);
|
|
754
|
+
expect(output).toContain("import { CounterpartyAccount } from './counterparty.account.js';");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('does not import locally defined models', () => {
|
|
758
|
+
const root = contractRoot([
|
|
759
|
+
model('CustomCurrency', [field('code', scalarType('string'))]),
|
|
760
|
+
model('LedgerAccount', [field('currency', refType('CustomCurrency'))]),
|
|
761
|
+
]);
|
|
762
|
+
const output = generateContract(root);
|
|
763
|
+
expect(output).not.toContain('import { CustomCurrency }');
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('imports base model when inherited from external', () => {
|
|
767
|
+
const root = contractRoot([model('Admin', [field('role', scalarType('string'))], { bases: ['User'] })]);
|
|
768
|
+
const output = generateContract(root);
|
|
769
|
+
expect(output).toContain("import { User } from './user.js';");
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('does not import base model when defined locally', () => {
|
|
773
|
+
const root = contractRoot([
|
|
774
|
+
model('User', [field('name', scalarType('string'))]),
|
|
775
|
+
model('Admin', [field('role', scalarType('string'))], { bases: ['User'] }),
|
|
776
|
+
]);
|
|
777
|
+
const output = generateContract(root);
|
|
778
|
+
expect(output).not.toContain('import { User }');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('emits no model imports when all refs are local', () => {
|
|
782
|
+
const root = contractRoot([model('User', [field('name', scalarType('string'))])]);
|
|
783
|
+
const output = generateContract(root);
|
|
784
|
+
const importLines = output.split('\n').filter(l => l.startsWith('import'));
|
|
785
|
+
expect(importLines).toHaveLength(1); // only zod
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// ─── Cross-directory import resolution ──────────────────────────
|
|
790
|
+
|
|
791
|
+
describe('cross-directory import resolution', () => {
|
|
792
|
+
it('generates correct relative path for ref in a different directory', () => {
|
|
793
|
+
const root = contractRoot([model('Counterparty', [field('accounts', arrayType(refType('CounterpartyAccount')))])]);
|
|
794
|
+
const context: ContractCodegenContext = {
|
|
795
|
+
currentOutPath: '/out/modules/transfers/counterparty.ts',
|
|
796
|
+
modelOutPaths: new Map([['CounterpartyAccount', '/out/modules/transfers/counterparty.account.ts']]),
|
|
797
|
+
};
|
|
798
|
+
const output = generateContract(root, context);
|
|
799
|
+
expect(output).toContain("import { CounterpartyAccount } from './counterparty.account.js';");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('generates ../ path when ref is in a parent directory', () => {
|
|
803
|
+
const root = contractRoot([model('Invoice', [field('pagination', refType('Pagination'))])]);
|
|
804
|
+
const context: ContractCodegenContext = {
|
|
805
|
+
currentOutPath: '/out/modules/billing/invoice.ts',
|
|
806
|
+
modelOutPaths: new Map([['Pagination', '/out/shared/pagination.ts']]),
|
|
807
|
+
};
|
|
808
|
+
const output = generateContract(root, context);
|
|
809
|
+
expect(output).toContain("import { Pagination } from '../../shared/pagination.js';");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('generates nested ../ path for deeply separated files', () => {
|
|
813
|
+
const root = contractRoot([model('Transfer', [field('account', refType('LedgerAccount'))])]);
|
|
814
|
+
const context: ContractCodegenContext = {
|
|
815
|
+
currentOutPath: '/out/modules/transfers/types/transfer.ts',
|
|
816
|
+
modelOutPaths: new Map([['LedgerAccount', '/out/modules/ledger/types/ledger.account.ts']]),
|
|
817
|
+
};
|
|
818
|
+
const output = generateContract(root, context);
|
|
819
|
+
expect(output).toContain("import { LedgerAccount } from '../../ledger/types/ledger.account.js';");
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('generates subdirectory path when ref is in a child directory', () => {
|
|
823
|
+
const root = contractRoot([model('Dashboard', [field('user', refType('User'))])]);
|
|
824
|
+
const context: ContractCodegenContext = {
|
|
825
|
+
currentOutPath: '/out/dashboard.ts',
|
|
826
|
+
modelOutPaths: new Map([['User', '/out/users/user.ts']]),
|
|
827
|
+
};
|
|
828
|
+
const output = generateContract(root, context);
|
|
829
|
+
expect(output).toContain("import { User } from './users/user.js';");
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('falls back to pascalToDotCase when ref is not in modelOutPaths', () => {
|
|
833
|
+
const root = contractRoot([model('Order', [field('item', refType('UnknownExternal'))])]);
|
|
834
|
+
const context: ContractCodegenContext = {
|
|
835
|
+
currentOutPath: '/out/order.ts',
|
|
836
|
+
modelOutPaths: new Map(), // empty — ref not found
|
|
837
|
+
};
|
|
838
|
+
const output = generateContract(root, context);
|
|
839
|
+
expect(output).toContain("import { UnknownExternal } from './unknown.external.js';");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('falls back to pascalToDotCase when no context is provided', () => {
|
|
843
|
+
const root = contractRoot([model('Counterparty', [field('accounts', arrayType(refType('CounterpartyAccount')))])]);
|
|
844
|
+
const output = generateContract(root); // no context
|
|
845
|
+
expect(output).toContain("import { CounterpartyAccount } from './counterparty.account.js';");
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('resolves multiple refs to different directories', () => {
|
|
849
|
+
const root = contractRoot([model('Transfer', [field('from', refType('Counterparty')), field('pagination', refType('Pagination'))])]);
|
|
850
|
+
const context: ContractCodegenContext = {
|
|
851
|
+
currentOutPath: '/out/modules/transfers/transfer.ts',
|
|
852
|
+
modelOutPaths: new Map([
|
|
853
|
+
['Counterparty', '/out/modules/transfers/counterparty.ts'],
|
|
854
|
+
['Pagination', '/out/shared/pagination.ts'],
|
|
855
|
+
]),
|
|
856
|
+
};
|
|
857
|
+
const output = generateContract(root, context);
|
|
858
|
+
expect(output).toContain("import { Counterparty } from './counterparty.js';");
|
|
859
|
+
expect(output).toContain("import { Pagination } from '../../shared/pagination.js';");
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// ─── format(input=, output=) ────────────────────────────────────
|
|
864
|
+
describe('format modifier', () => {
|
|
865
|
+
it('input=snake: parses snake_case keys, outputs camelCase', () => {
|
|
866
|
+
const root = contractRoot([
|
|
867
|
+
model('User', [field('firstName', scalarType('string')), field('lastName', scalarType('string'))], { inputCase: 'snake' }),
|
|
868
|
+
]);
|
|
869
|
+
const output = generateContract(root);
|
|
870
|
+
expect(output).toContain('first_name: z.string()');
|
|
871
|
+
expect(output).toContain('last_name: z.string()');
|
|
872
|
+
expect(output).toContain('.transform(data => ({');
|
|
873
|
+
expect(output).toContain('firstName: data.first_name');
|
|
874
|
+
expect(output).toContain('lastName: data.last_name');
|
|
875
|
+
expect(output).toContain('export type User = z.output<typeof User>');
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('input=camel: no transform (camel is identity)', () => {
|
|
879
|
+
const root = contractRoot([
|
|
880
|
+
model('User', [field('firstName', scalarType('string')), field('lastName', scalarType('string'))], { inputCase: 'camel' }),
|
|
881
|
+
]);
|
|
882
|
+
const output = generateContract(root);
|
|
883
|
+
expect(output).toContain('firstName: z.string()');
|
|
884
|
+
expect(output).toContain('lastName: z.string()');
|
|
885
|
+
expect(output).not.toContain('.transform(');
|
|
886
|
+
expect(output).toContain('export type User = z.infer<typeof User>');
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it('input=pascal: parses PascalCase keys, outputs camelCase', () => {
|
|
890
|
+
const root = contractRoot([
|
|
891
|
+
model('User', [field('firstName', scalarType('string')), field('lastName', scalarType('string'))], { inputCase: 'pascal' }),
|
|
892
|
+
]);
|
|
893
|
+
const output = generateContract(root);
|
|
894
|
+
expect(output).toContain('FirstName: z.string()');
|
|
895
|
+
expect(output).toContain('LastName: z.string()');
|
|
896
|
+
expect(output).toContain('.transform(data => ({');
|
|
897
|
+
expect(output).toContain('firstName: data.FirstName');
|
|
898
|
+
expect(output).toContain('lastName: data.LastName');
|
|
899
|
+
expect(output).toContain('export type User = z.output<typeof User>');
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('output=snake: parses camelCase keys, outputs snake_case', () => {
|
|
903
|
+
const root = contractRoot([
|
|
904
|
+
model('User', [field('firstName', scalarType('string')), field('lastName', scalarType('string'))], { outputCase: 'snake' }),
|
|
905
|
+
]);
|
|
906
|
+
const output = generateContract(root);
|
|
907
|
+
expect(output).toContain('firstName: z.string()');
|
|
908
|
+
expect(output).toContain('.transform(data => ({');
|
|
909
|
+
expect(output).toContain('first_name: data.firstName');
|
|
910
|
+
expect(output).toContain('last_name: data.lastName');
|
|
911
|
+
// Type uses z.input so the developer-facing shape stays camelCase;
|
|
912
|
+
// the snake_case transform is for serialization to the wire.
|
|
913
|
+
expect(output).toContain('export type User = z.input<typeof User>');
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('input=pascal, output=snake: parses PascalCase, outputs snake_case', () => {
|
|
917
|
+
const root = contractRoot([
|
|
918
|
+
model('User', [field('firstName', scalarType('string')), field('lastName', scalarType('string'))], {
|
|
919
|
+
inputCase: 'pascal',
|
|
920
|
+
outputCase: 'snake',
|
|
921
|
+
}),
|
|
922
|
+
]);
|
|
923
|
+
const output = generateContract(root);
|
|
924
|
+
expect(output).toContain('FirstName: z.string()');
|
|
925
|
+
expect(output).toContain('.transform(data => ({');
|
|
926
|
+
expect(output).toContain('first_name: data.FirstName');
|
|
927
|
+
expect(output).toContain('last_name: data.LastName');
|
|
928
|
+
expect(output).toContain('export type User = z.output<typeof User>');
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('input=pascal: nested inline objects also use PascalCase keys with transforms', () => {
|
|
932
|
+
const dataType = inlineObjectType([field('id', scalarType('uuid')), field('amount', scalarType('number'))]);
|
|
933
|
+
const root = contractRoot([model('Webhook', [field('event', scalarType('string')), field('data', dataType)], { inputCase: 'pascal' })]);
|
|
934
|
+
const output = generateContract(root);
|
|
935
|
+
expect(output).toContain('Event: z.string()');
|
|
936
|
+
expect(output).toContain('Id: z.uuid()');
|
|
937
|
+
expect(output).toContain('id: data.Id');
|
|
938
|
+
expect(output).toContain('amount: data.Amount');
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('mode cascades to inline object fields', () => {
|
|
942
|
+
const dataType = inlineObjectType([field('id', scalarType('uuid')), field('amount', scalarType('number'))]);
|
|
943
|
+
const root = contractRoot([model('Webhook', [field('event', scalarType('string')), field('data', dataType)], { mode: 'loose' })]);
|
|
944
|
+
const output = generateContract(root);
|
|
945
|
+
expect(output).toContain('export const Webhook = z.looseObject({');
|
|
946
|
+
expect(output).toContain('z.looseObject({');
|
|
947
|
+
expect(output).not.toContain('z.strictObject(');
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('output=snake with modelsWithOutput emits a UserOutput alias for the wire shape', () => {
|
|
951
|
+
const root = contractRoot([
|
|
952
|
+
model('User', [field('firstName', scalarType('string')), field('lastName', scalarType('string'))], { outputCase: 'snake' }),
|
|
953
|
+
]);
|
|
954
|
+
const output = generateContract(root, {
|
|
955
|
+
modelOutPaths: new Map(),
|
|
956
|
+
currentOutPath: '/tmp/user.ts',
|
|
957
|
+
modelsWithOutput: new Set(['User']),
|
|
958
|
+
});
|
|
959
|
+
// Base type stays camelCase (developer-facing).
|
|
960
|
+
expect(output).toContain('export type User = z.input<typeof User>');
|
|
961
|
+
// Output alias gives the post-transform wire shape (snake_case).
|
|
962
|
+
expect(output).toContain('export type UserOutput = z.output<typeof User>');
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('models without outputCase do not emit Output alias', () => {
|
|
966
|
+
const root = contractRoot([model('User', [field('firstName', scalarType('string'))])]);
|
|
967
|
+
const output = generateContract(root);
|
|
968
|
+
expect(output).not.toContain('UserOutput');
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it('child extending a format(input=snake) base inlines parent fields and inherits the transform', () => {
|
|
972
|
+
// The parent compiles to z.object().transform() — a ZodPipe that has no .extend().
|
|
973
|
+
// The child must flatten the chain so it can build its own object and re-apply the transform,
|
|
974
|
+
// instead of emitting `Parent.extend({...})` (which fails to type-check).
|
|
975
|
+
const root = contractRoot([
|
|
976
|
+
model('Base', [field('grantType', scalarType('string')), field('clientId', scalarType('uuid'), { optional: true })], {
|
|
977
|
+
inputCase: 'snake',
|
|
978
|
+
}),
|
|
979
|
+
model(
|
|
980
|
+
'Child',
|
|
981
|
+
[
|
|
982
|
+
field('grantType', literalType('client_credentials')),
|
|
983
|
+
field('clientId', scalarType('uuid')),
|
|
984
|
+
field('clientSecret', scalarType('string')),
|
|
985
|
+
],
|
|
986
|
+
{
|
|
987
|
+
bases: ['Base'],
|
|
988
|
+
},
|
|
989
|
+
),
|
|
990
|
+
]);
|
|
991
|
+
const output = generateContract(root);
|
|
992
|
+
// Child must NOT use Base.extend (Base is a ZodPipe).
|
|
993
|
+
expect(output).not.toContain('Base.extend');
|
|
994
|
+
// Child generates as a transformed object with snake_case input keys for both inherited and own fields.
|
|
995
|
+
expect(output).toMatch(
|
|
996
|
+
/export const Child = z\.strictObject\(\{[\s\S]*grant_type:[\s\S]*client_id:[\s\S]*client_secret:[\s\S]*\}\)\.transform/,
|
|
997
|
+
);
|
|
998
|
+
expect(output).toContain('grantType: data.grant_type');
|
|
999
|
+
expect(output).toContain('clientId: data.client_id');
|
|
1000
|
+
expect(output).toContain('clientSecret: data.client_secret');
|
|
1001
|
+
expect(output).toContain('export type Child = z.output<typeof Child>');
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
});
|