@contractkit/plugin-typescript 0.34.0 → 0.35.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,119 @@
1
+ import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
2
+
3
+ /**
4
+ * HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
5
+ * to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
6
+ */
7
+ export const SERVER_FRAMEWORK_NAMES = ['koa'] as const;
8
+
9
+ /** One of {@link SERVER_FRAMEWORK_NAMES}. */
10
+ export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
11
+
12
+ /** The framework assumed when a config names none. */
13
+ export const DEFAULT_SERVER_FRAMEWORK_NAME: ServerFrameworkName = 'koa';
14
+
15
+ /**
16
+ * Every framework-specific string the router and MCP router generators emit.
17
+ *
18
+ * Granularity is one statement (or one fragment) per method, so the shared codegen keeps ownership
19
+ * of control flow — which branches exist, what order they run in, and which values reach them — and
20
+ * an adapter only decides how a given step is spelled. Anything an adapter cannot express as a
21
+ * statement, such as ending a response, is returned as a list of lines so it can also be empty.
22
+ */
23
+ export interface ServerFramework {
24
+ readonly name: ServerFrameworkName;
25
+
26
+ /**
27
+ * Import lines for the framework runtime, already filtered down to what the generated body uses.
28
+ *
29
+ * The adapter applies `uses` itself rather than declaring a symbol list, because a framework may
30
+ * need more than one import line, and because only names the adapter chooses ever go through the
31
+ * word-boundary probe — a handler-local identifier can never be mistaken for an import.
32
+ */
33
+ imports(uses: (symbol: string) => boolean): string[];
34
+
35
+ /** The module-level router value every handler attaches to. */
36
+ routerDeclaration(routerName: string): string;
37
+
38
+ /** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
39
+ pathParam(identifier: string): string;
40
+
41
+ /** Opening line of a handler, including its middleware and the handler function's parameters. */
42
+ routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
43
+
44
+ /** Lines that close a handler opened by {@link routeOpen}. */
45
+ routeClose(): string[];
46
+
47
+ /** Route middleware factory calls, rendered as expressions for {@link routeOpen}. */
48
+ readonly middleware: {
49
+ policy(args: string): string;
50
+ bodyParser(tokensExpr: string): string;
51
+ signature(args: string): string;
52
+ };
53
+
54
+ /** Expressions a handler reads the request through. */
55
+ readonly request: {
56
+ params: string;
57
+ query: string;
58
+ headers: string;
59
+ /** The body already parsed by the body-parser middleware. */
60
+ parsedBody: string;
61
+ /**
62
+ * The request's media type with any parameters stripped. It is matched against declared MIME
63
+ * literals, so an adapter whose framework exposes only the raw header must normalise it here
64
+ * — a `; charset=utf-8` left on the end matches nothing.
65
+ */
66
+ contentType: string;
67
+ };
68
+
69
+ /** Expression resolving a service class out of the request-scoped DI container. */
70
+ resolveService(className: string): string;
71
+
72
+ /** Statements a handler writes the response with. */
73
+ readonly response: {
74
+ status(expr: string): string;
75
+ /**
76
+ * One statement setting a response header. It is emitted bare or behind an `if` guard for an
77
+ * optional header, so it must stay a single statement.
78
+ */
79
+ header(name: string, valueExpr: string): string;
80
+ type(expr: string): string;
81
+ /**
82
+ * The terminal write for a response, or for one without a body when `bodyExpr` is undefined.
83
+ * A framework that ends a response by returning needs a statement in both cases; Koa, which
84
+ * ends it by assignment, emits nothing for a bodyless one.
85
+ */
86
+ send(bodyExpr: string | undefined): string[];
87
+ /** What closes one `case` of the multi-status switch, after that status has been written. */
88
+ caseEnd(): string[];
89
+ };
90
+
91
+ /** The whole `mcp.router.ts` file, which is boilerplate rather than a per-operation render. */
92
+ mcpRouter(options: { path: string }): string;
93
+ }
94
+
95
+ /**
96
+ * Every supported framework, keyed by name. The annotation is what ties this to
97
+ * {@link SERVER_FRAMEWORK_NAMES}: adding a name without an adapter is a compile error.
98
+ */
99
+ export const SERVER_FRAMEWORKS: Readonly<Record<ServerFrameworkName, ServerFramework>> = {
100
+ koa: KOA_SERVER_FRAMEWORK,
101
+ };
102
+
103
+ /**
104
+ * Resolve a configured framework name to its adapter.
105
+ *
106
+ * @param name The `server.framework` value, or undefined for {@link DEFAULT_SERVER_FRAMEWORK_NAME}.
107
+ * @throws When `name` is not a supported framework. Config arrives as JSON, so this is a runtime
108
+ * check and not something the `ServerFrameworkName` type can enforce on its own.
109
+ */
110
+ export function resolveServerFramework(name: string | undefined): ServerFramework {
111
+ const resolved = name ?? DEFAULT_SERVER_FRAMEWORK_NAME;
112
+ const framework = (SERVER_FRAMEWORKS as Record<string, ServerFramework | undefined>)[resolved];
113
+ if (!framework) {
114
+ throw new Error(
115
+ `plugin-typescript: server.framework '${resolved}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
116
+ );
117
+ }
118
+ return framework;
119
+ }
@@ -141,6 +141,16 @@ describe('generateMcpFile', () => {
141
141
  expect(out).toContain('const CreatePaymentArgs = z.object({ body: PaymentInput });');
142
142
  expect(out).toContain('const { body } = await parseAndValidate(args, CreatePaymentArgs);');
143
143
  });
144
+
145
+ it('underscores the args param when the op takes no arguments', () => {
146
+ const root = opRoot([opRoute('/health', [opOperation('get', { mcp: true, responses: [opResponse(200, 'Health', 'application/json')] })])]);
147
+ const out = generateMcpFile(root);
148
+ // Nothing destructures `args` here, so an un-prefixed name trips no-unused-vars in
149
+ // consumers that lint generated output.
150
+ expect(out).toContain('async handle(_args: Record<string, unknown>, _context: McpToolContext)');
151
+ expect(out).not.toContain('async handle(args:');
152
+ expect(out).not.toContain('parseAndValidate');
153
+ });
144
154
  });
145
155
 
146
156
  describe('service call + result', () => {
@@ -220,7 +230,13 @@ describe('generateMcpAggregator', () => {
220
230
  expect(out).toContain('const map = new McpToolHandlerMap();');
221
231
  expect(out).toContain('registerPaymentsMcpTools(map, container);');
222
232
  expect(out).toContain('registerUsersMcpTools(map, container);');
223
- expect(out).toContain('container.register(McpToolHandlerMap, { useValue: map });');
233
+ expect(out).toContain('return map;');
234
+ });
235
+
236
+ it('only builds the map — Container has no register, that belongs to Registry', () => {
237
+ const out = generateMcpAggregator([{ registerFn: 'registerPaymentsMcpTools', importPath: './payments.mcp.js' }]);
238
+ expect(out).not.toContain('container.register');
239
+ expect(out).toContain('registry.register(McpToolHandlerMap).useFactory(registerMcpTools).asSingleton();');
224
240
  });
225
241
  });
226
242
 
@@ -233,6 +249,12 @@ describe('generateMcpRouter', () => {
233
249
  expect(out).toContain("dispatcher.sessionMode === 'stateful'");
234
250
  });
235
251
 
252
+ it('tells consumers to bind the aggregator rather than call it at startup', () => {
253
+ const out = generateMcpRouter();
254
+ expect(out).toContain('Bind `registerMcpTools` to the `McpToolHandlerMap` token.');
255
+ expect(out).not.toContain('Call `registerMcpTools(container)` at startup');
256
+ });
257
+
236
258
  it('parses the body before verifying the signature', () => {
237
259
  const out = generateMcpRouter({ path: '/mcp' });
238
260
  // `requireSignature` HMACs `ctx.rawBody`, which only `bodyParserMiddleware` populates,
@@ -264,6 +286,18 @@ describe('generateMcpRouter', () => {
264
286
  expect(out).not.toContain('ctx.request.body');
265
287
  });
266
288
 
289
+ it('delegates the whole file to the framework adapter', () => {
290
+ const stub = { mcpRouter: ({ path }: { path: string }) => `// stub mount at ${path}` };
291
+ // Only `mcpRouter` is reachable from here, so the rest of the adapter is left off the stub.
292
+ const out = generateMcpRouter({ framework: stub as never });
293
+ expect(out).toBe('// stub mount at /mcp');
294
+ });
295
+
296
+ it('passes a configured path through to the adapter', () => {
297
+ const stub = { mcpRouter: ({ path }: { path: string }) => `// stub mount at ${path}` };
298
+ expect(generateMcpRouter({ path: '/tools', framework: stub as never })).toBe('// stub mount at /tools');
299
+ });
300
+
267
301
  it('defaults the mount path to /mcp', () => {
268
302
  expect(generateMcpRouter()).toContain("router.post('/mcp'");
269
303
  });
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateOp } from '../src/codegen-operation.js';
3
+ import type { ServerFramework } from '../src/server-framework.js';
4
+ import { scalarType, refType, opParam, opRequest, opMultiRequest, opResponse, opResponseMulti, opOperation, opRoute, opRoot } from './helpers.js';
5
+
6
+ /**
7
+ * A framework whose every string is unmistakable. Rendering a router through it and finding no Koa
8
+ * left in the output is what proves the seam is complete: a `ctx.` the generator still emits inline
9
+ * would survive this substitution, and a `toContain` test against the Koa output never notices,
10
+ * because the default adapter puts the very same string there.
11
+ */
12
+ const STUB: ServerFramework = {
13
+ // The registry's key type admits only shipped frameworks; the adapter under test is a fake.
14
+ name: 'koa',
15
+ imports: uses => (uses('StubRouter') ? ["import { StubRouter } from '@stub/http';"] : []),
16
+ routerDeclaration: routerName => `export const ${routerName} = StubRouter();`,
17
+ pathParam: identifier => `<${identifier}>`,
18
+ routeOpen: (routerName, method, path, middlewares) => `${routerName}.route('${method}', '${path}', [${middlewares.join(', ')}], async (rq, rs) => {`,
19
+ routeClose: () => ['}, END);'],
20
+ middleware: {
21
+ policy: args => `stubPolicy(${args})`,
22
+ bodyParser: tokens => `stubBody(${tokens})`,
23
+ signature: args => `stubSignature(${args})`,
24
+ },
25
+ request: {
26
+ params: 'rq.pathParams',
27
+ query: 'rq.searchParams',
28
+ headers: 'rq.headerBag',
29
+ parsedBody: 'rq.payload',
30
+ contentType: 'rq.mediaType',
31
+ },
32
+ resolveService: className => `rq.services.resolve(${className})`,
33
+ response: {
34
+ status: expr => `rs.setStatus(${expr});`,
35
+ header: (name, valueExpr) => `rs.putHeader('${name}', ${valueExpr});`,
36
+ type: expr => `rs.setMedia(${expr});`,
37
+ send: bodyExpr => (bodyExpr === undefined ? ['return rs.finish();'] : [`return rs.deliver(${bodyExpr});`]),
38
+ caseEnd: () => [],
39
+ },
40
+ mcpRouter: ({ path }) => `// stub mcp at ${path}\n`,
41
+ };
42
+
43
+ /** One op root touching every branch of the generator that can emit a framework string. */
44
+ function everyBranchRoot() {
45
+ return opRoot([
46
+ opRoute(
47
+ '/payments/{paymentId}',
48
+ [
49
+ // Path params + query + headers + a single JSON body, with policy and signature middleware.
50
+ opOperation('post', {
51
+ request: opRequest('Payment'),
52
+ query: [opParam('limit', scalarType('int'))],
53
+ headers: [opParam('x-tenant', scalarType('string'))],
54
+ signature: 'stripe',
55
+ responses: [opResponse(201, 'Payment')],
56
+ }),
57
+ // Several request MIMEs with different shapes — the content-type switch.
58
+ opOperation('put', {
59
+ request: opMultiRequest([
60
+ ['application/json', 'Payment'],
61
+ ['multipart/form-data', 'Receipt'],
62
+ ]),
63
+ responses: [opResponse(200, 'Payment')],
64
+ }),
65
+ // No emitted body at all — the bodyless 204 path.
66
+ opOperation('delete', { responses: [] }),
67
+ // Several emitted statuses, one carrying response headers — the status switch.
68
+ opOperation('patch', {
69
+ responses: [
70
+ opResponseMulti(200, [{ contentType: 'application/json', bodyType: 'Payment' }], {
71
+ headers: [{ name: 'etag', optional: false, type: scalarType('string') }],
72
+ }),
73
+ opResponseMulti(202, [{ contentType: 'application/json', bodyType: refType('Payment') }], {
74
+ headers: [{ name: 'retry-after', optional: true, type: scalarType('string') }],
75
+ }),
76
+ ],
77
+ }),
78
+ ],
79
+ [opParam('paymentId', scalarType('uuid'))],
80
+ ),
81
+ ]);
82
+ }
83
+
84
+ describe('generateOp — framework seam', () => {
85
+ const output = generateOp(everyBranchRoot(), { framework: STUB });
86
+
87
+ it('leaves no Koa string anywhere in the output', () => {
88
+ expect(output).not.toMatch(/\bctx\b/);
89
+ expect(output).not.toContain('ServerKitRouter');
90
+ expect(output).not.toContain('@maroonedsoftware/koa');
91
+ expect(output).not.toContain('requirePolicy');
92
+ expect(output).not.toContain('bodyParserMiddleware');
93
+ expect(output).not.toContain('requireSignature');
94
+ });
95
+
96
+ it('renders the router shell through the adapter', () => {
97
+ expect(output).toContain('export const UsersRouter = StubRouter();');
98
+ expect(output).toContain("import { StubRouter } from '@stub/http';");
99
+ expect(output).toContain('}, END);');
100
+ });
101
+
102
+ it('renders the route line, its path params and its middleware through the adapter', () => {
103
+ expect(output).toContain("UsersRouter.route('post', '/payments/<paymentId>', [stubPolicy(), stubBody('json'), stubSignature('stripe')], async (rq, rs) => {");
104
+ });
105
+
106
+ it('reads params, query, headers and the body through the adapter', () => {
107
+ expect(output).toContain('rq.pathParams');
108
+ expect(output).toContain('rq.searchParams');
109
+ expect(output).toContain('rq.headerBag');
110
+ expect(output).toContain('rq.payload');
111
+ expect(output).toContain('switch (rq.mediaType) {');
112
+ });
113
+
114
+ it('resolves services through the adapter', () => {
115
+ expect(output).toContain('rq.services.resolve(UsersService)');
116
+ });
117
+
118
+ it('writes status, headers, content type and body through the adapter', () => {
119
+ expect(output).toContain('rs.setStatus(201);');
120
+ expect(output).toContain("rs.setMedia('application/json');");
121
+ expect(output).toContain('return rs.deliver(result);');
122
+ expect(output).toContain('rs.putHeader(\'etag\', String(result.headers["etag"]));');
123
+ expect(output).toContain('if (result.headers["retryAfter"] !== undefined) rs.putHeader(\'retry-after\', String(result.headers["retryAfter"]));');
124
+ });
125
+
126
+ it('gives a bodyless response the adapter\'s terminal statement', () => {
127
+ // Koa needs none, so the generator only emits one because the adapter asked for it.
128
+ expect(output).toContain('rs.setStatus(204);');
129
+ expect(output).toContain('return rs.finish();');
130
+ });
131
+
132
+ it('omits the status-case terminator when the adapter has none', () => {
133
+ expect(output).toContain('rs.setStatus(result.status);');
134
+ // Scoped to the status switch: the multi-MIME request dispatch is the generator's own control
135
+ // flow and keeps its `break;` whatever the framework is.
136
+ const statusSwitch = output.slice(output.indexOf('switch (result.status) {'));
137
+ expect(statusSwitch).toContain('case 202:');
138
+ expect(statusSwitch).not.toContain('break;');
139
+ });
140
+ });
@@ -567,6 +567,35 @@ describe('generateOperation', () => {
567
567
  expect(output).toContain('z.object({');
568
568
  });
