@martel/calyx 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import { CalyxApplication } from '../http/application.ts';
2
+
3
+ export class DocumentBuilder {
4
+ private document: any = {
5
+ openapi: '3.0.0',
6
+ info: {
7
+ title: 'Calyx Application',
8
+ version: '1.0.0',
9
+ description: '',
10
+ },
11
+ paths: {},
12
+ components: {
13
+ schemas: {},
14
+ },
15
+ };
16
+
17
+ setTitle(title: string) {
18
+ this.document.info.title = title;
19
+ return this;
20
+ }
21
+
22
+ setVersion(version: string) {
23
+ this.document.info.version = version;
24
+ return this;
25
+ }
26
+
27
+ setDescription(description: string) {
28
+ this.document.info.description = description;
29
+ return this;
30
+ }
31
+
32
+ build() {
33
+ return this.document;
34
+ }
35
+ }
36
+
37
+ export class SwaggerModule {
38
+ static createDocument(app: CalyxApplication, config: any): any {
39
+ const document = { ...config };
40
+ if (!document.paths) document.paths = {};
41
+ if (!document.components) document.components = {};
42
+ if (!document.components.schemas) document.components.schemas = {};
43
+
44
+ const routes = app.getRoutes();
45
+
46
+ for (const route of routes) {
47
+ const { method, path, handler } = route;
48
+
49
+ const swaggerPath = path.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
50
+
51
+ if (!document.paths[swaggerPath]) {
52
+ document.paths[swaggerPath] = {};
53
+ }
54
+
55
+ const operationMeta =
56
+ Reflect.getMetadata('calyx:api_operation', handler.controllerClass.prototype, handler.methodName) || {};
57
+ const tags =
58
+ Reflect.getMetadata('calyx:api_tags', handler.controllerClass.prototype, handler.methodName) ||
59
+ Reflect.getMetadata('calyx:api_tags', handler.controllerClass) ||
60
+ [];
61
+ const responsesMeta =
62
+ Reflect.getMetadata('calyx:api_responses', handler.controllerClass.prototype, handler.methodName) || [];
63
+
64
+ const pathParams = [...path.matchAll(/:([a-zA-Z0-9_]+)/g)].map((m) => m[1]);
65
+
66
+ const parameters = pathParams.map((name) => ({
67
+ name,
68
+ in: 'path',
69
+ required: true,
70
+ schema: { type: 'string' },
71
+ }));
72
+
73
+ const responses: Record<string, any> = {};
74
+ if (responsesMeta.length > 0) {
75
+ for (const res of responsesMeta) {
76
+ responses[String(res.status)] = {
77
+ description: res.description,
78
+ };
79
+ if (res.type) {
80
+ const schemaName = res.type.name;
81
+ responses[String(res.status)].content = {
82
+ 'application/json': {
83
+ schema: { $ref: `#/components/schemas/${schemaName}` },
84
+ },
85
+ };
86
+ if (!document.components.schemas[schemaName]) {
87
+ const props = Reflect.getMetadata('calyx:api_properties', res.type) || [];
88
+ const schemaProps: Record<string, any> = {};
89
+ for (const p of props) {
90
+ schemaProps[p.propertyKey] = {
91
+ type: p.type ? p.type.name.toLowerCase() : 'string',
92
+ description: p.description,
93
+ };
94
+ }
95
+ document.components.schemas[schemaName] = {
96
+ type: 'object',
97
+ properties: schemaProps,
98
+ };
99
+ }
100
+ }
101
+ }
102
+ } else {
103
+ responses['200'] = { description: 'OK' };
104
+ }
105
+
106
+ document.paths[swaggerPath][method.toLowerCase()] = {
107
+ summary: operationMeta.summary || '',
108
+ description: operationMeta.description || '',
109
+ tags,
110
+ parameters,
111
+ responses,
112
+ };
113
+ }
114
+
115
+ return document;
116
+ }
117
+
118
+ static setup(path: string, app: CalyxApplication, document: any) {
119
+ const jsonPath = `/${path}-json`.replace(/\/\/+/g, '/');
120
+ const uiPath = `/${path}`.replace(/\/\/+/g, '/');
121
+
122
+ app.use((req: any, res: any, next: any) => {
123
+ const url = new URL(req.url);
124
+ if (url.pathname === jsonPath && req.method === 'GET') {
125
+ res.status(200);
126
+ res.set('content-type', 'application/json');
127
+ res.send(JSON.stringify(document));
128
+ return;
129
+ }
130
+ next();
131
+ });
132
+
133
+ const html = `
134
+ <!DOCTYPE html>
135
+ <html lang="en">
136
+ <head>
137
+ <meta charset="utf-8" />
138
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
139
+ <title>Calyx Swagger UI</title>
140
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui.css" />
141
+ </head>
142
+ <body>
143
+ <div id="swagger-ui"></div>
144
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-bundle.js"></script>
145
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-standalone-preset.js"></script>
146
+ <script>
147
+ window.onload = () => {
148
+ window.ui = SwaggerUIBundle({
149
+ url: '${jsonPath}',
150
+ dom_id: '#swagger-ui',
151
+ presets: [
152
+ SwaggerUIBundle.presets.apis,
153
+ SwaggerUIStandalonePreset
154
+ ],
155
+ layout: "BaseLayout"
156
+ });
157
+ };
158
+ </script>
159
+ </body>
160
+ </html>
161
+ `;
162
+
163
+ app.use((req: any, res: any, next: any) => {
164
+ const url = new URL(req.url);
165
+ if (url.pathname === uiPath && req.method === 'GET') {
166
+ res.status(200);
167
+ res.set('content-type', 'text/html');
168
+ res.send(html);
169
+ return;
170
+ }
171
+ next();
172
+ });
173
+ }
174
+ }
@@ -0,0 +1,124 @@
1
+ import 'reflect-metadata';
2
+ import { ValidationRule } from './decorators.ts';
3
+
4
+ export class ValidationCompiler {
5
+ private static readonly compiledValidators = new Map<any, (obj: any) => string[] | null>();
6
+
7
+ static compile(dtoClass: any): (obj: any) => string[] | null {
8
+ if (this.compiledValidators.has(dtoClass)) {
9
+ return this.compiledValidators.get(dtoClass)!;
10
+ }
11
+
12
+ const rules: ValidationRule[] = Reflect.getMetadata('calyx:validation_rules', dtoClass) || [];
13
+
14
+ const rulesByProp = new Map<string, ValidationRule[]>();
15
+ for (const rule of rules) {
16
+ let list = rulesByProp.get(rule.propertyKey);
17
+ if (!list) {
18
+ list = [];
19
+ rulesByProp.set(rule.propertyKey, list);
20
+ }
21
+ list.push(rule);
22
+ }
23
+
24
+ const codeParts: string[] = ['const errors = [];'];
25
+
26
+ for (const [prop, propRules] of rulesByProp.entries()) {
27
+ const isOptional = propRules.some((r) => r.type === 'optional');
28
+
29
+ const propCode: string[] = [];
30
+ for (const rule of propRules) {
31
+ if (rule.type === 'optional') continue;
32
+
33
+ if (rule.type === 'string') {
34
+ propCode.push(`if (typeof obj.${prop} !== 'string') errors.push('${prop} must be a string');`);
35
+ } else if (rule.type === 'number') {
36
+ propCode.push(`if (typeof obj.${prop} !== 'number' || isNaN(obj.${prop})) errors.push('${prop} must be a number');`);
37
+ } else if (rule.type === 'email') {
38
+ propCode.push(
39
+ `if (typeof obj.${prop} !== 'string' || !obj.${prop}.includes('@')) errors.push('${prop} must be a valid email');`
40
+ );
41
+ }
42
+ }
43
+
44
+ if (isOptional) {
45
+ codeParts.push(`if (obj.${prop} !== undefined && obj.${prop} !== null) {
46
+ ${propCode.join('\n')}
47
+ }`);
48
+ } else {
49
+ codeParts.push(`if (obj.${prop} === undefined || obj.${prop} === null) {
50
+ errors.push('${prop} should not be empty');
51
+ } else {
52
+ ${propCode.join('\n')}
53
+ }`);
54
+ }
55
+ }
56
+
57
+ codeParts.push('return errors.length > 0 ? errors : null;');
58
+
59
+ const fnBody = codeParts.join('\n');
60
+ try {
61
+ const validator = new Function('obj', fnBody) as (obj: any) => string[] | null;
62
+ this.compiledValidators.set(dtoClass, validator);
63
+ return validator;
64
+ } catch (err) {
65
+ console.error('Failed to compile validation JIT for class:', dtoClass.name || dtoClass);
66
+ console.error('Code body:', fnBody);
67
+ throw err;
68
+ }
69
+ }
70
+ }
71
+
72
+ export class SerializationCompiler {
73
+ private static readonly compiledSerializers = new Map<any, (obj: any) => string>();
74
+
75
+ static compile(dtoClass: any): (obj: any) => string {
76
+ if (this.compiledSerializers.has(dtoClass)) {
77
+ return this.compiledSerializers.get(dtoClass)!;
78
+ }
79
+
80
+ const excludes: Set<string> = Reflect.getMetadata('calyx:exclude_properties', dtoClass) || new Set();
81
+
82
+ const rules: ValidationRule[] = Reflect.getMetadata('calyx:validation_rules', dtoClass) || [];
83
+ const exposedKeys = new Set(rules.map((r) => r.propertyKey));
84
+
85
+ const exposes: Set<string> = Reflect.getMetadata('calyx:expose_properties', dtoClass) || new Set();
86
+ for (const exp of exposes) {
87
+ exposedKeys.add(exp);
88
+ }
89
+
90
+ const keys = Array.from(exposedKeys).filter((k) => !excludes.has(k));
91
+
92
+ const jsonParts: string[] = [];
93
+ for (const key of keys) {
94
+ const propRules = rules.filter((r) => r.propertyKey === key);
95
+ const isNumber = propRules.some((r) => r.type === 'number');
96
+ const isString = propRules.some((r) => r.type === 'string');
97
+
98
+ if (isNumber) {
99
+ jsonParts.push(`"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : obj.${key}}`);
100
+ } else if (isString) {
101
+ jsonParts.push(
102
+ `"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : JSON.stringify(obj.${key})}`
103
+ );
104
+ } else {
105
+ jsonParts.push(
106
+ `"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : JSON.stringify(obj.${key})}`
107
+ );
108
+ }
109
+ }
110
+
111
+ const fnBody = `
112
+ return \`{${jsonParts.join(',')}}\`;
113
+ `;
114
+
115
+ try {
116
+ const serializer = new Function('obj', fnBody) as (obj: any) => string;
117
+ this.compiledSerializers.set(dtoClass, serializer);
118
+ return serializer;
119
+ } catch (err) {
120
+ console.error('Failed to compile JIT response serializer for class:', dtoClass.name || dtoClass);
121
+ throw err;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,47 @@
1
+ import 'reflect-metadata';
2
+
3
+ export interface ValidationRule {
4
+ type: string;
5
+ propertyKey: string;
6
+ args?: any[];
7
+ }
8
+
9
+ function registerValidationRule(type: string, target: any, propertyKey: string, args?: any[]) {
10
+ const existing: ValidationRule[] = Reflect.getOwnMetadata('calyx:validation_rules', target.constructor) || [];
11
+ existing.push({ type, propertyKey, args });
12
+ Reflect.defineMetadata('calyx:validation_rules', existing, target.constructor);
13
+ }
14
+
15
+ export function IsString(): PropertyDecorator {
16
+ return (target, propertyKey) => registerValidationRule('string', target, String(propertyKey));
17
+ }
18
+
19
+ export function IsNumber(): PropertyDecorator {
20
+ return (target, propertyKey) => registerValidationRule('number', target, String(propertyKey));
21
+ }
22
+
23
+ export function IsOptional(): PropertyDecorator {
24
+ return (target, propertyKey) => registerValidationRule('optional', target, String(propertyKey));
25
+ }
26
+
27
+ export function IsEmail(): PropertyDecorator {
28
+ return (target, propertyKey) => registerValidationRule('email', target, String(propertyKey));
29
+ }
30
+
31
+ export function Expose(): PropertyDecorator {
32
+ return (target, propertyKey) => {
33
+ const constructor = target.constructor;
34
+ const existing = Reflect.getOwnMetadata('calyx:expose_properties', constructor) || new Set();
35
+ existing.add(String(propertyKey));
36
+ Reflect.defineMetadata('calyx:expose_properties', existing, constructor);
37
+ };
38
+ }
39
+
40
+ export function Exclude(): PropertyDecorator {
41
+ return (target, propertyKey) => {
42
+ const constructor = target.constructor;
43
+ const existing = Reflect.getOwnMetadata('calyx:exclude_properties', constructor) || new Set();
44
+ existing.add(String(propertyKey));
45
+ Reflect.defineMetadata('calyx:exclude_properties', existing, constructor);
46
+ };
47
+ }
@@ -0,0 +1,3 @@
1
+ export * from './decorators.ts';
2
+ export * from './compiler.ts';
3
+ export * from './pipe.ts';
@@ -0,0 +1,31 @@
1
+ import { PipeTransform, ArgumentMetadata } from '../lifecycle/interfaces.ts';
2
+ import { Injectable } from '../core/decorators.ts';
3
+ import { HttpException } from '../http/exceptions.ts';
4
+ import { ValidationCompiler } from './compiler.ts';
5
+
6
+ @Injectable()
7
+ export class ValidationPipe implements PipeTransform {
8
+ async transform(value: any, metadata: ArgumentMetadata) {
9
+ const metatype = metadata.metatype;
10
+ if (!metatype || this.toValidate(metatype)) {
11
+ return value;
12
+ }
13
+
14
+ const validate = ValidationCompiler.compile(metatype);
15
+ const errors = validate(value);
16
+ if (errors) {
17
+ throw new HttpException({
18
+ statusCode: 400,
19
+ message: 'Validation failed',
20
+ errors,
21
+ }, 400);
22
+ }
23
+
24
+ return value;
25
+ }
26
+
27
+ private toValidate(metatype: Function): boolean {
28
+ const types: Function[] = [String, Boolean, Number, Array, Object];
29
+ return types.includes(metatype);
30
+ }
31
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Controller,
5
+ Get,
6
+ CalyxFactory,
7
+ CacheModule,
8
+ CacheService,
9
+ CacheInterceptor,
10
+ UseInterceptors,
11
+ } from '../src/index.ts';
12
+
13
+ let calculateCalls = 0;
14
+
15
+ @Controller('math')
16
+ class MathController {
17
+ constructor(private readonly cache: CacheService) {}
18
+
19
+ @Get('slow-calc')
20
+ @UseInterceptors(CacheInterceptor)
21
+ slowCalculation() {
22
+ calculateCalls++;
23
+ return { result: 42, count: calculateCalls };
24
+ }
25
+ }
26
+
27
+ @Module({
28
+ imports: [CacheModule.register({ defaultTtl: 1 })], // 1 second TTL
29
+ controllers: [MathController],
30
+ })
31
+ class TestApp {}
32
+
33
+ describe('SQLite Caching (CacheModule, CacheService, CacheInterceptor)', () => {
34
+ let app: any;
35
+ let baseUrl: string;
36
+ const PORT = 3918;
37
+
38
+ beforeAll(async () => {
39
+ calculateCalls = 0;
40
+ app = await CalyxFactory.create(TestApp);
41
+ await app.listen(PORT);
42
+ baseUrl = `http://localhost:${PORT}`;
43
+ });
44
+
45
+ afterAll(async () => {
46
+ await app.close();
47
+ });
48
+
49
+ test('should set, get, and del values directly using CacheService', async () => {
50
+ const cacheService = app.container.getGlobalOrAnyInstance(CacheService);
51
+
52
+ await cacheService.set('foo', 'bar');
53
+ expect(await cacheService.get('foo')).toBe('bar');
54
+
55
+ await cacheService.del('foo');
56
+ expect(await cacheService.get('foo')).toBeUndefined();
57
+ });
58
+
59
+ test('should expire cached key after TTL expires', async () => {
60
+ const cacheService = app.container.getGlobalOrAnyInstance(CacheService);
61
+
62
+ await cacheService.set('expire_test', { val: 123 }, 1); // 1 second TTL
63
+ expect(await cacheService.get('expire_test')).toEqual({ val: 123 });
64
+
65
+ // Wait 1.1s
66
+ await new Promise((resolve) => setTimeout(resolve, 1100));
67
+ expect(await cacheService.get('expire_test')).toBeUndefined();
68
+ });
69
+
70
+ test('should cache GET route response and short-circuit subsequent requests', async () => {
71
+ // 1. First call -> should execute handler
72
+ const res1 = await fetch(`${baseUrl}/math/slow-calc`);
73
+ expect(res1.status).toBe(200);
74
+ const body1 = await res1.json();
75
+ expect(body1).toEqual({ result: 42, count: 1 });
76
+ expect(calculateCalls).toBe(1);
77
+
78
+ // 2. Second call -> should return cached response (calculateCalls remains 1)
79
+ const res2 = await fetch(`${baseUrl}/math/slow-calc`);
80
+ expect(res2.status).toBe(200);
81
+ const body2 = await res2.json();
82
+ expect(body2).toEqual({ result: 42, count: 1 });
83
+ expect(calculateCalls).toBe(1);
84
+
85
+ // 3. Wait 1.1s for cache to expire, then call again -> should execute handler again
86
+ await new Promise((resolve) => setTimeout(resolve, 1100));
87
+ const res3 = await fetch(`${baseUrl}/math/slow-calc`);
88
+ expect(res3.status).toBe(200);
89
+ const body3 = await res3.json();
90
+ expect(body3).toEqual({ result: 42, count: 2 });
91
+ expect(calculateCalls).toBe(2);
92
+ });
93
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { Module, CalyxFactory } from '../src/index.ts';
3
+ import {
4
+ Resolver,
5
+ Query,
6
+ ResolveField,
7
+ Args,
8
+ Parent,
9
+ ObjectType,
10
+ Field,
11
+ GraphQLModule,
12
+ } from '../src/graphql/index.ts';
13
+
14
+ // 1. GraphQL Object Type DTO
15
+ @ObjectType()
16
+ class Author {
17
+ @Field()
18
+ id!: number;
19
+
20
+ @Field()
21
+ name!: string;
22
+ }
23
+
24
+ @ObjectType()
25
+ class PostGql {
26
+ @Field()
27
+ id!: number;
28
+
29
+ @Field()
30
+ title!: string;
31
+
32
+ @Field(() => Author)
33
+ author!: Author;
34
+ }
35
+
36
+ // 2. Resolver Class
37
+ @Resolver(PostGql)
38
+ class PostResolver {
39
+ @Query(() => PostGql)
40
+ getPost(@Args('id') id: number) {
41
+ return {
42
+ id,
43
+ title: `Calyx: GraphQL JIT Performance`,
44
+ authorId: 456, // to be resolved by ResolveField
45
+ };
46
+ }
47
+
48
+ @ResolveField(() => Author)
49
+ author(@Parent() post: any) {
50
+ return {
51
+ id: post.authorId,
52
+ name: 'Jane Doe',
53
+ };
54
+ }
55
+ }
56
+
57
+ @Module({
58
+ imports: [GraphQLModule],
59
+ providers: [PostResolver],
60
+ })
61
+ class TestApp {}
62
+
63
+ describe('Native Code-First GraphQL Module', () => {
64
+ let app: any;
65
+ let baseUrl: string;
66
+ const PORT = 3928;
67
+
68
+ beforeAll(async () => {
69
+ app = await CalyxFactory.create(TestApp);
70
+ await app.listen(PORT);
71
+ baseUrl = `http://localhost:${PORT}`;
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await app.close();
76
+ });
77
+
78
+ test('should execute Query and ResolveField successfully using native graphql adapter', async () => {
79
+ const res = await fetch(`${baseUrl}/graphql`, {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/json' },
82
+ body: JSON.stringify({
83
+ query: `
84
+ query {
85
+ getPost(id: 123) {
86
+ id
87
+ title
88
+ author {
89
+ id
90
+ name
91
+ }
92
+ }
93
+ }
94
+ `,
95
+ }),
96
+ });
97
+
98
+ expect(res.status).toBe(200);
99
+ const body = await res.json();
100
+ expect(body.errors).toBeUndefined();
101
+ expect(body.data).toEqual({
102
+ getPost: {
103
+ id: 123,
104
+ title: 'Calyx: GraphQL JIT Performance',
105
+ author: {
106
+ id: 456,
107
+ name: 'Jane Doe',
108
+ },
109
+ },
110
+ });
111
+ });
112
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Controller,
5
+ Get,
6
+ Param,
7
+ CalyxFactory,
8
+ ApiTags,
9
+ ApiOperation,
10
+ ApiResponse,
11
+ ApiProperty,
12
+ DocumentBuilder,
13
+ SwaggerModule,
14
+ } from '../src/index.ts';
15
+
16
+ // 1. Model class
17
+ class Item {
18
+ @ApiProperty({ description: 'The unique identifier', type: Number })
19
+ id!: number;
20
+
21
+ @ApiProperty({ description: 'The item name', type: String })
22
+ name!: string;
23
+ }
24
+
25
+ @ApiTags('Items')
26
+ @Controller('items')
27
+ class ItemsController {
28
+ @Get(':id')
29
+ @ApiOperation({ summary: 'Get item by id', description: 'Returns a single item' })
30
+ @ApiResponse({ status: 200, description: 'Item found successfully', type: Item })
31
+ getItem(@Param('id') id: string) {
32
+ return { id: 1, name: 'Gadget' };
33
+ }
34
+ }
35
+
36
+ @Module({
37
+ controllers: [ItemsController],
38
+ })
39
+ class TestApp {}
40
+
41
+ describe('OpenAPI (Swagger) Generation', () => {
42
+ let app: any;
43
+ let baseUrl: string;
44
+ const PORT = 3932;
45
+
46
+ beforeAll(async () => {
47
+ app = await CalyxFactory.create(TestApp);
48
+
49
+ // Build OpenAPI config
50
+ const config = new DocumentBuilder()
51
+ .setTitle('My Test API')
52
+ .setDescription('OpenAPI description')
53
+ .setVersion('2.0.0')
54
+ .build();
55
+
56
+ const document = SwaggerModule.createDocument(app, config);
57
+ SwaggerModule.setup('api', app, document);
58
+
59
+ await app.listen(PORT);
60
+ baseUrl = `http://localhost:${PORT}`;
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await app.close();
65
+ });
66
+
67
+ test('should serve OpenAPI JSON specification with paths and components', async () => {
68
+ const res = await fetch(`${baseUrl}/api-json`);
69
+ expect(res.status).toBe(200);
70
+ const spec = await res.json();
71
+
72
+ expect(spec.openapi).toBe('3.0.0');
73
+ expect(spec.info.title).toBe('My Test API');
74
+ expect(spec.info.version).toBe('2.0.0');
75
+ expect(spec.paths['/items/{id}']).toBeDefined();
76
+
77
+ const getOp = spec.paths['/items/{id}'].get;
78
+ expect(getOp.summary).toBe('Get item by id');
79
+ expect(getOp.tags).toContain('Items');
80
+ expect(getOp.parameters[0].name).toBe('id');
81
+ expect(getOp.parameters[0].in).toBe('path');
82
+
83
+ expect(spec.components.schemas.Item).toBeDefined();
84
+ expect(spec.components.schemas.Item.properties.name.type).toBe('string');
85
+ expect(spec.components.schemas.Item.properties.id.type).toBe('number');
86
+ });
87
+
88
+ test('should serve Swagger UI html wrapper', async () => {
89
+ const res = await fetch(`${baseUrl}/api`);
90
+ expect(res.status).toBe(200);
91
+ const html = await res.text();
92
+ expect(html).toContain('swagger-ui');
93
+ expect(html).toContain('window.ui = SwaggerUIBundle');
94
+ });
95
+ });