diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md index 6c3f45c..3cfb1e8 100644 --- a/UPGRADE-3.0.md +++ b/UPGRADE-3.0.md @@ -2,10 +2,4 @@ # Event -- Remove `eventBusMiddleware` and use `httpKernel` instead. -- Replace all the types from `@koala-ts/framework/Event` with types from `@koala-ts/framework/Kernel`. - -```diff --- import { eventBusMiddleware } from '@koala-ts/framework/Event'; -++ import { httpKernel } from '@koala-ts/framework/Kernel'; -``` +- Remove all usage of the `Event` module. diff --git a/src/Application/ApplicationFactory.test.ts b/src/Application/ApplicationFactory.test.ts index 1496996..14e8dda 100644 --- a/src/Application/ApplicationFactory.test.ts +++ b/src/Application/ApplicationFactory.test.ts @@ -1,175 +1,9 @@ -import Koa from 'koa'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { expect, test } from 'vitest'; import { create } from '@/Application/ApplicationFactory'; -import { KoalaConfig, koalaDefaultConfig } from '@/Config'; -import type { HttpRequest, HttpScope, NextMiddleware, UploadedFile } from '@/Http'; -import { Route } from '@/Routing'; -import { createTestAgent, TestAgent } from '@/Testing'; +import { koalaDefaultConfig } from '@/Config'; -describe('Application', () => { - let agent: TestAgent; - beforeEach(() => { - agent = createTestAgent(koalaDefaultConfig); - }); +test('create app with default config', () => { + const app = create(koalaDefaultConfig); - const middleware1 = function (_: HttpScope, next: NextMiddleware): Promise { - return next(); - }; - const service = vi.fn(); - - const middleware2 = function (_: HttpScope, next: NextMiddleware): Promise { - service(); - return next(); - }; - - interface BarRequest extends HttpRequest { - body?: { - name: string; - }; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - class FooController { - @Route({ method: 'any', path: '/bar', options: { parseBody: false }, middleware: [middleware1, middleware2] }) - bar(scope: HttpScope): void { - scope.response.body = { - name: scope.request.body?.name ?? 'Koala', - }; - } - - @Route({ method: 'post', path: '/qux' }) - qux(scope: HttpScope): void { - scope.response.body = scope.request.body; - } - - @Route({ method: ['get', 'post'], path: '/handle-multiple-methods' }) - handleMultipleMethods(scope: HttpScope): void { - scope.response.body = { method: scope.request.method }; - } - - @Route({ method: 'get', path: '/access-route-params/:id' }) - accessRouteParams(scope: HttpScope): void { - scope.response.body = { id: scope.request.params.id }; - } - - @Route({ method: 'post', path: '/upload', options: { multipart: true } }) - upload(scope: HttpScope): void { - const avatar = scope.request.files?.avatar as unknown as UploadedFile; - scope.response.body = avatar.originalFilename; - } - - @Route({ method: 'get', path: '/set-header' }) - setHeader(scope: HttpScope): void { - scope.response.setHeader('X-Foo', 'Bar').setHeader('X-Bar', 'Foo').body = 'Header set'; - } - - @Route({ method: 'get', path: '/with-headers' }) - withHeaders(scope: HttpScope): void { - scope.response.withHeaders({ - 'X-Foo': 'Bar', - 'X-Bar': 'Foo', - }).body = 'Headers set'; - } - } - - test('it creates application instance', () => { - const app = create(koalaDefaultConfig); - - expect(app).toBeInstanceOf(Koa); - }); - - test('app provides access to scope', () => { - const app = create(koalaDefaultConfig); - - expect(app.scope).toBe(app.context); - }); - - test('e2e set header', async () => { - const response = await agent.get('/set-header'); - - expect(response.header['x-foo']).toBe('Bar'); - expect(response.header['x-bar']).toBe('Foo'); - }); - - test('e2e with headers', async () => { - const response = await agent.get('/with-headers'); - - expect(response.header['x-foo']).toBe('Bar'); - expect(response.header['x-bar']).toBe('Foo'); - }); - - test('e2e with body', async () => { - const response = await agent.post('/qux').send({ name: 'Koala' }); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ name: 'Koala' }); - }); - - test('e2e without body', async () => { - const response = await agent.get('/bar').send({ name: 'Not Koala' }); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ name: 'Koala' }); - }); - - test('it should call middleware', async () => { - await agent.get('/bar'); - - expect(service).toHaveBeenCalled(); - }); - - test('it should handle multiple methods', async () => { - const response1 = await agent.get('/handle-multiple-methods'); - const response2 = await agent.post('/handle-multiple-methods'); - - expect(response1.status).toBe(200); - expect(response2.status).toBe(200); - expect(response1.body).toEqual({ method: 'GET' }); - expect(response2.body).toEqual({ method: 'POST' }); - }); - - test('it should access route params', async () => { - const id = '123'; - const response = await agent.get(`/access-route-params/${id}`); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ id }); - }); - - test('upload file', async () => { - const response = await agent.post('/upload').attach('avatar', 'tests/fixtures/avatar.png'); - - expect(response.text).toBe('avatar.png'); - }); -}); - -describe('Global Middleware', () => { - test('it should register configured global middleware', async () => { - const middlewareFn = vi.fn(); - const config = { - controllers: [], - globalMiddleware: [middlewareFn], - }; - const testAgent = createTestAgent(config as KoalaConfig); - - await testAgent.get('/'); - - expect(middlewareFn).toHaveBeenCalled(); - }); -}); - -describe('Serving Static Files', () => { - test('it should serve public static files', async () => { - const config = { - staticFiles: { - root: 'tests/fixtures', - }, - }; - const testAgent = createTestAgent(config as KoalaConfig); - - const response = await testAgent.get('/sample.txt'); - - expect(response.status).toBe(200); - expect(response.text).toBe('Howdy!\n'); - }); + expect(app).toBeDefined(); }); diff --git a/src/Application/ApplicationFactory.ts b/src/Application/ApplicationFactory.ts index 9eeb3b4..e69c7b2 100644 --- a/src/Application/ApplicationFactory.ts +++ b/src/Application/ApplicationFactory.ts @@ -6,6 +6,7 @@ import { extendResponse } from '@/Application/Response'; import { type KoalaConfig } from '@/Config'; import { type HttpMiddleware, type HttpScope } from '@/Http'; import { serveStaticFiles } from '@/Http/Files'; +import { type EventSubscriber, httpKernel } from '@/Kernel'; import { getRoutes } from '@/Routing'; export function create(config: KoalaConfig): Application { @@ -13,7 +14,11 @@ export function create(config: KoalaConfig): Application { app.scope = app.context; app.use(extendResponse); - if (undefined !== config.globalMiddleware) registerGlobalMiddleware(app, config.globalMiddleware); + app.use(httpKernel); + + if (undefined !== config.globalMiddleware) { + registerGlobalMiddleware(app, config.globalMiddleware); + } app.use(serveStaticFiles(config.staticFiles)); @@ -22,7 +27,9 @@ export function create(config: KoalaConfig): Application { app.use(router.routes()); app.use(router.allowedMethods()); - registerEventSubscribers(app, config.eventSubscribers); + if (undefined !== config.eventSubscribers) { + registerEventSubscribers(app, config.eventSubscribers); + } return app; } @@ -48,9 +55,7 @@ function registerGlobalMiddleware(app: Application, middleware: HttpMiddleware[] } } -function registerEventSubscribers(app: Application, map: KoalaConfig['eventSubscribers']): void { - if (undefined === map) return; - +function registerEventSubscribers(app: Application, map: Record): void { for (const [event, subscribers] of Object.entries(map)) { if (Array.isArray(subscribers)) { for (const subscriber of subscribers) app.on(event, subscriber as unknown as (...args: unknown[]) => void); diff --git a/src/Config/ConfigLoader.test.ts b/src/Config/ConfigLoader.test.ts index 394639e..6113aad 100644 --- a/src/Config/ConfigLoader.test.ts +++ b/src/Config/ConfigLoader.test.ts @@ -10,14 +10,18 @@ describe('Load env config', () => { test('it should load .env file', () => { loadEnvConfig('development'); - expect(configSpy).toHaveBeenCalledWith({ path: expect.stringContaining('.env'), override: true }); + expect(configSpy).toHaveBeenCalledWith({ path: expect.stringContaining('.env'), override: true, quiet: true }); expect(expandSpy).toHaveBeenCalled(); }); test('it should load .env.local file', () => { loadEnvConfig('development'); - expect(configSpy).toHaveBeenCalledWith({ path: expect.stringContaining('.env.local'), override: true }); + expect(configSpy).toHaveBeenCalledWith({ + path: expect.stringContaining('.env.local'), + override: true, + quiet: true, + }); expect(expandSpy).toHaveBeenCalled(); }); @@ -30,14 +34,22 @@ describe('Load env config', () => { test('it should load .env. file', () => { loadEnvConfig('development'); - expect(configSpy).toHaveBeenCalledWith({ path: expect.stringContaining('.env.development'), override: true }); + expect(configSpy).toHaveBeenCalledWith({ + path: expect.stringContaining('.env.development'), + override: true, + quiet: true, + }); expect(expandSpy).toHaveBeenCalled(); }); test('it should load .env..local file', () => { loadEnvConfig('test'); - expect(configSpy).toHaveBeenCalledWith({ path: expect.stringContaining('.env.test.local'), override: true }); + expect(configSpy).toHaveBeenCalledWith({ + path: expect.stringContaining('.env.test.local'), + override: true, + quiet: true, + }); expect(expandSpy).toHaveBeenCalled(); }); }); diff --git a/src/Config/ConfigLoader.ts b/src/Config/ConfigLoader.ts index 921a8f0..9c928d4 100644 --- a/src/Config/ConfigLoader.ts +++ b/src/Config/ConfigLoader.ts @@ -17,6 +17,7 @@ function loadEnvFile(fileName: string): void { const expandedOptions = dotenv.config({ path: path.resolve(rootDir, fileName), override: true, + quiet: true, }); dotenvExpand.expand(expandedOptions); diff --git a/tests/.gitignore b/tests/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/tests/RegisterEventSubscribers.test.ts b/tests/RegisterEventSubscribers.test.ts deleted file mode 100644 index ee69af9..0000000 --- a/tests/RegisterEventSubscribers.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { create, type KoalaConfig, koalaDefaultConfig } from '../src'; - -describe('Register Event Subscribers', () => { - test('Register single subscriber for an event', () => { - const config = { - ...koalaDefaultConfig, - eventSubscribers: { - firstEvent: vi.fn(), - }, - }; - const app = create(config as unknown as KoalaConfig); - - app.emit('firstEvent', 'data1'); - - expect(config.eventSubscribers.firstEvent).toHaveBeenCalledWith('data1'); - }); - - test('Register multiple subscribers for an event', () => { - const firstSubscriber = vi.fn(); - const secondSubscriber = vi.fn(); - const config = { - ...koalaDefaultConfig, - eventSubscribers: { - multiEvent: [firstSubscriber, secondSubscriber], - }, - }; - const app = create(config as unknown as KoalaConfig); - - app.emit('multiEvent', 'data2'); - - expect(firstSubscriber).toHaveBeenCalledWith('data2'); - expect(secondSubscriber).toHaveBeenCalledWith('data2'); - }); - - test('Register async subscriber for an event', () => { - const internalFn = vi.fn(); - const asyncSubscriber = async (data: string): Promise => { - await internalFn(data); - }; - const config = { - ...koalaDefaultConfig, - eventSubscribers: { - asyncEvent: asyncSubscriber, - }, - }; - const app = create(config as unknown as KoalaConfig); - - app.emit('asyncEvent', 'data3'); - - expect(internalFn).toHaveBeenCalledWith('data3'); - }); -}); diff --git a/tests/event-subscribers.e2e.test.ts b/tests/event-subscribers.e2e.test.ts new file mode 100644 index 0000000..ff21dfa --- /dev/null +++ b/tests/event-subscribers.e2e.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createTestAgent, KoalaConfig, Route } from '../src'; +import { useEmit } from '../src/Kernel'; + +class MyController { + @Route({ method: 'ANY', path: '/publish-event' }) + publishEvent(): void { + const emitter = useEmit(); + emitter.emit('myEvent', { data: 'event-data' }); + } +} + +describe('Event subscribers E2E Test', () => { + test('subscribe to an event', async () => { + const handler = vi.fn(); + const agent = createTestAgent({ + controllers: [MyController], + eventSubscribers: { myEvent: handler }, + } as unknown as KoalaConfig); + + await agent.get('/publish-event'); + + expect(handler).toHaveBeenCalledWith({ data: 'event-data' }); + }); + + test('multiple subscribers to an event', async () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const agent = createTestAgent({ + controllers: [MyController], + eventSubscribers: { myEvent: [handler1, handler2] }, + } as unknown as KoalaConfig); + + await agent.get('/publish-event'); + + expect(handler1).toHaveBeenCalledWith({ data: 'event-data' }); + expect(handler2).toHaveBeenCalledWith({ data: 'event-data' }); + }); + + test('register async event subscriber', async () => { + const internalFn = vi.fn(); + const asyncSubscriber = async (data: string): Promise => { + await internalFn(data); + }; + const agent = createTestAgent({ + controllers: [MyController], + eventSubscribers: { myEvent: asyncSubscriber }, + } as unknown as KoalaConfig); + + await agent.get('/publish-event'); + + expect(internalFn).toHaveBeenCalledWith({ data: 'event-data' }); + }); +}); diff --git a/tests/generate-response.e2e.test.ts b/tests/generate-response.e2e.test.ts new file mode 100644 index 0000000..b370445 --- /dev/null +++ b/tests/generate-response.e2e.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; +import { createTestAgent, type HttpScope, Route } from '../src'; + +class MyController { + @Route({ method: 'GET', path: '/response-basics' }) + responseBasics(scope: HttpScope): void { + scope.response.set('Custom-Header', 'HeaderValue'); + scope.response.body = { message: 'Response Basics' }; + scope.response.status = 200; + } +} + +describe('Generate Response E2E Test', () => { + test('response basics', async () => { + const agent = createTestAgent({ controllers: [MyController] }); + + const response = await agent.get('/response-basics'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ message: 'Response Basics' }); + expect(response.headers['custom-header']).toBe('HeaderValue'); + }); +}); diff --git a/tests/request-props.e2e.test.ts b/tests/request-props.e2e.test.ts new file mode 100644 index 0000000..bc74de4 --- /dev/null +++ b/tests/request-props.e2e.test.ts @@ -0,0 +1,71 @@ +import getRawBody from 'raw-body'; +import { describe, expect, test } from 'vitest'; +import { createTestAgent, type HttpRequest, type HttpScope, Route, UploadedFile } from '../src'; + +interface MyRequest extends HttpRequest { + body: { name: string }; + params: { id: string }; + headers: { foo: string }; + files: { + avatar: UploadedFile; + }; +} + +class MyController { + @Route({ method: 'POST', path: '/my-action/:id' }) + myAction(scope: HttpScope): void { + scope.response.body = { + greeting: `Hello, ${scope.request.body.name}!`, + sentId: scope.request.params.id, + barHeader: scope.request.headers.foo, + }; + } + + @Route({ method: 'POST', path: '/upload-avatar', options: { multipart: true } }) + multipartUpload(scope: HttpScope): void { + scope.response.body = { + uploadedFileName: scope.request.files.avatar.originalFilename, + }; + } + + @Route({ method: 'POST', path: '/non-parsed-body', options: { parseBody: false } }) + async nonParsedBody(scope: HttpScope): Promise { + const rawBody = await getRawBody(scope.request.req, { encoding: 'utf8' }); + scope.response.body = { rawBody }; + } +} + +describe('Request Properties E2E Test', () => { + test('access request props', async () => { + const agent = createTestAgent({ controllers: [MyController] }); + + const response = await agent.post('/my-action/13').send({ name: 'Koala' }).set('foo', 'bar'); + + expect(response.body).toEqual({ + greeting: 'Hello, Koala!', + sentId: '13', + barHeader: 'bar', + }); + }); + + test('access uploaded files', async () => { + const agent = createTestAgent({ controllers: [MyController] }); + + const response = await agent.post('/upload-avatar').attach('avatar', 'tests/fixtures/avatar.png'); + + expect(response.body).toEqual({ + uploadedFileName: 'avatar.png', + }); + }); + + test('non-parsed body', async () => { + const agent = createTestAgent({ controllers: [MyController] }); + const rawBody = 'raw body content'; + + const response = await agent.post('/non-parsed-body').set('Content-Type', 'text/plain').send(rawBody); + + expect(response.body).toEqual({ + rawBody, + }); + }); +}); diff --git a/tests/routing.e2e.test.ts b/tests/routing.e2e.test.ts new file mode 100644 index 0000000..2f3b15a --- /dev/null +++ b/tests/routing.e2e.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createTestAgent, type KoalaConfig } from '../src'; + +describe('Routing E2E Test', () => { + test('register global middleware', async () => { + const middleware = vi.fn(); + const config = { + globalMiddleware: [middleware], + }; + const agent = createTestAgent(config as unknown as KoalaConfig); + + await agent.get('/some-route'); + + expect(middleware).toHaveBeenCalled(); + }); +}); diff --git a/tests/serve-static-files.e2e.test.ts b/tests/serve-static-files.e2e.test.ts new file mode 100644 index 0000000..9eec67e --- /dev/null +++ b/tests/serve-static-files.e2e.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest'; +import { createTestAgent, type KoalaConfig } from '../src'; + +describe('Serve static files E2E Test', () => { + test('serve text file', async () => { + const agent = createTestAgent({ + staticFiles: { root: 'tests/fixtures' }, + } as unknown as KoalaConfig); + + const response = await agent.get('/sample.txt'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('text/plain; charset=utf-8'); + expect(response.text).toBe('Howdy!\n'); + }); + + test('serve image file', async () => { + const agent = createTestAgent({ + staticFiles: { root: 'tests/fixtures' }, + } as unknown as KoalaConfig); + + const response = await agent.get('/avatar.png'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('image/png'); + expect(response.body).toBeInstanceOf(Buffer); + }); +});