Files
afilmory/be/packages/framework/tests/application.spec.ts

1998 lines
57 KiB
TypeScript

import 'reflect-metadata'
import { ReadableStream } from 'node:stream/web'
import type { Context, Next } from 'hono'
import { injectable } from 'tsyringe'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import type {
ArgumentMetadata,
CanActivate,
Constructor,
ExceptionFilter,
ExecutionContext,
FrameworkResponse,
Interceptor,
PipeTransform,
RouteParamMetadataItem,
} from '../src'
import {
BadRequestException,
BeforeApplicationShutdown,
Body,
ContextParam,
Controller,
createApplication,
createLogger,
Delete,
forwardRef,
Get,
Headers,
HonoHttpApplication,
HttpContext,
HttpMiddleware,
Middleware,
MiddlewareDefinition,
Module,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
Param,
Post,
Query,
Req,
RouteParamtypes,
UseFilters,
UseGuards,
UseInterceptors,
ZodSchema,
ZodValidationPipe,
} from '../src'
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE, ROUTE_ARGS_METADATA } from '../src/constants'
import type { ArgumentsHost, CallHandler } from '../src/interfaces'
const BASE_URL = 'http://localhost'
const GENERATED_RESPONSE = Symbol.for('hono.framework.generatedResponse')
declare module '../src/context/http-context' {
interface HttpContextValues {
auth?: {
apiKey?: string
allowed?: boolean
}
}
}
function createRequest(path: string, init?: RequestInit) {
return new Request(`${BASE_URL}${path}`, init)
}
const callOrder: string[] = []
function FactoryParam() {
return (target: object, propertyKey: string | symbol, parameterIndex: number) => {
const existing = (Reflect.getMetadata(ROUTE_ARGS_METADATA, target, propertyKey) || []) as RouteParamMetadataItem[]
existing.push({
index: parameterIndex,
type: RouteParamtypes.CUSTOM,
pipes: [],
factory: () => 'factory-value',
})
Reflect.defineMetadata(ROUTE_ARGS_METADATA, existing, target, propertyKey)
}
}
@injectable()
class SharedService {
getValue() {
return 'shared'
}
}
@injectable()
class DemoService {
constructor(private readonly shared: SharedService) {}
greet(name: string) {
return `Hello ${name.toUpperCase()} from ${this.shared.getValue()}`
}
}
@injectable()
class ApiKeyGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
callOrder.push('global-guard')
const httpContext = context.getContext()
expect(HttpContext.get()).toBe(httpContext)
const apiKey = httpContext.hono.req.header('x-api-key') ?? undefined
HttpContext.assign({
auth: {
...httpContext.auth,
apiKey,
},
})
return apiKey === 'test-key'
}
}
@injectable()
class AllowGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
callOrder.push('method-guard')
const httpContext = context.getContext()
const allowed = httpContext.hono.req.header('x-allow') === 'yes'
HttpContext.assign({
auth: {
...httpContext.auth,
allowed,
},
})
return allowed
}
}
@injectable()
class GlobalPipe implements PipeTransform<unknown> {
transform(value: unknown, metadata) {
callOrder.push(`pipe-${metadata.type}`)
if (metadata.type === 'query' && typeof value === 'string') {
return `${value}-global`
}
return value
}
}
@injectable()
class TrackingZodPipe extends ZodValidationPipe {
constructor() {
super({
transform: true,
whitelist: true,
errorHttpStatusCode: 422,
forbidUnknownValues: true,
enableDebugMessages: true,
stopAtFirstError: true,
})
}
transform(value: unknown, metadata: ArgumentMetadata) {
callOrder.push(`zod-${metadata.type}:${metadata.metatype?.name ?? 'unknown'}`)
return super.transform(value, metadata)
}
}
@injectable()
class DoublePipe implements PipeTransform<unknown, number> {
transform(value: unknown) {
callOrder.push('double-pipe')
const parsed = Number(value)
if (Number.isNaN(parsed)) {
throw new BadRequestException('NaN')
}
return parsed * 2
}
}
@injectable()
class GlobalInterceptor implements Interceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
callOrder.push('global-interceptor-before')
const result = await next.handle()
callOrder.push('global-interceptor-after')
return result
}
}
@injectable()
class MethodInterceptor implements Interceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
callOrder.push('method-interceptor-before')
const result = await next.handle()
callOrder.push('method-interceptor-after')
const generated = (result as unknown as Record<PropertyKey, unknown>)[GENERATED_RESPONSE]
const contentType = result.headers.get('content-type') ?? ''
if (generated && contentType.includes('text/plain')) {
const text = await result.text()
const headerEntries = Array.from(result.headers.entries())
if (!headerEntries.some(([key]) => key.toLowerCase() === 'content-type')) {
headerEntries.push(['content-type', 'text/plain; charset=utf-8'])
}
const response = new Response(`${text}|intercepted`, {
status: result.status,
statusText: result.statusText,
headers: headerEntries,
})
Reflect.set(response as unknown as Record<PropertyKey, unknown>, GENERATED_RESPONSE, true)
return response
}
return result
}
}
@injectable()
class MissingService {
constructor(private readonly value: string) {}
}
@injectable()
@Controller('broken')
class BrokenController {
constructor(private readonly missing: MissingService) {}
@Get('/')
handler() {
return 'broken'
}
}
@Module({
controllers: [BrokenController],
})
class BrokenModule {}
@Module({
imports: [forwardRef(() => ForwardRefRightModule)],
})
class ForwardRefLeftModule {}
@Module({
imports: [forwardRef(() => ForwardRefLeftModule)],
})
class ForwardRefRightModule {}
class CustomError extends Error {}
class StacklessError extends Error {
constructor() {
super('stackless')
this.stack = undefined
}
}
@injectable()
class GlobalExceptionFilter implements ExceptionFilter {
async catch(exception: unknown, host: ArgumentsHost) {
if (exception instanceof CustomError) {
const ctx = host.getContext().hono
return ctx.json({ handled: 'custom' }, 418)
}
return
}
}
@injectable()
class MethodExceptionFilter implements ExceptionFilter {
async catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.getContext().hono
return ctx.json(
{
handled: true,
message: (exception as Error).message,
},
422,
)
}
}
@injectable()
class MiddlewareTracker {
readonly events: string[] = []
}
@Middleware({ path: '/*', priority: -20 })
@injectable()
class FirstTestMiddleware implements HttpMiddleware {
constructor(private readonly tracker: MiddlewareTracker) {}
async use(context: Context, next: Next): Promise<void> {
this.tracker.events.push(`first-before:${context.req.path}`)
await next()
this.tracker.events.push(`first-after:${context.req.path}`)
}
}
@Middleware({ path: '/*', priority: 0 })
@injectable()
class SecondTestMiddleware implements HttpMiddleware {
constructor(private readonly tracker: MiddlewareTracker) {}
async use(context: Context, next: Next): Promise<void> {
this.tracker.events.push(`second-before:${context.req.path}`)
await next()
this.tracker.events.push(`second-after:${context.req.path}`)
}
}
@injectable()
@Controller('middleware')
class MiddlewareController {
constructor(private readonly tracker: MiddlewareTracker) {}
@Get('/')
handle() {
this.tracker.events.push('handler')
return { ok: true }
}
}
@Module({
controllers: [MiddlewareController],
providers: [
MiddlewareTracker,
{
provide: APP_MIDDLEWARE as unknown as Constructor,
useClass: FirstTestMiddleware,
},
{
provide: APP_MIDDLEWARE as unknown as Constructor,
useClass: SecondTestMiddleware,
},
],
})
class MiddlewareTestModule {}
@injectable()
class ContextPropagationMiddleware implements HttpMiddleware {
async use(context: Context, next: Next): Promise<void> {
HttpContext.assign({
auth: {
...HttpContext.get().auth,
apiKey: context.req.header('x-api-key') ?? 'missing',
},
})
await next()
}
}
@Controller('context-prop')
class ContextPropagationController {
@Get('/')
read() {
return { apiKey: HttpContext.get().auth?.apiKey }
}
}
@Module({
controllers: [ContextPropagationController],
providers: [
{
provide: APP_MIDDLEWARE as unknown as Constructor,
useClass: ContextPropagationMiddleware,
},
],
})
class ContextPropagationModule {}
const BodySchema = z
.object({
message: z.string({ message: 'message required' }),
tags: z.array(z.string()).default([]),
})
.describe('BodySchema')
@ZodSchema(BodySchema)
class BodyDto {
message!: string
tags!: string[]
}
@UseInterceptors(MethodInterceptor)
@Controller('demo')
@injectable()
class DemoController {
constructor(private readonly service: DemoService) {}
@Get('/')
async greet(
@Query('name') name: string,
@Headers('x-extra') header: string | undefined,
@ContextParam() contextParam: Context,
@Req() request: Request,
context: Context,
) {
expect(contextParam).toBe(context)
expect((request as any).raw).toBeInstanceOf(Request)
HttpContext.setContext(context)
return `${this.service.greet(name)}|header:${header ?? 'none'}`
}
@Get('/guarded')
@UseGuards(AllowGuard)
guarded() {
return 'guarded'
}
@Get('/raw')
raw(context: Context) {
const response = context.newResponse('raw-body')
context.res = response
return context.res
}
@Get('/buffer')
buffer() {
return new TextEncoder().encode('buffer')
}
@Get('/array-buffer')
arrayBuffer() {
return new ArrayBuffer(16)
}
@Get('/stream')
stream() {
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('stream-data'))
controller.close()
},
})
}
@Get('/http-error')
httpError() {
throw new BadRequestException({ message: 'bad' })
}
@Get('/global-error')
globalError() {
throw new Error('boom')
}
@Get('/stackless-error')
stacklessError() {
throw new StacklessError()
}
@Get('/string-error')
stringError() {
throw 'string failure'
}
@Get('/custom-error')
throwCustom() {
throw new CustomError('custom')
}
@Get('/void')
voidRoute() {}
@Get('/full-query')
fullQuery(@Query() query: Record<string, string | undefined>) {
return query
}
@Get('/full-params/:id/:slug')
fullParams(@Param() params: Record<string, string>) {
return params
}
@Post('/double/:id')
@UseGuards(AllowGuard)
async double(
@Param('id', DoublePipe) id: number,
@Body() payload: BodyDto,
@Body() rawBody: unknown,
@Headers() headers: Record<string, string>,
context: Context,
) {
context.header('x-double', String(id))
return {
id,
payload,
isDtoInstance: payload instanceof BodyDto,
rawBody,
headerCount: Object.keys(headers).length,
}
}
@Post('/plain')
async plain(@Body() payload: unknown) {
return { payload }
}
@Post('/cache')
async cache(@Body() first: unknown, @Body() second: unknown) {
return { same: first === second }
}
@Delete('/method-filter')
@UseFilters(MethodExceptionFilter)
methodFilter() {
throw new Error('broken')
}
}
Reflect.defineMetadata(
'design:paramtypes',
[Number, BodyDto, Object, Object, Object],
DemoController.prototype,
'double',
)
@Module({
providers: [SharedService],
})
class SharedModule {}
@Module({
imports: [SharedModule, SharedModule],
controllers: [DemoController],
providers: [
DemoService,
ApiKeyGuard,
AllowGuard,
GlobalPipe,
TrackingZodPipe,
DoublePipe,
GlobalInterceptor,
MethodInterceptor,
GlobalExceptionFilter,
MethodExceptionFilter,
],
})
class RootModule {}
@injectable()
@Controller('factory')
class FactoryController {
@Get('/')
handle(@FactoryParam() value: string) {
return value
}
}
@Module({
controllers: [FactoryController],
})
class FactoryModule {}
@injectable()
@Controller({ prefix: 'static', bypassGlobalPrefix: true })
class StaticController {
@Get('/asset')
asset() {
return 'static-asset'
}
}
@Module({ controllers: [StaticController] })
class StaticModule {}
const lifecycleEvents: string[] = []
@injectable()
class LifecycleProvider
implements OnModuleInit, OnModuleDestroy, OnApplicationBootstrap, BeforeApplicationShutdown, OnApplicationShutdown
{
onModuleInit(): void {
lifecycleEvents.push('module:init')
}
onApplicationBootstrap(): void {
lifecycleEvents.push('app:bootstrap')
}
beforeApplicationShutdown(signal?: string): void {
lifecycleEvents.push(`before:${signal ?? 'none'}`)
}
async onModuleDestroy(): Promise<void> {
lifecycleEvents.push('module:destroy')
}
onApplicationShutdown(signal?: string): void {
lifecycleEvents.push(`app:shutdown:${signal ?? 'none'}`)
}
}
@Module({
providers: [LifecycleProvider],
})
class LifecycleModule {}
describe('HonoHttpApplication end-to-end', () => {
let fetcher: (request: Request) => Promise<Response>
let app: HonoHttpApplication
it('emits metadata for DTO parameters', () => {
const paramTypes = (Reflect.getMetadata('design:paramtypes', DemoController.prototype, 'double') ??
[]) as Constructor[]
expect(paramTypes.length).toBeGreaterThan(1)
expect(paramTypes[1]).toBe(BodyDto)
})
beforeAll(async () => {
app = await createApplication(RootModule, { globalPrefix: '/api' })
app.useGlobalGuards(new ApiKeyGuard())
app.useGlobalPipes(new GlobalPipe(), new TrackingZodPipe())
app.useGlobalInterceptors(new GlobalInterceptor())
app.useGlobalFilters(new GlobalExceptionFilter())
fetcher = (request) => Promise.resolve(app.getInstance().fetch(request))
})
afterAll(async () => {
await app.close('test-suite')
})
beforeEach(() => {
callOrder.length = 0
})
const authorizedHeaders = (extra: Record<string, string> = {}) => ({
'x-api-key': 'test-key',
...extra,
})
it('processes successful request through guards, pipes, and interceptors', async () => {
const response = await fetcher(
createRequest('/api/demo?name=neo', {
headers: authorizedHeaders({ 'x-extra': 'value' }),
}),
)
const text = await response.text()
expect(text).toContain('Hello NEO-GLOBAL')
expect(text).toContain('header:value')
expect(text).toContain('intercepted')
expect(callOrder[0]).toBe('global-guard')
expect(callOrder).toContain('pipe-query')
expect(callOrder).toContain('method-interceptor-before')
expect(callOrder).toContain('method-interceptor-after')
expect(callOrder.indexOf('method-interceptor-before')).toBeLessThan(callOrder.indexOf('method-interceptor-after'))
})
it('enforces method guard and returns forbidden', async () => {
const response = await fetcher(
createRequest('/api/demo/guarded', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(403)
})
it('auto-registers class-based enhancers from decorators without providers', async () => {
@injectable()
class InlineGuard implements CanActivate {
async canActivate(): Promise<boolean> {
callOrder.push('inline-guard')
return true
}
}
@Controller('inline')
@injectable()
class InlineController {
@Get('/')
@UseGuards(InlineGuard)
handle() {
return 'ok'
}
}
@Module({ controllers: [InlineController] })
class InlineModule {}
const app = await createApplication(InlineModule)
const fetcher = (request: Request) => Promise.resolve(app.getInstance().fetch(request))
const response = await fetcher(createRequest('/inline'))
expect(response.status).toBe(200)
expect(callOrder).toContain('inline-guard')
await app.close('inline-enhancers')
})
it('returns context response when handler yields existing response', async () => {
const response = await fetcher(
createRequest('/api/demo/raw', {
headers: authorizedHeaders(),
}),
)
expect(await response.text()).toBe('raw-body')
})
it('preserves HttpContext values assigned by middleware for downstream handlers', async () => {
const app = await createApplication(ContextPropagationModule)
const hono = app.getInstance()
const fetcher = (request: Request) => Promise.resolve(hono.fetch(request))
const response = await fetcher(
createRequest('/context-prop', {
headers: { 'x-api-key': 'from-middleware' },
}),
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ apiKey: 'from-middleware' })
await app.close('context-propagation')
})
it('supports array buffer and readable stream responses', async () => {
const bufferResponse = await fetcher(createRequest('/api/demo/buffer', { headers: authorizedHeaders() }))
expect(await bufferResponse.arrayBuffer()).toBeInstanceOf(ArrayBuffer)
const arrayBufferResponse = await fetcher(createRequest('/api/demo/array-buffer', { headers: authorizedHeaders() }))
expect((await arrayBufferResponse.arrayBuffer()).byteLength).toBe(16)
const streamResponse = await fetcher(createRequest('/api/demo/stream', { headers: authorizedHeaders() }))
expect(await streamResponse.text()).toBe('stream-data')
})
it('returns default response when handler does not produce a payload', async () => {
const response = await fetcher(
createRequest('/api/demo/void', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(200)
expect(await response.text()).toBe('')
})
it('handles body parsing, caching, and zod validation', async () => {
const response = await fetcher(
createRequest('/api/demo/double/5', {
method: 'POST',
headers: {
...authorizedHeaders({
'x-allow': 'yes',
'content-type': 'application/json',
}),
},
body: JSON.stringify({ message: 'payload', tags: ['a'] }),
}),
)
expect(response.headers.get('x-double')).toBe('10')
const json = await response.json()
expect(json).toMatchObject({
id: 10,
payload: { message: 'payload', tags: ['a'] },
isDtoInstance: true,
})
expect(json.rawBody).toEqual({ message: 'payload', tags: ['a'] })
expect(callOrder).toContain('zod-body:BodyDto')
})
it('supports bypassing global prefix for specific controllers', async () => {
const app = await createApplication(StaticModule, { globalPrefix: '/api' })
const hono = app.getInstance()
const fetcher = (request: Request) => Promise.resolve(hono.fetch(request))
const success = await fetcher(createRequest('/static/asset'))
expect(success.status).toBe(200)
expect(await success.text()).toBe('static-asset')
const missing = await fetcher(createRequest('/api/static/asset'))
expect(missing.status).toBe(404)
await app.close('bypass-global-prefix')
})
it('surfaces validation errors for DTO payloads', async () => {
const response = await fetcher(
createRequest('/api/demo/double/5', {
method: 'POST',
headers: {
...authorizedHeaders({
'x-allow': 'yes',
'content-type': 'application/json',
}),
},
body: JSON.stringify({ tags: ['a'] }),
}),
)
expect(response.status).toBe(422)
const json = await response.json()
expect(json).toMatchObject({
statusCode: 422,
message: 'Validation failed',
errors: {
message: [expect.stringContaining('message required')],
},
meta: {
target: 'BodyDto',
paramType: 'body',
},
})
})
it('rejects body when payload is not json', async () => {
const response = await fetcher(
createRequest('/api/demo/plain', {
method: 'POST',
headers: {
...authorizedHeaders(),
'content-type': 'text/plain',
},
body: 'just text',
}),
)
expect(response.status).toBe(422)
const json = await response.json()
expect(json).toMatchObject({
statusCode: 422,
message: 'Payload must be a JSON object',
})
})
it('rejects body when content-type header is missing', async () => {
const response = await fetcher(
createRequest('/api/demo/plain', {
method: 'POST',
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(422)
expect(await response.json()).toMatchObject({
statusCode: 422,
message: 'Payload must be a JSON object',
})
})
it('caches parsed body across multiple parameters', async () => {
const response = await fetcher(
createRequest('/api/demo/cache', {
method: 'POST',
headers: {
...authorizedHeaders(),
'content-type': 'application/json',
},
body: JSON.stringify({ ok: true }),
}),
)
const json = await response.json()
expect(json).toEqual({ same: true })
})
it('provides full query objects when decorator omits key', async () => {
const response = await fetcher(
createRequest('/api/demo/full-query?name=neo&role=admin', {
headers: authorizedHeaders(),
}),
)
expect(await response.json()).toMatchObject({ name: 'neo', role: 'admin' })
})
it('provides full params when decorator omits key', async () => {
const response = await fetcher(
createRequest('/api/demo/full-params/123/slug', {
headers: authorizedHeaders(),
}),
)
expect(await response.json()).toMatchObject({ id: '123', slug: 'slug' })
})
it('propagates http exceptions as structured json', async () => {
const response = await fetcher(
createRequest('/api/demo/http-error', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ message: 'bad' })
})
it('handles errors without stack traces gracefully', async () => {
const response = await fetcher(
createRequest('/api/demo/stackless-error', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(500)
expect(await response.json()).toMatchObject({
message: 'Internal server error',
})
})
it('falls back to default 500 when filters do not handle error', async () => {
const response = await fetcher(
createRequest('/api/demo/global-error', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(500)
expect(await response.json()).toMatchObject({
message: 'Internal server error',
})
})
it('normalizes non-error throwables to 500 responses', async () => {
const response = await fetcher(
createRequest('/api/demo/string-error', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(500)
expect(await response.json()).toMatchObject({
message: 'Internal server error',
})
})
it('applies method filters overriding global behavior', async () => {
const response = await fetcher(
createRequest('/api/demo/method-filter', {
method: 'DELETE',
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(422)
expect(await response.json()).toMatchObject({
handled: true,
message: 'broken',
})
})
it('handles custom filter registered globally', async () => {
const response = await fetcher(
createRequest('/api/demo/custom-error', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(418)
expect(await response.json()).toMatchObject({ handled: 'custom' })
})
it('returns empty body when handler yields void', async () => {
const response = await fetcher(
createRequest('/api/demo/void', {
headers: authorizedHeaders(),
}),
)
expect(response.status).toBe(200)
expect(await response.text()).toBe('')
})
it('rejects invalid json payloads', async () => {
const response = await fetcher(
createRequest('/api/demo/double/5', {
method: 'POST',
headers: {
...authorizedHeaders({
'x-allow': 'yes',
'content-type': 'application/json',
}),
},
body: '{ invalid json',
}),
)
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({
message: 'Invalid JSON payload',
})
})
it('exposes the underlying dependency container', () => {
expect(app.getContainer()).toBeDefined()
})
it('emits parameter metadata logs when DEBUG is enabled', async () => {
const original = Reflect.getMetadata('design:paramtypes', DemoController.prototype, 'greet')
Reflect.defineMetadata('design:paramtypes', [String], DemoController.prototype, 'greet')
process.env.DEBUG = 'true'
try {
const response = await fetcher(
createRequest('/api/demo?name=params', {
headers: authorizedHeaders({ 'x-extra': 'value', 'x-allow': 'yes' }),
}),
)
expect(response.status).toBe(200)
} finally {
if (original) {
Reflect.defineMetadata('design:paramtypes', original, DemoController.prototype, 'greet')
} else {
Reflect.deleteMetadata('design:paramtypes', DemoController.prototype, 'greet')
}
delete process.env.DEBUG
}
})
})
describe('HonoHttpApplication logging', () => {
it('uses anonymous fallback module name when constructor is unnamed', async () => {
const infoLogs: string[] = []
const logger = createLogger('Framework', {
colors: false,
writer: {
info: (...args) => infoLogs.push(args.filter(Boolean).join(' ')),
debug: () => {},
warn: () => {},
error: () => {},
log: () => {},
},
})
const app = await createApplication(class {}, { logger })
await app.close('logger-test')
expect(infoLogs).not.toHaveLength(0)
infoLogs.forEach((entry) => {
expect(entry).toContain('AnonymousModule')
})
})
})
describe('HonoHttpApplication parameter factories', () => {
it('resolves values provided by metadata factories without pipes', async () => {
const app = await createApplication(FactoryModule)
const response = await app.getInstance().fetch(createRequest('/factory'))
expect(response.status).toBe(200)
expect(await response.text()).toBe('factory-value')
await app.close('factory-test')
})
})
describe('HonoHttpApplication internals', () => {
it('throws descriptive errors when dependency resolution fails', async () => {
await expect(createApplication(BrokenModule)).rejects.toThrowError(/Cannot inject the dependency missing/i)
})
it('supports forwardRef module relationships without infinite recursion', async () => {
const app = await createApplication(ForwardRefLeftModule)
expect(app.getInstance()).toBeDefined()
await app.close('forward-ref')
})
it('exposes initialization state through getInitialized()', async () => {
const app = new HonoHttpApplication(FactoryModule)
expect(app.getInitialized()).toBe(false)
await app.init()
expect(app.getInitialized()).toBe(true)
await app.close('initialization-state')
})
it('reuses an existing response object without wrapping', async () => {
const app = await createApplication(FactoryModule)
const context = {
res: { sentinel: true },
json: () => new Response('never'),
} as unknown as Context
const ensureResponse = (
app as unknown as {
ensureResponse: (ctx: Context, payload?: unknown) => Response | unknown
}
).ensureResponse.bind(app)
const result = ensureResponse(context, context.res)
expect(result).toBe(context.res)
await app.close('reuse-response')
await app.close('reuse-response-again')
})
it('falls back to context response when payload is undefined', async () => {
const app = await createApplication(FactoryModule)
const baseResponse = new Response('fallback')
const context = {
res: baseResponse,
json: () => new Response('never'),
} as unknown as Context
const ensureResponse = (
app as unknown as {
ensureResponse: (ctx: Context, payload?: unknown) => Response | unknown
}
).ensureResponse.bind(app)
const result = ensureResponse(context)
expect(result).toBe(baseResponse)
await app.close('undefined-payload')
})
it('throws a descriptive error when provider token is undefined', async () => {
const app = await createApplication(FactoryModule)
const getProviderInstance = (
app as unknown as {
getProviderInstance: (token: Constructor) => unknown
}
).getProviderInstance.bind(app)
expect(() => getProviderInstance(undefined as unknown as Constructor)).toThrowError(
/Cannot resolve provider for undefined token/,
)
await app.close('undefined-token')
})
it('preserves token identity when resolution fails for non-constructors', async () => {
const app = await createApplication(FactoryModule)
const getProviderInstance = (
app as unknown as {
getProviderInstance: (token: Constructor | string) => unknown
}
).getProviderInstance.bind(app)
expect(() => getProviderInstance('missing-token' as unknown as Constructor)).toThrowError(
/Failed to resolve provider missing-token/,
)
await app.close('missing-token-resolution')
})
it('formats anonymous provider names when resolution fails', async () => {
const app = await createApplication(FactoryModule)
const getProviderInstance = (
app as unknown as {
getProviderInstance: (token: Constructor) => unknown
}
).getProviderInstance.bind(app)
const Anonymous = class {
constructor(private readonly dep: unknown) {}
}
injectable()(Anonymous)
Object.defineProperty(Anonymous, 'name', { value: '', configurable: true })
expect(() => getProviderInstance(Anonymous as unknown as Constructor)).toThrowError(
/Failed to resolve provider class/,
)
await app.close('anonymous-resolution')
})
it('formats token names consistently', async () => {
const app = await createApplication(FactoryModule)
const formatTokenName = (
app as unknown as {
formatTokenName: (token: Constructor | string | undefined) => string
}
).formatTokenName.bind(app)
class Named {}
const Anonymous = class {}
Object.defineProperty(Anonymous, 'name', { value: '', configurable: true })
expect(formatTokenName(Named as Constructor)).toBe('Named')
expect(formatTokenName(Anonymous as Constructor)).toContain('class')
expect(formatTokenName('token' as unknown as Constructor)).toBe('token')
expect(formatTokenName(undefined as unknown as Constructor)).toBe('AnonymousProvider')
await app.close('format-token-name')
})
it('skips duplicate provider registrations', async () => {
const app = await createApplication(FactoryModule)
const registerSingleton = (
app as unknown as {
registerSingleton: (token: Constructor) => void
}
).registerSingleton.bind(app)
const Temp = class TempService {}
const Anonymous = class {}
Object.defineProperty(Anonymous, 'name', { value: '', configurable: true })
registerSingleton(Temp as Constructor)
registerSingleton(Temp as Constructor)
registerSingleton(Anonymous as Constructor)
registerSingleton(Anonymous as Constructor)
expect(app.getContainer().isRegistered(Temp as Constructor, true)).toBe(true)
await app.close('duplicate-providers')
})
it('extracts middleware metadata and lifecycle targets across variants', async () => {
const app = await createApplication(FactoryModule)
const internals = app as unknown as {
extractMiddlewareLifecycleTarget: (value?: unknown) => unknown
extractMiddlewareMetadata: (source?: Constructor | Record<string, unknown>) => Record<string, unknown>
resolveMiddlewareDefinition: (value: unknown, source?: Constructor | Record<string, unknown>) => unknown
getEnhancerType: (token: unknown) => unknown
}
@Middleware({ path: '/decorated-inline', priority: 7 })
class InlineMiddleware implements HttpMiddleware {
async use(): Promise<void> {}
}
const instance = new InlineMiddleware()
const definition = { handler: instance, path: '/explicit', priority: 2 }
expect(internals.extractMiddlewareLifecycleTarget()).toBeUndefined()
expect(internals.extractMiddlewareLifecycleTarget(instance)).toBe(instance)
expect(internals.extractMiddlewareLifecycleTarget(definition)).toBe(instance)
expect(internals.extractMiddlewareMetadata()).toEqual({})
const suppliedMetadata = { path: '/supplied', priority: 4 }
expect(internals.extractMiddlewareMetadata(suppliedMetadata)).toBe(suppliedMetadata)
expect(internals.extractMiddlewareMetadata(InlineMiddleware)).toEqual({ path: '/decorated-inline', priority: 7 })
const decoratedDefinition = internals.resolveMiddlewareDefinition(
instance,
InlineMiddleware,
) as MiddlewareDefinition
expect(decoratedDefinition.path).toBe('/decorated-inline')
expect(decoratedDefinition.priority).toBe(7)
const mergedDefinition = internals.resolveMiddlewareDefinition(definition, {
path: '/override',
priority: 1,
}) as MiddlewareDefinition
expect(mergedDefinition.path).toBe('/explicit')
expect(mergedDefinition.priority).toBe(2)
const fallbackMerged = internals.resolveMiddlewareDefinition({ handler: instance } as MiddlewareDefinition, {
path: '/fallback',
priority: 3,
}) as MiddlewareDefinition
expect(fallbackMerged.path).toBe('/fallback')
expect(fallbackMerged.priority).toBe(3)
expect(() => internals.getEnhancerType(Symbol('unknown-token'))).toThrowError(/Unknown enhancer token/)
await app.close('middleware-metadata')
})
it('registers global middlewares across array and string paths', async () => {
const app = await createApplication(FactoryModule)
const internals = app as unknown as {
resolveMiddlewareDefinition: (
value: unknown,
source?: Constructor | Record<string, unknown>,
) => MiddlewareDefinition
useGlobalMiddlewares: (...definition: MiddlewareDefinition[]) => void
globalEnhancers: { middlewares: MiddlewareDefinition[] }
}
class ArrayMiddleware implements HttpMiddleware {
public calls: string[] = []
async use(context: Context, next: Next): Promise<void> {
this.calls.push(context.req.path)
await next()
}
}
@Middleware({ path: '/decorated-global', priority: 4 })
class DecoratedGlobalMiddleware implements HttpMiddleware {
async use(): Promise<void> {}
}
const arrayInstance = new ArrayMiddleware()
const arrayDefinition: MiddlewareDefinition = {
handler: arrayInstance,
path: ['/alpha', /beta/i],
priority: 10,
}
const decoratedDefinition = internals.resolveMiddlewareDefinition(
new DecoratedGlobalMiddleware(),
DecoratedGlobalMiddleware,
)
class RegexMiddleware implements HttpMiddleware {
async use(): Promise<void> {}
}
const regexDefinition: MiddlewareDefinition = {
handler: new RegexMiddleware(),
path: /regex-path/,
}
const anonymousHandler = new (class {
async use(): Promise<void> {}
})()
const fallbackDefinition: MiddlewareDefinition = {
handler: anonymousHandler,
}
const hono = app.getInstance()
const useSpy = vi.spyOn(hono, 'use')
internals.useGlobalMiddlewares(arrayDefinition, decoratedDefinition, regexDefinition, fallbackDefinition)
expect(useSpy).toHaveBeenCalledWith('/alpha', expect.any(Function))
expect(useSpy).toHaveBeenCalledWith(/beta/i, expect.any(Function))
expect(useSpy).toHaveBeenCalledWith('/decorated-global', expect.any(Function))
expect(useSpy).toHaveBeenCalledWith(/regex-path/, expect.any(Function))
expect(useSpy).toHaveBeenCalledWith('/*', expect.any(Function))
expect(internals.globalEnhancers.middlewares).toEqual(
expect.arrayContaining([
expect.objectContaining({ handler: arrayInstance }),
expect.objectContaining({ path: '/decorated-global' }),
expect.objectContaining({ path: '/*', handler: anonymousHandler }),
]),
)
useSpy.mockRestore()
await app.close('global-middlewares')
})
it('describes middleware paths across variants', async () => {
const app = await createApplication(FactoryModule)
const describePath = (
app as unknown as {
describeMiddlewarePath: (path: unknown) => string
}
).describeMiddlewarePath.bind(app)
expect(describePath('/literal')).toBe('/literal')
expect(describePath(/regex-case/)).toBe('/regex-case/')
expect(describePath(['/alpha', /beta/i])).toBe('/alpha, /beta/i')
await app.close('describe-middleware-path')
})
it('falls back to default middleware metadata when definitions omit fields', async () => {
const app = await createApplication(FactoryModule)
const appAny = app as any
const normalizeSpy = vi
.spyOn(appAny, 'normalizeMiddlewareDefinition')
.mockImplementation((definition: any) => definition)
const hono = app.getInstance()
const useSpy = vi.spyOn(hono, 'use')
const verboseSpy = vi.spyOn(appAny.middlewareLogger, 'verbose')
const AnonymousMiddleware = class {
async use(): Promise<void> {}
}
Object.defineProperty(AnonymousMiddleware, 'name', { value: '', configurable: true })
const handlerInstance = new AnonymousMiddleware()
expect(handlerInstance.constructor.name).toBe('')
try {
appAny.useGlobalMiddlewares({ handler: handlerInstance } as any)
expect(useSpy).toHaveBeenCalledWith('/*', expect.any(Function))
const verboseMessage = verboseSpy.mock.calls.find(
(call): call is [string, string] => typeof call[0] === 'string' && call[0].includes('AnonymousMiddleware'),
)
expect(verboseMessage?.[0]).toContain('AnonymousMiddleware')
expect(verboseMessage?.[0]).toContain('/*')
} finally {
normalizeSpy.mockRestore()
useSpy.mockRestore()
verboseSpy.mockRestore()
await app.close('middleware-defaults')
}
})
it('sorts global middlewares using default priority when values are undefined', async () => {
const app = await createApplication(FactoryModule)
const appAny = app as any
const normalizeSpy = vi
.spyOn(appAny, 'normalizeMiddlewareDefinition')
.mockImplementation((definition: any) => definition)
const hono = app.getInstance()
const useSpy = vi.spyOn(hono, 'use')
const lowPriority = {
handler: { async use(): Promise<void> {} },
path: '/low',
priority: undefined,
}
const highPriority = {
handler: { async use(): Promise<void> {} },
path: '/high',
priority: undefined,
}
try {
appAny.useGlobalMiddlewares(lowPriority, highPriority)
expect(appAny.globalEnhancers.middlewares).toEqual(
expect.arrayContaining([expect.objectContaining({ path: '/low' }), expect.objectContaining({ path: '/high' })]),
)
expect(useSpy).toHaveBeenCalledTimes(2)
expect(useSpy).toHaveBeenCalledWith('/low', expect.any(Function))
expect(useSpy).toHaveBeenCalledWith('/high', expect.any(Function))
} finally {
normalizeSpy.mockRestore()
useSpy.mockRestore()
await app.close('middleware-default-priority')
}
})
it('throws descriptive error for invalid provider configuration objects', async () => {
const app = await createApplication(FactoryModule)
const internals = app as unknown as {
registerRegularProvider: (config: any, scoped: Constructor[]) => void
}
expect(() => internals.registerRegularProvider({ provide: Symbol('no-resolver') } as any, [])).toThrowError(
/Invalid provider configuration/,
)
await app.close('invalid-provider-config')
})
it("throws when provider configuration omits the 'provide' token", async () => {
@Module({
providers: [{ useValue: 123 } as any],
})
class MissingProvideModule {}
await expect(createApplication(MissingProvideModule)).rejects.toThrowError(
/Invalid provider configuration: missing 'provide' token/,
)
})
it('normalizes empty paths to root', async () => {
const app = await createApplication(FactoryModule)
const buildPath = (
app as unknown as {
buildPath: (controller: { prefix: string; bypassGlobalPrefix: boolean }, routePath: string) => string
}
).buildPath.bind(app)
expect(buildPath({ prefix: '', bypassGlobalPrefix: false }, '')).toBe('/')
await app.close('normalize-paths')
})
it('serializes undefined payloads to null json bodies', async () => {
const app = await createApplication(FactoryModule)
const json = (
app as unknown as {
json: (ctx: Context, payload: unknown, status: number) => Response
}
).json.bind(app)
const response = json({} as Context, undefined, 200)
expect(response.status).toBe(200)
expect(await response.json()).toBeNull()
await app.close('serialize-null')
})
})
describe('Lifecycle hooks integration', () => {
it('invokes lifecycle hooks in expected order', async () => {
lifecycleEvents.length = 0
const app = await createApplication(LifecycleModule)
expect(lifecycleEvents).toEqual(['module:init', 'app:bootstrap'])
lifecycleEvents.length = 0
await app.close('SIGTERM')
expect(lifecycleEvents).toEqual(['before:SIGTERM', 'module:destroy', 'app:shutdown:SIGTERM'])
})
})
describe('Container resolution policy', () => {
it('throws when resolving unregistered tokens via container', async () => {
@injectable()
class Unregistered {}
const app = await createApplication(FactoryModule)
const container = app.getContainer()
expect(() => container.resolve(Unregistered as unknown as Constructor)).toThrowError(
/Cannot resolve unregistered token/,
)
await app.close('unregistered-token')
})
it('supports provider aliasing via useExisting and useClass', async () => {
const calls: string[] = []
@injectable()
class Impl {
constructor() {
calls.push('impl:new')
}
ping() {
return 'pong'
}
}
class Base {}
@Module({
providers: [{ provide: Base as unknown as Constructor, useExisting: Impl as unknown as Constructor }, Impl],
controllers: [],
})
class AliasModule {}
const app = await createApplication(AliasModule)
const c = app.getContainer()
const a = c.resolve(Impl as unknown as Constructor) as unknown as { ping: () => string }
const b = c.resolve(Base as unknown as Constructor) as unknown as { ping: () => string }
expect(a.ping()).toBe('pong')
expect(b.ping()).toBe('pong')
expect(calls.filter((x) => x === 'impl:new').length).toBe(1)
await app.close('aliasing')
})
it('registers global guards via APP_GUARD provider token', async () => {
callOrder.length = 0
@Module({
controllers: [DemoController],
providers: [
SharedService,
DemoService,
{ provide: APP_GUARD as unknown as Constructor, useExisting: ApiKeyGuard },
ApiKeyGuard,
],
})
class GuardTokenModule {}
const app = await createApplication(GuardTokenModule, { globalPrefix: '/api' })
const fetcher = (request: Request) => Promise.resolve(app.getInstance().fetch(request))
// Without x-api-key, should be forbidden by global guard
const response = await fetcher(new Request('http://localhost/api/demo'))
expect(response.status).toBe(403)
await app.close('guard-token')
})
})
describe('APP_* providers and auto-registration of decorator enhancers', () => {
it('applies APP_PIPE as a global pipe (useValue)', async () => {
@injectable()
class AppPipe implements PipeTransform<unknown> {
transform(value: unknown, metadata: ArgumentMetadata) {
if (metadata.type === 'query' && typeof value === 'string') return `${value}-app`
return value
}
}
@injectable()
@Controller('apptok')
class AppTokController {
@Get('/')
greet(@Query('q') q: string) {
return `Q:${q}`
}
}
@Module({
controllers: [AppTokController],
providers: [{ provide: APP_PIPE as unknown as Constructor, useValue: new AppPipe() }],
})
class AppTokModule {}
const app = await createApplication(AppTokModule)
const fetcher = (req: Request) => Promise.resolve(app.getInstance().fetch(req))
const res = await fetcher(new Request('http://localhost/apptok?q=a'))
expect(await res.text()).toBe('Q:a-app')
await app.close('app-pipe')
})
it('applies APP_INTERCEPTOR as a global interceptor (useClass)', async () => {
@injectable()
class AppInterceptor2 implements Interceptor {
async intercept(_ctx: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
const result = await next.handle()
const text = await result.text()
return new Response(`${text}|gx`, { headers: result.headers, status: result.status }) as FrameworkResponse
}
}
@injectable()
@Controller('iapp')
class IAppController {
@Get('/')
ping() {
return 'pong'
}
}
@Module({
controllers: [IAppController],
providers: [{ provide: APP_INTERCEPTOR as unknown as Constructor, useClass: AppInterceptor2 }, AppInterceptor2],
})
class IAppModule {}
const app = await createApplication(IAppModule)
const fetcher = (req: Request) => Promise.resolve(app.getInstance().fetch(req))
const res = await fetcher(new Request('http://localhost/iapp'))
expect(await res.text()).toBe('pong|gx')
await app.close('app-interceptor')
})
it('applies APP_FILTER as a global filter (useFactory)', async () => {
@injectable()
class AppFilter2 implements ExceptionFilter {
async catch(_e: unknown, _host: ArgumentsHost) {
return Response.json({ handled: 'app' }, {
status: 499,
headers: {
'content-type': 'application/json',
},
})
}
}
@injectable()
@Controller('fapp')
class FAppController {
@Get('/')
boom() {
throw new Error('oops')
}
}
@Module({
controllers: [FAppController],
providers: [
{
provide: APP_FILTER as unknown as Constructor,
useFactory: () => new AppFilter2(),
},
],
})
class FAppModule {}
const app = await createApplication(FAppModule)
const fetcher = (req: Request) => Promise.resolve(app.getInstance().fetch(req))
const res = await fetcher(new Request('http://localhost/fapp'))
expect(res.status).toBe(499)
expect(await res.json()).toEqual({ handled: 'app' })
await app.close('app-filter')
})
it('auto-registers class decorators for interceptors/filters/pipes without providers', async () => {
@injectable()
class InlineInterceptor2 implements Interceptor {
async intercept(_ctx: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
const result = await next.handle()
const text = await result.text()
return new Response(`${text}|inlined`, { headers: result.headers, status: result.status }) as FrameworkResponse
}
}
@injectable()
class InlineFilter2 implements ExceptionFilter {
async catch(_e: unknown, host: ArgumentsHost) {
const ctx = host.getContext().hono
return ctx.json({ ok: true }, 208)
}
}
@injectable()
class InlinePipe2 implements PipeTransform<unknown, string> {
transform(value: unknown) {
return `${String(value)}-piped`
}
}
@injectable()
@Controller('inline2')
class Inline2Controller {
@Get('/i')
@UseInterceptors(InlineInterceptor2)
i() {
return 'OK'
}
@Get('/e')
@UseFilters(InlineFilter2)
e() {
throw new Error('x')
}
@Get('/p/:id')
p(@Param('id', InlinePipe2) id: string) {
return id
}
}
@Module({ controllers: [Inline2Controller] })
class Inline2Module {}
const app = await createApplication(Inline2Module)
const fetcher = (req: Request) => Promise.resolve(app.getInstance().fetch(req))
const r1 = await fetcher(new Request('http://localhost/inline2/i'))
expect(await r1.text()).toBe('OK|inlined')
const r2 = await fetcher(new Request('http://localhost/inline2/e'))
expect(r2.status).toBe(208)
expect(await r2.json()).toEqual({ ok: true })
const r3 = await fetcher(new Request('http://localhost/inline2/p/abc'))
expect(await r3.text()).toBe('abc-piped')
await app.close('inline2')
})
it('applies APP_MIDDLEWARE providers with DI support and priority ordering', async () => {
const app = await createApplication(MiddlewareTestModule)
const hono = app.getInstance()
const fetcher = (req: Request) => Promise.resolve(hono.fetch(req))
const response = await fetcher(new Request('http://localhost/middleware'))
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
const tracker = app.getContainer().resolve(MiddlewareTracker)
expect(tracker.events).toEqual([
'first-before:/middleware',
'second-before:/middleware',
'handler',
'second-after:/middleware',
'first-after:/middleware',
])
await app.close('middleware')
})
})
describe('Advanced provider registrations and middleware metadata', () => {
it('covers provider configuration permutations and middleware metadata merging', async () => {
const TRANSIENT_TOKEN = Symbol('TRANSIENT_TOKEN')
const ALIAS_TOKEN = Symbol('ALIAS_TOKEN')
const VALUE_TOKEN = Symbol('VALUE_TOKEN')
const SINGLETON_FACTORY_TOKEN = Symbol('SINGLETON_FACTORY_TOKEN')
const TRANSIENT_FACTORY_TOKEN = Symbol('TRANSIENT_FACTORY_TOKEN')
const SINGLETON_CLASS_TOKEN = Symbol('SINGLETON_CLASS_TOKEN')
const middlewareEvents: string[] = []
const lifecycleEvents: string[] = []
let transientConstructs = 0
let singletonFactoryCreates = 0
let transientFactoryCreates = 0
class ArrayPathMiddleware implements HttpMiddleware, OnModuleDestroy {
async use(context: Context, next: Next): Promise<void> {
middlewareEvents.push(`array-before:${context.req.path}`)
await next()
middlewareEvents.push(`array-after:${context.req.path}`)
}
onModuleDestroy(): void {
lifecycleEvents.push('array:destroy')
}
}
@Middleware({ path: '/api/coverage/decorated', priority: 2 })
@injectable()
class DecoratedCoverageMiddleware implements HttpMiddleware, OnModuleDestroy {
async use(context: Context, next: Next): Promise<void> {
middlewareEvents.push(`decorated-before:${context.req.path}`)
await next()
middlewareEvents.push(`decorated-after:${context.req.path}`)
}
onModuleDestroy(): void {
lifecycleEvents.push('decorated:destroy')
}
}
class PlainGlobalMiddleware implements HttpMiddleware, OnModuleDestroy {
async use(context: Context, next: Next): Promise<void> {
middlewareEvents.push(`plain-before:${context.req.path}`)
await next()
middlewareEvents.push(`plain-after:${context.req.path}`)
}
onModuleDestroy(): void {
lifecycleEvents.push('plain:destroy')
}
}
const lifecycleValue = {
destroyed: false,
onApplicationShutdown(signal?: string) {
lifecycleEvents.push(`value:shutdown:${signal ?? 'none'}`)
this.destroyed = true
},
}
@injectable()
class TransientClass {
constructor() {
transientConstructs += 1
}
ping() {
return 'transient'
}
}
@injectable()
class SingletonService {
public readonly createdAt = Date.now()
}
@injectable()
@Controller('coverage')
class CoverageController {
@Get('/ping')
ping() {
return 'pong'
}
@Get('/decorated')
decorated() {
return 'decorated'
}
}
const arrayMiddlewareInstance = new ArrayPathMiddleware()
@Module({
controllers: [CoverageController],
providers: [
DecoratedCoverageMiddleware,
TransientClass,
{
provide: TRANSIENT_TOKEN,
useClass: TransientClass,
singleton: false,
},
{
provide: SINGLETON_CLASS_TOKEN,
useClass: SingletonService,
},
{
provide: ALIAS_TOKEN,
useExisting: TransientClass,
},
{
provide: VALUE_TOKEN,
useValue: lifecycleValue,
},
{
provide: SINGLETON_FACTORY_TOKEN,
useFactory: (value: typeof lifecycleValue) => {
singletonFactoryCreates += 1
const product = {
destroyed: false,
source: value,
onModuleDestroy() {
this.destroyed = true
lifecycleEvents.push('factory:singleton:destroy')
},
}
lifecycleEvents.push('factory:singleton:create')
return product
},
inject: [VALUE_TOKEN],
},
{
provide: TRANSIENT_FACTORY_TOKEN,
singleton: false,
useFactory: () => {
transientFactoryCreates += 1
return { stamp: Symbol('transient') }
},
},
{
provide: APP_MIDDLEWARE as unknown as Constructor,
useValue: {
handler: arrayMiddlewareInstance,
path: ['/api/coverage/ping', '/api/coverage/extra'],
priority: -5,
},
},
{
provide: APP_MIDDLEWARE as unknown as Constructor,
useExisting: DecoratedCoverageMiddleware,
},
{
provide: APP_MIDDLEWARE as unknown as Constructor,
useFactory: () => new PlainGlobalMiddleware(),
},
],
})
class CoverageModule {}
const app = await createApplication(CoverageModule, { globalPrefix: '/api' })
const fetcher = (req: Request) => Promise.resolve(app.getInstance().fetch(req))
const firstResponse = await fetcher(new Request('http://localhost/api/coverage/ping'))
expect(await firstResponse.text()).toBe('pong')
const decoratedResponse = await fetcher(new Request('http://localhost/api/coverage/decorated'))
expect(await decoratedResponse.text()).toBe('decorated')
expect(middlewareEvents).toEqual([
'array-before:/api/coverage/ping',
'plain-before:/api/coverage/ping',
'plain-after:/api/coverage/ping',
'array-after:/api/coverage/ping',
'plain-before:/api/coverage/decorated',
'decorated-before:/api/coverage/decorated',
'decorated-after:/api/coverage/decorated',
'plain-after:/api/coverage/decorated',
])
const container = app.getContainer()
const transientA = container.resolve(TRANSIENT_TOKEN as any)
const transientB = container.resolve(TRANSIENT_TOKEN as any)
expect(transientA).not.toBe(transientB)
expect(transientConstructs).toBeGreaterThanOrEqual(2)
const singletonClassA = container.resolve(SINGLETON_CLASS_TOKEN as any)
const singletonClassB = container.resolve(SINGLETON_CLASS_TOKEN as any)
expect(singletonClassA).toBe(singletonClassB)
const aliasResolved = container.resolve(ALIAS_TOKEN as any)
const original = container.resolve(TransientClass)
expect(aliasResolved).toBe(original)
const valueResolved = container.resolve(VALUE_TOKEN as any)
expect(valueResolved).toBe(lifecycleValue)
const singletonA = container.resolve(SINGLETON_FACTORY_TOKEN as any)
const singletonB = container.resolve(SINGLETON_FACTORY_TOKEN as any)
expect(singletonA).toBe(singletonB)
expect(singletonFactoryCreates).toBe(1)
const transientFactoryA = container.resolve(TRANSIENT_FACTORY_TOKEN as any)
const transientFactoryB = container.resolve(TRANSIENT_FACTORY_TOKEN as any)
expect(transientFactoryA).not.toBe(transientFactoryB)
expect(transientFactoryCreates).toBe(2)
await app.close('advanced-providers')
expect(lifecycleValue.destroyed).toBe(true)
expect(lifecycleEvents).toContain('value:shutdown:advanced-providers')
expect(lifecycleEvents).toContain('factory:singleton:destroy')
expect(lifecycleEvents).toContain('plain:destroy')
expect(lifecycleEvents).toContain('decorated:destroy')
expect(lifecycleEvents).toContain('array:destroy')
})
it('throws descriptive error for invalid middleware values', async () => {
@Module({
providers: [
{
provide: APP_MIDDLEWARE as unknown as Constructor,
useValue: { handler: {} },
},
],
})
class BrokenMiddlewareModule {}
await expect(createApplication(BrokenMiddlewareModule)).rejects.toThrowError(
/Invalid middleware configuration: expected Middleware or MiddlewareDefinition instance/,
)
})
it('throws descriptive error for malformed global enhancer definitions', async () => {
@Module({
providers: [
{
provide: APP_GUARD as unknown as Constructor,
} as any,
],
})
class BrokenGlobalEnhancerModule {}
await expect(createApplication(BrokenGlobalEnhancerModule)).rejects.toThrowError(
/Invalid global enhancer configuration/,
)
})
})