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
This commit is contained in:
parent
428e973548
commit
7ffc60c490
35
packages/testing/package.json
Normal file
35
packages/testing/package.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
100
packages/testing/src/__tests__/testing.test.ts
Normal file
100
packages/testing/src/__tests__/testing.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
55
packages/testing/src/auth-fixtures.ts
Normal file
55
packages/testing/src/auth-fixtures.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
87
packages/testing/src/cosmos-mocks.ts
Normal file
87
packages/testing/src/cosmos-mocks.ts
Normal file
@ -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<typeof vi.fn>;
|
||||
query: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
item: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
mockDatabase: {
|
||||
container: ReturnType<typeof vi.fn>;
|
||||
containers: { createIfNotExists: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
mockDatabases: {
|
||||
createIfNotExists: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
MockCosmosClient: ReturnType<typeof vi.fn>;
|
||||
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 };
|
||||
}
|
||||
80
packages/testing/src/fastify-helpers.ts
Normal file
80
packages/testing/src/fastify-helpers.ts
Normal file
@ -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<string, string | string[] | undefined>;
|
||||
json: () => unknown;
|
||||
}
|
||||
|
||||
/** Inject a GET request into a Fastify instance */
|
||||
export async function injectGet(
|
||||
app: FastifyInstance,
|
||||
url: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<InjectResult> {
|
||||
return app.inject({ method: 'GET', url, headers }) as unknown as Promise<InjectResult>;
|
||||
}
|
||||
|
||||
/** Inject a POST request with JSON body into a Fastify instance */
|
||||
export async function injectPost(
|
||||
app: FastifyInstance,
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers?: Record<string, string>
|
||||
): Promise<InjectResult> {
|
||||
return app.inject({
|
||||
method: 'POST',
|
||||
url,
|
||||
payload: body as string,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
}) as unknown as Promise<InjectResult>;
|
||||
}
|
||||
|
||||
/** Inject a PATCH request with JSON body into a Fastify instance */
|
||||
export async function injectPatch(
|
||||
app: FastifyInstance,
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers?: Record<string, string>
|
||||
): Promise<InjectResult> {
|
||||
return app.inject({
|
||||
method: 'PATCH',
|
||||
url,
|
||||
payload: body as string,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
}) as unknown as Promise<InjectResult>;
|
||||
}
|
||||
|
||||
/** Inject a DELETE request into a Fastify instance */
|
||||
export async function injectDelete(
|
||||
app: FastifyInstance,
|
||||
url: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<InjectResult> {
|
||||
return app.inject({ method: 'DELETE', url, headers }) as unknown as Promise<InjectResult>;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
15
packages/testing/src/index.ts
Normal file
15
packages/testing/src/index.ts
Normal file
@ -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';
|
||||
9
packages/testing/tsconfig.json
Normal file
9
packages/testing/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@ -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':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user