From 7ffc60c49060a2896b54836755f22472e702f872 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Feb 2026 22:59:28 -0800 Subject: [PATCH] feat(testing): create @bytelyst/testing shared package with 10 tests Exports: - createCosmosMocks(): full Cosmos DB mock factory for vitest - TEST_USERS, TEST_JWT_SECRET, createTestTokenPayload(): auth fixtures - injectGet/Post/Patch/Delete(): Fastify inject wrappers - expectHealthOk(): health endpoint assertion helper --- packages/testing/package.json | 35 ++++++ .../testing/src/__tests__/testing.test.ts | 100 ++++++++++++++++++ packages/testing/src/auth-fixtures.ts | 55 ++++++++++ packages/testing/src/cosmos-mocks.ts | 87 +++++++++++++++ packages/testing/src/fastify-helpers.ts | 80 ++++++++++++++ packages/testing/src/index.ts | 15 +++ packages/testing/tsconfig.json | 9 ++ pnpm-lock.yaml | 19 ++++ 8 files changed, 400 insertions(+) create mode 100644 packages/testing/package.json create mode 100644 packages/testing/src/__tests__/testing.test.ts create mode 100644 packages/testing/src/auth-fixtures.ts create mode 100644 packages/testing/src/cosmos-mocks.ts create mode 100644 packages/testing/src/fastify-helpers.ts create mode 100644 packages/testing/src/index.ts create mode 100644 packages/testing/tsconfig.json diff --git a/packages/testing/package.json b/packages/testing/package.json new file mode 100644 index 00000000..5ab1620e --- /dev/null +++ b/packages/testing/package.json @@ -0,0 +1,35 @@ +{ + "name": "@bytelyst/testing", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "devDependencies": { + "@bytelyst/fastify-core": "workspace:*", + "@bytelyst/errors": "workspace:*", + "@fastify/cors": "^10.0.2", + "fastify": "^5.2.1" + }, + "peerDependencies": { + "vitest": ">=3.0.0", + "fastify": ">=5.0.0" + }, + "peerDependenciesMeta": { + "fastify": { + "optional": true + } + } +} diff --git a/packages/testing/src/__tests__/testing.test.ts b/packages/testing/src/__tests__/testing.test.ts new file mode 100644 index 00000000..4112315d --- /dev/null +++ b/packages/testing/src/__tests__/testing.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + createCosmosMocks, + TEST_JWT_SECRET, + TEST_USERS, + createTestTokenPayload, + injectGet, + expectHealthOk, +} from '../index.js'; +import { createServiceApp } from '@bytelyst/fastify-core'; + +describe('createCosmosMocks', () => { + it('returns all mock objects', () => { + const mocks = createCosmosMocks(); + expect(mocks.mockContainer).toBeDefined(); + expect(mocks.mockContainer.id).toBe('test-container'); + expect(mocks.mockDatabase).toBeDefined(); + expect(mocks.mockDatabases).toBeDefined(); + expect(mocks.MockCosmosClient).toBeDefined(); + expect(typeof mocks.resetMocks).toBe('function'); + }); + + it('MockCosmosClient returns database', () => { + const { MockCosmosClient, mockDatabase } = createCosmosMocks(); + const client = new MockCosmosClient('endpoint', 'key'); + expect(client.database('test-db')).toBe(mockDatabase); + }); + + it('mockContainer.items has CRUD methods', () => { + const { mockContainer } = createCosmosMocks(); + expect(typeof mockContainer.items.create).toBe('function'); + expect(typeof mockContainer.items.query).toBe('function'); + expect(typeof mockContainer.items.upsert).toBe('function'); + expect(typeof mockContainer.item).toBe('function'); + }); + + it('resetMocks clears all mock state', () => { + const { mockContainer, resetMocks } = createCosmosMocks(); + mockContainer.items.create({ id: '1' }); + expect(mockContainer.items.create).toHaveBeenCalledOnce(); + resetMocks(); + expect(mockContainer.items.create).not.toHaveBeenCalled(); + }); +}); + +describe('auth fixtures', () => { + it('TEST_JWT_SECRET is a non-empty string', () => { + expect(TEST_JWT_SECRET).toBeTruthy(); + expect(TEST_JWT_SECRET.length).toBeGreaterThanOrEqual(16); + }); + + it('TEST_USERS has admin, viewer, and user', () => { + expect(TEST_USERS.admin.email).toBe('admin@test.com'); + expect(TEST_USERS.viewer.role).toBe('viewer'); + expect(TEST_USERS.user.productId).toBe('lysnrai'); + }); + + it('createTestTokenPayload returns valid shape', () => { + const payload = createTestTokenPayload('admin'); + expect(payload.sub).toBe('user-admin-001'); + expect(payload.email).toBe('admin@test.com'); + expect(payload.role).toBe('super_admin'); + expect(payload.iss).toBe('test'); + expect(payload.exp).toBeGreaterThan(payload.iat); + }); + + it('createTestTokenPayload defaults to admin', () => { + const payload = createTestTokenPayload(); + expect(payload.sub).toBe('user-admin-001'); + }); +}); + +describe('fastify helpers', () => { + it('injectGet + expectHealthOk work with a real fastify-core app', async () => { + const app = await createServiceApp({ + name: 'test-svc', + version: '1.0.0', + logger: false, + }); + + const res = await injectGet(app, '/health'); + expect(res.statusCode).toBe(200); + expectHealthOk(res, 'test-svc'); + + await app.close(); + }); + + it('expectHealthOk throws on wrong service name', async () => { + const app = await createServiceApp({ + name: 'real-svc', + version: '1.0.0', + logger: false, + }); + + const res = await injectGet(app, '/health'); + expect(() => expectHealthOk(res, 'wrong-name')).toThrow('Expected service "wrong-name"'); + + await app.close(); + }); +}); diff --git a/packages/testing/src/auth-fixtures.ts b/packages/testing/src/auth-fixtures.ts new file mode 100644 index 00000000..aaa94ad2 --- /dev/null +++ b/packages/testing/src/auth-fixtures.ts @@ -0,0 +1,55 @@ +/** + * Auth test fixtures — JWT tokens, user payloads, and password helpers. + * + * Usage: + * ```ts + * import { TEST_USERS, TEST_JWT_SECRET, createTestToken } from '@bytelyst/testing'; + * ``` + */ + +/** Test JWT secret — NEVER use in production */ +export const TEST_JWT_SECRET = 'test-jwt-secret-32-chars-long!!'; + +/** Pre-built test user payloads */ +export const TEST_USERS = { + admin: { + id: 'user-admin-001', + email: 'admin@test.com', + name: 'Test Admin', + role: 'super_admin', + productId: 'lysnrai', + }, + viewer: { + id: 'user-viewer-001', + email: 'viewer@test.com', + name: 'Test Viewer', + role: 'viewer', + productId: 'lysnrai', + }, + user: { + id: 'user-basic-001', + email: 'user@test.com', + name: 'Test User', + role: 'user', + productId: 'lysnrai', + }, +} as const; + +export type TestUserKey = keyof typeof TEST_USERS; + +/** + * Create a minimal JWT-like token payload for testing (not cryptographically signed). + * For actual JWT creation, use `@bytelyst/auth` createJwtUtils(). + */ +export function createTestTokenPayload(userKey: TestUserKey = 'admin') { + const user = TEST_USERS[userKey]; + return { + sub: user.id, + email: user.email, + role: user.role, + productId: user.productId, + iss: 'test', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; +} diff --git a/packages/testing/src/cosmos-mocks.ts b/packages/testing/src/cosmos-mocks.ts new file mode 100644 index 00000000..86dd3f3e --- /dev/null +++ b/packages/testing/src/cosmos-mocks.ts @@ -0,0 +1,87 @@ +/** + * Shared Cosmos DB mock factories for Vitest. + * + * Usage: + * ```ts + * import { createCosmosMocks } from '@bytelyst/testing'; + * const { mockContainer, mockDatabase, MockCosmosClient, resetMocks } = createCosmosMocks(); + * vi.mock('@azure/cosmos', () => ({ CosmosClient: MockCosmosClient })); + * ``` + */ + +import { vi } from 'vitest'; + +export interface MockItem { + id: string; + [key: string]: unknown; +} + +export interface CosmosMocks { + mockContainer: { + id: string; + items: { + create: ReturnType; + query: ReturnType; + upsert: ReturnType; + }; + item: ReturnType; + }; + mockDatabase: { + container: ReturnType; + containers: { createIfNotExists: ReturnType }; + }; + mockDatabases: { + createIfNotExists: ReturnType; + }; + MockCosmosClient: ReturnType; + resetMocks: () => void; +} + +/** + * Create a full set of Cosmos DB mocks for unit testing. + * + * Returns mock objects for container, database, and the CosmosClient constructor, + * plus a `resetMocks()` function to clear all mock state between tests. + */ +export function createCosmosMocks(): CosmosMocks { + const mockItem = { + read: vi.fn().mockResolvedValue({ resource: undefined }), + replace: vi.fn().mockResolvedValue({ resource: undefined }), + delete: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({ resource: undefined }), + }; + + const mockContainer = { + id: 'test-container', + items: { + create: vi.fn().mockResolvedValue({ resource: {} }), + query: vi.fn().mockReturnValue({ + fetchAll: vi.fn().mockResolvedValue({ resources: [] }), + }), + upsert: vi.fn().mockResolvedValue({ resource: {} }), + }, + item: vi.fn().mockReturnValue(mockItem), + }; + + const mockDatabase = { + container: vi.fn().mockReturnValue(mockContainer), + containers: { + createIfNotExists: vi.fn().mockResolvedValue({ container: mockContainer }), + }, + }; + + const mockDatabases = { + createIfNotExists: vi.fn().mockResolvedValue({ database: mockDatabase }), + }; + + const MockCosmosClient = vi.fn().mockImplementation(() => ({ + database: vi.fn().mockReturnValue(mockDatabase), + databases: mockDatabases, + })); + + function resetMocks() { + vi.clearAllMocks(); + } + + return { mockContainer, mockDatabase, mockDatabases, MockCosmosClient, resetMocks }; +} diff --git a/packages/testing/src/fastify-helpers.ts b/packages/testing/src/fastify-helpers.ts new file mode 100644 index 00000000..6673e55d --- /dev/null +++ b/packages/testing/src/fastify-helpers.ts @@ -0,0 +1,80 @@ +/** + * Fastify test helpers — inject wrappers and assertion utilities. + * + * Usage: + * ```ts + * import { injectGet, injectPost, expectHealthOk } from '@bytelyst/testing'; + * const res = await injectGet(app, '/health'); + * expectHealthOk(res, 'platform-service'); + * ``` + */ + +import type { FastifyInstance } from 'fastify'; + +interface InjectResult { + statusCode: number; + payload: string; + headers: Record; + json: () => unknown; +} + +/** Inject a GET request into a Fastify instance */ +export async function injectGet( + app: FastifyInstance, + url: string, + headers?: Record +): Promise { + return app.inject({ method: 'GET', url, headers }) as unknown as Promise; +} + +/** Inject a POST request with JSON body into a Fastify instance */ +export async function injectPost( + app: FastifyInstance, + url: string, + body: unknown, + headers?: Record +): Promise { + return app.inject({ + method: 'POST', + url, + payload: body as string, + headers: { 'content-type': 'application/json', ...headers }, + }) as unknown as Promise; +} + +/** Inject a PATCH request with JSON body into a Fastify instance */ +export async function injectPatch( + app: FastifyInstance, + url: string, + body: unknown, + headers?: Record +): Promise { + return app.inject({ + method: 'PATCH', + url, + payload: body as string, + headers: { 'content-type': 'application/json', ...headers }, + }) as unknown as Promise; +} + +/** Inject a DELETE request into a Fastify instance */ +export async function injectDelete( + app: FastifyInstance, + url: string, + headers?: Record +): Promise { + return app.inject({ method: 'DELETE', url, headers }) as unknown as Promise; +} + +/** Assert a health check response has the correct shape */ +export function expectHealthOk(res: InjectResult, serviceName: string): void { + const body = JSON.parse(res.payload); + if (res.statusCode !== 200) throw new Error(`Expected 200, got ${res.statusCode}`); + if (body.status !== 'ok') throw new Error(`Expected status "ok", got "${body.status}"`); + if (body.service !== serviceName) + throw new Error(`Expected service "${serviceName}", got "${body.service}"`); + if (!body.timestamp) throw new Error('Missing timestamp in health response'); + if (!body.requestId) throw new Error('Missing requestId in health response'); +} + +export type { InjectResult }; diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts new file mode 100644 index 00000000..d7fcb052 --- /dev/null +++ b/packages/testing/src/index.ts @@ -0,0 +1,15 @@ +export { createCosmosMocks, type CosmosMocks, type MockItem } from './cosmos-mocks.js'; +export { + TEST_JWT_SECRET, + TEST_USERS, + createTestTokenPayload, + type TestUserKey, +} from './auth-fixtures.js'; +export { + injectGet, + injectPost, + injectPatch, + injectDelete, + expectHealthOk, + type InjectResult, +} from './fastify-helpers.js'; diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json new file mode 100644 index 00000000..c17685d9 --- /dev/null +++ b/packages/testing/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44748c5b..c92b8c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,25 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + packages/testing: + dependencies: + vitest: + specifier: '>=3.0.0' + version: 3.2.4(@types/node@22.19.11)(jsdom@28.0.0)(tsx@4.21.0)(yaml@2.8.2) + devDependencies: + '@bytelyst/errors': + specifier: workspace:* + version: link:../errors + '@bytelyst/fastify-core': + specifier: workspace:* + version: link:../fastify-core + '@fastify/cors': + specifier: ^10.0.2 + version: 10.1.0 + fastify: + specifier: ^5.2.1 + version: 5.7.4 + services/billing-service: dependencies: '@azure/cosmos':