diff --git a/src/index.ts b/src/index.ts index f667387e..1bf2dbd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,6 +117,7 @@ import type { MapResponse, Checksum, MacroManager, + MacroIntrospectionMetadata, MacroToProperty, TransformHandler, MetadataBase, @@ -281,6 +282,16 @@ export default class Elysia< } } + // Stores macro options applied via `.guard({ ...macros })` + // so we can run macro `introspect` once per route with full metadata. + // Is there a way to do this without a separate store? + protected guardMacroOptions: Record = {} + + // Flag to skip macro introspection for routes added to child instances inside groups + // Macro introspection will happen when routes are added to the parent with the fully-resolved path + // Is there a way to do this without a flag? + protected skipMacroIntrospection?: boolean + protected standaloneValidator: StandaloneValidator = { global: null, scoped: null, @@ -487,7 +498,65 @@ export default class Elysia< localHook ??= {} - this.applyMacro(localHook) + // Normalize path before macro introspection to ensure metadata has the correct path + if (path !== '' && path.charCodeAt(0) !== 47) path = '/' + path + if (this.config.prefix && !skipPrefix) path = this.config.prefix + path + + // Run macro introspection for guard-level macros (configured via `.guard({ macro: ... })`) + // once per route with the fully-resolved path. + // Skip introspection for routes added to child instances inside groups + // Macro introspection will happen when routes are added to the parent with the fully-resolved path + if ( + !this.skipMacroIntrospection && + this.guardMacroOptions && + Object.keys(this.guardMacroOptions).length + ) { + const macro = this.extender.macro + + for (const [key, value] of Object.entries(this.guardMacroOptions)) { + if (!(key in macro)) continue + + const macroDef = macro[key] + const macroHook = + typeof macroDef === 'function' ? macroDef(value) : macroDef + + if ( + !macroHook || + (typeof macroDef === 'object' && value === false) + ) + continue + + const introspectFn = + typeof macroHook === 'object' && + macroHook !== null && + 'introspect' in macroHook + ? ( + macroHook as { + introspect?: ( + option: Record, + context: MacroIntrospectionMetadata + ) => unknown + } + ).introspect + : undefined + + if (typeof introspectFn === 'function') { + const introspectOptions: Record = { + [key]: value + } + + introspectFn(introspectOptions, { path, method }) + } + } + } + + // Skip macro introspection for routes added to child instances inside groups + // Macro introspection will happen when routes are added to the parent with the fully-resolved path + if (!this.skipMacroIntrospection) { + this.applyMacro(localHook, localHook, { + metadata: { path, method } + }) + } let standaloneValidators = [] as InputSchema[] @@ -511,9 +580,6 @@ export default class Elysia< this.standaloneValidator.global ) - if (path !== '' && path.charCodeAt(0) !== 47) path = '/' + path - if (this.config.prefix && !skipPrefix) path = this.config.prefix + path - if (localHook?.type) switch (localHook.type) { case 'text': @@ -3976,11 +4042,21 @@ export default class Elysia< scoped: [...(this.standaloneValidator.scoped ?? [])], global: [...(this.standaloneValidator.global ?? [])] } + // Mark this instance as being inside a group to skip macro introspection + // Macro introspection will happen when routes are added to the parent with the fully-resolved path + instance.skipMacroIntrospection = true const isSchema = typeof schemaOrRun === 'object' const sandbox = (isSchema ? run! : schemaOrRun)(instance) this.singleton = mergeDeep(this.singleton, instance.singleton) as any this.definitions = mergeDeep(this.definitions, instance.definitions) + // Merge macros from the group instance so introspect can be called when routes are added to the parent + if (isNotEmpty(instance.extender.macro)) { + this.extender.macro = { + ...this.extender.macro, + ...instance.extender.macro + } + } if (sandbox.event.request?.length) this.event.request = [ @@ -3996,6 +4072,20 @@ export default class Elysia< this.model(sandbox.definitions.type) + // Merge guardMacroOptions from group instance to parent during route merging + // so guard macro introspection happens with correct options when routes are added + // The parent's guardMacroOptions will be restored after merging is complete + const originalGuardMacroOptions = this.guardMacroOptions + if ( + instance.guardMacroOptions && + Object.keys(instance.guardMacroOptions).length + ) { + this.guardMacroOptions = { + ...this.guardMacroOptions, + ...instance.guardMacroOptions + } + } + Object.values(instance.router.history).forEach( ({ method, path, handler, hooks }) => { path = @@ -4066,6 +4156,9 @@ export default class Elysia< } ) + // Restore original guardMacroOptions + this.guardMacroOptions = originalGuardMacroOptions + return this as any } @@ -4472,6 +4565,38 @@ export default class Elysia< ): AnyElysia { if (!run) { if (typeof hook === 'object') { + // Capture guard-level macro options so we can introspect them per-route later + const macro = this.extender.macro + if (macro && typeof macro === 'object') { + const updatedGuardMacroOptions = { + ...this.guardMacroOptions + } + let guardMacroOptionsChanged = false + + for (const [key, value] of Object.entries(hook)) { + if (!(key in macro)) continue + + const macroDef = macro[key] + + // Match object-style macro semantics: value=false disables the macro entirely + if (typeof macroDef === 'object' && value === false) { + if (key in updatedGuardMacroOptions) { + delete updatedGuardMacroOptions[key] + guardMacroOptionsChanged = true + } + continue + } + + if (updatedGuardMacroOptions[key] !== value) { + updatedGuardMacroOptions[key] = value + guardMacroOptionsChanged = true + } + } + + if (guardMacroOptionsChanged) + this.guardMacroOptions = updatedGuardMacroOptions + } + this.applyMacro(hook) if (hook.detail) { @@ -4558,6 +4683,41 @@ export default class Elysia< instance.definitions = { ...this.definitions } instance.inference = cloneInference(this.inference) instance.extender = { ...this.extender } + instance.guardMacroOptions = { ...this.guardMacroOptions } + // Apply guard hook's macro options to child instance so routes are introspected + // with the correct options when added (not when merged) + if (typeof hook === 'object') { + const macro = this.extender.macro + if (macro && typeof macro === 'object') { + const updatedGuardMacroOptions = { + ...instance.guardMacroOptions + } + let guardMacroOptionsChanged = false + + for (const [key, value] of Object.entries(hook)) { + if (!(key in macro)) continue + + const macroDef = macro[key] + + // Match object-style macro semantics: value=false disables the macro entirely + if (typeof macroDef === 'object' && value === false) { + if (key in updatedGuardMacroOptions) { + delete updatedGuardMacroOptions[key] + guardMacroOptionsChanged = true + } + continue + } + + if (updatedGuardMacroOptions[key] !== value) { + updatedGuardMacroOptions[key] = value + guardMacroOptionsChanged = true + } + } + + if (guardMacroOptionsChanged) + instance.guardMacroOptions = updatedGuardMacroOptions + } + } instance.getServer = () => this.getServer() const sandbox = run(instance) @@ -4581,6 +4741,15 @@ export default class Elysia< this.model(sandbox.definitions.type) + // Routes are already introspected for guard-level macros when added to child instances + // so we don't need to introspect guard-level macros again when merging routes back + // Temporarily skip macro introspection and set guardMacroOptions to empty + // Then manually apply macros without introspection to ensure macros are still applied + const originalGuardMacroOptions = this.guardMacroOptions + const originalSkipMacroIntrospection = this.skipMacroIntrospection + this.skipMacroIntrospection = true + this.guardMacroOptions = {} + Object.values(instance.router.history).forEach( ({ method, path, handler, hooks: localHook }) => { const { @@ -4596,41 +4765,50 @@ export default class Elysia< const hasStandaloneSchema = body || headers || query || params || cookie || response + const mergedHook = mergeHook(guardHook as AnyLocalHook, { + ...((localHook || {}) as AnyLocalHook), + error: !localHook.error + ? sandbox.event.error + : Array.isArray(localHook.error) + ? [ + ...(localHook.error ?? []), + ...(sandbox.event.error ?? []) + ] + : [ + localHook.error, + ...(sandbox.event.error ?? []) + ], + standaloneValidator: !hasStandaloneSchema + ? localHook.standaloneValidator + : [ + ...(localHook.standaloneValidator ?? []), + { + body, + headers, + query, + params, + cookie, + response + } + ] + }) + + // Apply macros without introspection (no metadata passed) before adding route + this.applyMacro(mergedHook, mergedHook) + this.add( method, path, handler, - mergeHook(guardHook as AnyLocalHook, { - ...((localHook || {}) as AnyLocalHook), - error: !localHook.error - ? sandbox.event.error - : Array.isArray(localHook.error) - ? [ - ...(localHook.error ?? []), - ...(sandbox.event.error ?? []) - ] - : [ - localHook.error, - ...(sandbox.event.error ?? []) - ], - standaloneValidator: !hasStandaloneSchema - ? localHook.standaloneValidator - : [ - ...(localHook.standaloneValidator ?? []), - { - body, - headers, - query, - params, - cookie, - response - } - ] - }) + mergedHook ) } ) + // Restore original guardMacroOptions and skipMacroIntrospection + this.guardMacroOptions = originalGuardMacroOptions + this.skipMacroIntrospection = originalSkipMacroIntrospection + return this as any } @@ -5390,8 +5568,13 @@ export default class Elysia< appliable: AnyLocalHook = localHook, { iteration = 0, - applied = {} - }: { iteration?: number; applied?: { [key: number]: true } } = {} + applied = {}, + metadata + }: { + iteration?: number + applied?: { [key: number]: true } + metadata?: MacroIntrospectionMetadata + } = {} ) { if (iteration >= 16) return const macro = this.extender.macro @@ -5429,7 +5612,17 @@ export default class Elysia< } if (k === 'introspect') { - value?.(localHook) + // Only run introspect when route metadata is available. + // Guard-level macros (configured via `.guard({ macro: ... })`) + // are introspected per-route in `add`. + if (metadata) { + // Call introspect with only the options relevant to the current macro key + const introspectOptions: Record = { + [key]: appliable[key] + } + + value?.(introspectOptions, metadata) + } delete localHook[key] continue @@ -5449,7 +5642,7 @@ export default class Elysia< this.applyMacro( localHook, { [k]: value }, - { applied, iteration: iteration + 1 } + { applied, iteration: iteration + 1, metadata } ) delete localHook[key] @@ -8214,6 +8407,7 @@ export type { MapResponse, BaseMacro, MacroManager, + MacroIntrospectionMetadata, MacroToProperty, MergeElysiaInstances, MaybeArray, diff --git a/src/types.ts b/src/types.ts index 22e72d17..22d63d36 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1943,6 +1943,23 @@ export type BaseMacro = Record< export type MaybeValueOrVoidFunction = T | ((...a: any) => void | T) +export interface MacroIntrospectionMetadata { + /** + * Metadata of the unresolved route being introspected. + * + * @example + * '/route/:id' + */ + path: string + /** + * HTTP method of the unresolved route being introspected. + * + * @example + * 'GET' + */ + method: HTTPMethod +} + export interface MacroProperty< in out Macro extends BaseMacro = {}, in out TypedRoute extends RouteSchema = {}, @@ -1970,9 +1987,13 @@ export interface MacroProperty< /** * Introspect hook option for documentation generation or analysis * - * @param option + * @param option The options passed to the macro + * @param context The metadata of the introspection. */ - introspect?(option: Prettify): unknown + introspect?( + option: Record, + context: MacroIntrospectionMetadata + ): unknown } export interface Macro< diff --git a/test/macro/macro.test.ts b/test/macro/macro.test.ts index 2dbd7d9d..f7bee83b 100644 --- a/test/macro/macro.test.ts +++ b/test/macro/macro.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { describe, it, expect } from 'bun:test' -import { Elysia, t, status } from '../../src' +import { describe, expect, it } from 'bun:test' +import { Elysia, status, t } from '../../src' import { post, req } from '../utils' describe('Macro', () => { @@ -1438,4 +1438,589 @@ describe('Macro', () => { expect(invalid3.status).toBe(422) }) + + it('introspect: shorthand style 1', () => { + let introspectCalled = false + let capturedIntrospectOptions: any + let capturedMetadata: any + + const aPlugin = new Elysia({ name: 'a-plugin' }).macro('a', { + introspect(introspectOptions, metadata) { + introspectCalled = true + capturedIntrospectOptions = introspectOptions + capturedMetadata = metadata + } + }) + + const app = new Elysia() + .use(aPlugin) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + + expect(introspectCalled).toBe(true) + expect(capturedIntrospectOptions).toEqual({ + a: true + }) + expect(capturedMetadata).toHaveProperty('path', '/route2/:id') + expect(capturedMetadata).toHaveProperty('method', 'POST') + }) + + it('introspect: shorthand style 2', () => { + let introspectCalled = false + let capturedIntrospectOptions: any + let capturedMetadata: any + + const aPlugin = new Elysia({ name: 'a-plugin' }).macro({ + a: { + introspect(introspectOptions, metadata) { + introspectCalled = true + capturedIntrospectOptions = introspectOptions + capturedMetadata = metadata + } + } + }) + + const app = new Elysia() + .use(aPlugin) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + + expect(introspectCalled).toBe(true) + expect(capturedIntrospectOptions).toEqual({ + a: true + }) + expect(capturedMetadata).toHaveProperty('path', '/route2/:id') + expect(capturedMetadata).toHaveProperty('method', 'POST') + }) + + it('introspect: longhand style', () => { + let introspectCalled = false + let aOptions: any + let capturedIntrospectOptions: any + let capturedMetadata: any + + const aPlugin = new Elysia({ name: 'a-plugin' }).macro({ + a: (options) => ({ + introspect(introspectOptions, metadata) { + introspectCalled = true + aOptions = options + capturedIntrospectOptions = introspectOptions + capturedMetadata = metadata + } + }) + }) + + const app = new Elysia() + .use(aPlugin) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + + expect(introspectCalled).toBe(true) + expect(aOptions).toBe(true) + expect(capturedIntrospectOptions).toEqual({ + a: true + }) + expect(capturedMetadata).toHaveProperty('path', '/route2/:id') + expect(capturedMetadata).toHaveProperty('method', 'POST') + }) + + it('introspect: guard with conflicting macros', () => { + const data: { + options: any + introspectOptions: any + metadata: any + }[] = [] + + const aPlugin = new Elysia({ name: 'a-plugin' }).macro({ + a: (options) => ({ + introspect(introspectOptions, metadata) { + data.push({ options, introspectOptions, metadata }) + } + }) + }) + + const app = new Elysia().use(aPlugin).guard({ a: true }, (app) => + app + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: false + }) + ) + + expect(data).toEqual( + expect.arrayContaining([ + { + options: false, + introspectOptions: { a: false }, + metadata: { path: '/route2/:id', method: 'POST' } + }, + { + options: true, + introspectOptions: { a: true }, + metadata: { path: '/route1', method: 'GET' } + }, + { + options: true, + introspectOptions: { a: true }, + metadata: { path: '/route2/:id', method: 'POST' } + } + ]) + ) + expect(data.length).toBe(3) + }) + + it('introspect: resolve guard', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia() + .macro({ + account: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + }, + resolve: () => ({ + account: 'A' + }) + }) + }) + .guard({ + account: true + }) + .get('/local', ({ account }) => account === 'A') + + expect(data).toEqual([ + { + introspectOptions: { account: true }, + metadata: { path: '/local', method: 'GET' } + } + ]) + }) + + it('introspect: nested routes', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia({ prefix: '/prefix' }) + + .macro({ + a: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + } + }) + }) + .group('/group', (app) => + app + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + ) + + expect(data).toEqual([ + { + introspectOptions: { a: true }, + metadata: { path: '/prefix/group/route2/:id', method: 'POST' } + } + ]) + }) + + it('introspect: macro defined inside groups', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia({ prefix: '/prefix' }).group('/group1', (app) => + app.group('/group2', (app) => + app + .macro({ + a: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + } + }) + }) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + ) + ) + + expect(data).toEqual([ + { + introspectOptions: { a: true }, + metadata: { + path: '/prefix/group1/group2/route2/:id', + method: 'POST' + } + } + ]) + }) + + it('introspect: macro called inside group', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia() + app.macro({ + myMacro: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + } + }) + }).group('/api', (group) => + group + .guard({ myMacro: true }) + .get('/users/:id', () => 'Hello from users/:id') + ) + + expect(data).toEqual([ + { + introspectOptions: { myMacro: true }, + metadata: { + path: '/api/users/:id', + method: 'GET' + } + } + ]) + }) + + it('introspect: macro inside guard', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia({ prefix: '/prefix' }).guard({}, (app) => + app + .macro({ + a: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + } + }) + }) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + ) + + expect(data).toEqual([ + { + introspectOptions: { a: true }, + metadata: { + path: '/route2/:id', + method: 'POST' + } + } + ]) + }) + + it('introspect: macro inside nested guards', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia({ prefix: '/prefix' }).guard({}, (app) => + app.guard({}, (app) => + app + .macro({ + a: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + } + }) + }) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + ) + ) + + expect(data).toEqual([ + { + introspectOptions: { a: true }, + metadata: { + path: '/route2/:id', + method: 'POST' + } + } + ]) + }) + + it('introspect: macro inside guard with group', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia({ prefix: '/prefix' }).group('/group1', (app) => + app.guard({}, (app) => + app + .macro({ + a: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + } + }) + }) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + ) + ) + + expect(data).toEqual([ + { + introspectOptions: { a: true }, + metadata: { + path: '/route2/:id', + method: 'POST' + } + } + ]) + }) + + it('introspect: macro inside group with guard', () => { + const data: { + introspectOptions: any + metadata: any + }[] = [] + + const app = new Elysia({ prefix: '/prefix' }).guard({}, (app) => + app.group('/group1', (app) => + app + .macro({ + a: (a: boolean) => ({ + introspect(introspectOptions, metadata) { + data.push({ introspectOptions, metadata }) + } + }) + }) + .get('/route1', () => 'Hello from route1') + .post('/route2/:id', () => 'Hello from route2', { + a: true + }) + ) + ) + + expect(data).toEqual([ + { + introspectOptions: { a: true }, + metadata: { + path: '/group1/route2/:id', + method: 'POST' + } + } + ]) + }) + + it('introspect: not called for routes without macros', () => { + let introspectCalled = false + + const aPlugin = new Elysia({ name: 'a-plugin' }).macro({ + a: () => ({ + introspect() { + introspectCalled = true + } + }) + }) + + new Elysia().use(aPlugin).get('/route1', () => 'Hello from route1') + + expect(introspectCalled).toBe(false) + }) + + it('introspect: multiple macros', () => { + const aData: { + introspectOptions: any + metadata: any + }[] = [] + const bData: { + introspectOptions: any + metadata: any + }[] = [] + + const aPlugin = new Elysia({ name: 'a-plugin' }).macro({ + a: () => ({ + introspect(introspectOptions, metadata) { + aData.push({ introspectOptions, metadata }) + } + }), + b: () => ({ + introspect(introspectOptions, metadata) { + bData.push({ introspectOptions, metadata }) + } + }) + }) + + const app = new Elysia() + .use(aPlugin) + .get('/route', () => 'Hello from route') + .post('/routeA/:a', () => 'Hello from routeA', { + a: true + }) + .delete('/routeB/:b', () => 'Hello from routeB', { + b: true + }) + .put('/routeAB/:ab', () => 'Hello from routeAB', { + a: true, + b: true + }) + + expect(aData).toEqual( + expect.arrayContaining([ + { + introspectOptions: { a: true }, + metadata: { path: '/routeA/:a', method: 'POST' } + }, + { + introspectOptions: { a: true }, + metadata: { path: '/routeAB/:ab', method: 'PUT' } + } + ]) + ) + expect(aData.length).toBe(2) + expect(bData).toEqual( + expect.arrayContaining([ + { + introspectOptions: { b: true }, + metadata: { path: '/routeB/:b', method: 'DELETE' } + }, + { + introspectOptions: { b: true }, + metadata: { path: '/routeAB/:ab', method: 'PUT' } + } + ]) + ) + expect(bData.length).toBe(2) + }) + + it('introspect: not called for disabled macro', () => { + const introspectCalled: boolean[] = [] + + const aPlugin = new Elysia({ name: 'a-plugin' }).macro({ + a: { + introspect() { + introspectCalled.push(true) + } + } + }) + + const app = new Elysia() + .use(aPlugin) + .get('/enabled', () => 'hello', { + a: true + }) + .get('/disabled', () => 'hi', { + a: false + }) + + // The macro's introspect should be called for 'enabled' but not 'disabled' + expect(introspectCalled.length).toBe(1) + }) + + it('introspect: not run for macro keys set to false', () => { + // This test documents the current behavior. + const calls: any[] = [] + + const plugin = new Elysia({ name: 'macro-plugin' }).macro({ + a: { + introspect(opt, meta) { + calls.push([opt, meta]) + } + } + }) + + const app = new Elysia() + .use(plugin) + .get('/a', () => 1, { a: true }) + .get('/b', () => 2, { a: false }) + + expect(calls.length).toBe(1) + expect(calls[0]?.[0]).toEqual({ a: true }) + // No call with { a: false } + }) + + it('guard({ macro: false }) clears stale introspection state', () => { + // Test that disabling a macro in a guard removes it from guardMacroOptions + // so subsequent routes don't introspect with stale { macro: true } + const calls: { + introspectOptions: any + metadata: any + }[] = [] + + const plugin = new Elysia({ name: 'macro-plugin' }).macro({ + a: { + introspect(introspectOptions, metadata) { + calls.push({ introspectOptions, metadata }) + } + } + }) + + const app = new Elysia() + .use(plugin) + .guard({ a: true }, (app) => + app + .get('/route1', () => 'route1') + .guard({ a: false }, (app) => + app.get('/route2', () => 'route2') + ) + ) + expect(calls).toEqual([ + { + introspectOptions: { a: true }, + metadata: { path: '/route1', method: 'GET' } + } + ]) + }) + + it('guard({ macro: false }) prevents stale introspection on route after guard disabled', () => { + const calls: { + introspectOptions: any + metadata: any + }[] = [] + + const plugin = new Elysia({ name: 'macro-plugin' }).macro({ + a: { + introspect(introspectOptions, metadata) { + calls.push({ introspectOptions, metadata }) + } + } + }) + + const app = new Elysia() + .use(plugin) + .guard({ a: true }, (app) => app.get('/route1', () => 'route1')) + .guard({ a: false }, (app) => app.get('/route2', () => 'route2')) + .get('/route3', () => 'route3') + + // Route1 should introspect with { a: true } + // Route2 and route3 should NOT introspect with stale { a: true } + // Only route1 should have introspected + expect(calls.length).toBe(1) + expect(calls[0]?.introspectOptions).toEqual({ a: true }) + expect(calls[0]?.metadata.path).toBe('/route1') + // Route2 and route3 should not have introspected at all since the macro was disabled + // If the bug exists, they would introspect with stale { a: true } + }) })