569
569
 
570
+ it('renders an inline array header through renderInputType, without the query coercion', () => {
571
+ // The comma-splitting preprocess belongs to query strings only. Headers and params share
572
+ // the same inline-param renderer, so the block has to be told which one it is rendering.
573
+ const root = opRoot([
574
+ opRoute('/users', [
575
+ opOperation('get', {
576
+ headers: [opParam('x-tags', arrayType(scalarType('string')))],
577
+ }),
578
+ ]),
579
+ ]);
580
+ const output = generateOp(root);
581
+ expect(output).toContain('ctx.headers');
582
+ expect(output).not.toContain('z.preprocess');
583
+ expect(output).not.toContain("v.split(',')");
584
+ });
585
+
586
+ it('declares headers as a whole object rather than destructuring it', () => {
587
+ const root = opRoot([
588
+ opRoute('/users', [
589
+ opOperation('get', {
590
+ headers: [opParam('authorization', scalarType('string'))],
591
+ }),
592
+ ]),
593
+ ]);
594
+ const output = generateOp(root);
595
+ expect(output).toContain('const headers = await parseAndValidate(');
596
+ expect(output).not.toContain('const { authorization }');
597
+ });
598
+
570
599
  it('generates parseAndValidate import when operation has headers', () => {
571
600
  const root = opRoot([
572
601
  opRoute('/users', [
@@ -37,6 +37,13 @@ function inputs(opRoots = [opRoot([opRoute('/users', [opOperation('get')])], '/p
37
37
  };
38
38
  }
39
39
 
40
+ /** Inputs carrying an MCP-exposed operation, which is what makes the mcp sub-generator emit a router. */
41
+ function mcpInputs() {
42
+ return inputs([
43
+ opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])], '/project/contracts/users.ck'),
44
+ ]);
45
+ }
46
+
40
47
  // ─── Tests ─────────────────────────────────────────────────────────────────
41
48
 
42
49
  describe('createTypescriptPlugin (server)', () => {
@@ -224,6 +231,55 @@ describe('createTypescriptPlugin (server)', () => {
224
231
  });
225
232
  });
226
233
 
234
+ describe('framework', () => {
235
+ it('emits a Koa router when no framework is configured', async () => {
236
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
237
+ const ctx = makeCtx('/project');
238
+ await plugin.generateTargets!(inputs(), ctx);
239
+ const [content] = [...ctx.emitted.values()];
240
+ expect(content).toContain("from '@maroonedsoftware/koa'");
241
+ });
242
+
243
+ it('emits byte-identical output when koa is named explicitly', async () => {
244
+ const runWith = async (server: Record<string, unknown>) => {
245
+ const ctx = makeCtx('/project');
246
+ await createTypescriptPlugin({ server }, '/project').generateTargets!(inputs(), ctx);
247
+ return [...ctx.emitted.values()].join('\n');
248
+ };
249
+ expect(await runWith({ framework: 'koa' })).toEqual(await runWith({}));
250
+ });
251
+
252
+ it('rejects a framework that has no adapter', async () => {
253
+ const plugin = createTypescriptPlugin({ server: { framework: 'express' as never } }, '/project');
254
+ await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(
255
+ /server\.framework 'express' is not supported — expected one of: koa/,
256
+ );
257
+ });
258
+
259
+ it('rejects the unknown framework before any other config complaint', async () => {
260
+ // Both rules are broken here; the framework is the one reported, since a router for a
261
+ // framework that does not exist cannot be generated whatever the other settings say.
262
+ const plugin = createTypescriptPlugin({ server: { framework: 'express' as never, validateResponses: true } }, '/project');
263
+ await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(/server\.framework/);
264
+ });
265
+
266
+ it('renders mcp.router.ts for the configured framework', async () => {
267
+ const plugin = createTypescriptPlugin({ server: { framework: 'koa' }, mcp: {} }, '/project');
268
+ const ctx = makeCtx('/project');
269
+ await plugin.generateTargets!(mcpInputs(), ctx);
270
+ const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
271
+ expect(router).toContain("from '@maroonedsoftware/koa'");
272
+ });
273
+
274
+ it('still emits a Koa mcp.router.ts when there is no server sub-config', async () => {
275
+ const plugin = createTypescriptPlugin({ mcp: {} }, '/project');
276
+ const ctx = makeCtx('/project');
277
+ await plugin.generateTargets!(mcpInputs(), ctx);
278
+ const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
279
+ expect(router).toContain("from '@maroonedsoftware/koa'");
280
+ });
281
+ });
282
+
227
283
  describe('validateResponses', () => {
228
284
  const userRoot = () =>
229
285
  opRoot(
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { KOA_SERVER_FRAMEWORK as koa } from '../src/server-framework-koa.js';
3
+
4
+ /**
5
+ * The Koa adapter is the reference implementation: every string here is one the generator emitted
6
+ * inline before the seam existed. Pinning them individually means a change to any one of them shows
7
+ * up as a failure here, naming the piece, rather than only as a snapshot diff two packages away.
8
+ */
9
+ describe('KOA_SERVER_FRAMEWORK', () => {
10
+ it('is named koa', () => {
11
+ expect(koa.name).toBe('koa');
12
+ });
13
+
14
+ describe('imports', () => {
15
+ it('emits one line naming every symbol the body uses', () => {
16
+ expect(koa.imports(() => true)).toEqual([
17
+ "import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature } from '@maroonedsoftware/koa';",
18
+ ]);
19
+ });
20
+
21
+ it('narrows to the symbols the body actually references', () => {
22
+ expect(koa.imports(s => s === 'requirePolicy')).toEqual(["import { requirePolicy } from '@maroonedsoftware/koa';"]);
23
+ });
24
+
25
+ it('emits nothing when the body references none of them', () => {
26
+ expect(koa.imports(() => false)).toEqual([]);
27
+ });
28
+ });
29
+
30
+ it('declares the router', () => {
31
+ expect(koa.routerDeclaration('UsersRouter')).toBe('export const UsersRouter = ServerKitRouter();');
32
+ });
33
+
34
+ it('renders a path parameter with a colon', () => {
35
+ expect(koa.pathParam('userId')).toBe(':userId');
36
+ });
37
+
38
+ describe('routeOpen', () => {
39
+ it('omits the middleware list when there is none', () => {
40
+ expect(koa.routeOpen('UsersRouter', 'get', '/users', [])).toBe("UsersRouter.get('/users', async ctx => {");
41
+ });
42
+
43
+ it('places the middleware between the path and the handler', () => {
44
+ expect(koa.routeOpen('UsersRouter', 'post', '/users', ['requirePolicy()', "bodyParserMiddleware(['json'])"])).toBe(
45
+ "UsersRouter.post('/users', requirePolicy(), bodyParserMiddleware(['json']), async ctx => {",
46
+ );
47
+ });
48
+ });
49
+
50
+ it('closes a route', () => {
51
+ expect(koa.routeClose()).toEqual(['});']);
52
+ });
53
+
54
+ it('renders the route middleware factories', () => {
55
+ expect(koa.middleware.policy('')).toBe('requirePolicy()');
56
+ expect(koa.middleware.policy("{ policy: 'admin' }")).toBe("requirePolicy({ policy: 'admin' })");
57
+ expect(koa.middleware.bodyParser("'json', 'multipart'")).toBe("bodyParserMiddleware(['json', 'multipart'])");
58
+ expect(koa.middleware.signature("'slack'")).toBe("requireSignature('slack')");
59
+ });
60
+
61
+ it('reads the request off the context', () => {
62
+ expect(koa.request).toEqual({
63
+ params: 'ctx.params',
64
+ query: 'ctx.query',
65
+ headers: 'ctx.headers',
66
+ parsedBody: 'ctx.parsedBody',
67
+ contentType: 'ctx.request.type',
68
+ });
69
+ });
70
+
71
+ it('resolves a service from the request container', () => {
72
+ expect(koa.resolveService('PaymentService')).toBe('ctx.container.get(PaymentService)');
73
+ });
74
+
75
+ describe('response', () => {
76
+ it('assigns the status', () => {
77
+ expect(koa.response.status('200')).toBe('ctx.status = 200;');
78
+ expect(koa.response.status('result.status')).toBe('ctx.status = result.status;');
79
+ });
80
+
81
+ it('sets a header as a single statement, so the optional guard can wrap it', () => {
82
+ expect(koa.response.header('x-request-id', 'String(result.headers["xRequestId"])')).toBe(
83
+ 'ctx.set(\'x-request-id\', String(result.headers["xRequestId"]));',
84
+ );
85
+ });
86
+
87
+ it('assigns the content type', () => {
88
+ expect(koa.response.type("'application/json'")).toBe("ctx.type = 'application/json';");
89
+ expect(koa.response.type('result.contentType')).toBe('ctx.type = result.contentType;');
90
+ });
91
+
92
+ it('assigns the body, and writes nothing at all when there is none', () => {
93
+ expect(koa.response.send('result')).toEqual(['ctx.body = result;']);
94
+ // Koa ends the response on its own once the handler resolves, so a 204 needs no statement.
95
+ expect(koa.response.send(undefined)).toEqual([]);
96
+ });
97
+
98
+ it('closes a status case with a break', () => {
99
+ expect(koa.response.caseEnd()).toEqual(['break;']);
100
+ });
101
+ });
102
+
103
+ describe('mcpRouter', () => {
104
+ it('mounts the dispatcher at the given path', () => {
105
+ const out = koa.mcpRouter({ path: '/tools' });
106
+ expect(out).toContain("router.post('/tools',");
107
+ expect(out).toContain("import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '@maroonedsoftware/koa';");
108
+ expect(out).toContain('const dispatcher = ctx.container.get(McpDispatcher);');
109
+ });
110
+
111
+ it('escapes the backticks in its own doc comment rather than closing the template', () => {
112
+ expect(koa.mcpRouter({ path: '/mcp' })).toContain('Bind `registerMcpTools` to the `McpToolHandlerMap` token.');
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveServerFramework, SERVER_FRAMEWORK_NAMES, SERVER_FRAMEWORKS, DEFAULT_SERVER_FRAMEWORK_NAME } from '../src/server-framework.js';
3
+
4
+ describe('resolveServerFramework', () => {
5
+ it('defaults to koa when the config names none', () => {
6
+ expect(resolveServerFramework(undefined).name).toBe('koa');
7
+ expect(DEFAULT_SERVER_FRAMEWORK_NAME).toBe('koa');
8
+ });
9
+
10
+ it('resolves a supported name', () => {
11
+ expect(resolveServerFramework('koa').name).toBe('koa');
12
+ });
13
+
14
+ it('rejects an unsupported name, naming what is supported', () => {
15
+ expect(() => resolveServerFramework('express')).toThrow(/server\.framework 'express' is not supported/);
16
+ expect(() => resolveServerFramework('express')).toThrow(/expected one of: koa/);
17
+ });
18
+
19
+ it('has an adapter for every declared name', () => {
20
+ for (const name of SERVER_FRAMEWORK_NAMES) {
21
+ expect(SERVER_FRAMEWORKS[name]?.name).toBe(name);
22
+ }
23
+ });
24
+ });