feat: add package tests (58 new) + @bytelyst/fastify-core package

Package tests added:
- @bytelyst/cosmos: 12 tests (client singleton, env vars, container registry)
- @bytelyst/config: 15 tests (base schema, loadConfig, product identity)
- @bytelyst/auth: 10 tests (JWT round-trip, password hash/verify)
- @bytelyst/api-client: 10 tests (fetch, safeFetch, auth injection)
- @bytelyst/design-tokens: 11 tests (loadTokens, generated file checks)

New package:
- @bytelyst/fastify-core: 8 tests (createServiceApp factory with CORS,
  x-request-id, health endpoint, ServiceError handler)

Total: 246 tests passing (was 180)
This commit is contained in:
saravanakumardb1 2026-02-12 22:17:17 -08:00
parent 81452bc157
commit 832eccafed
13 changed files with 981 additions and 408 deletions

View File

@ -0,0 +1,133 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createApiClient } from '../index.js';
// Mock globalThis.fetch
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function jsonResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(data),
};
}
describe('createApiClient', () => {
beforeEach(() => {
mockFetch.mockReset();
});
it('returns an object with fetch and safeFetch', () => {
const api = createApiClient({ baseUrl: 'http://localhost:4003' });
expect(typeof api.fetch).toBe('function');
expect(typeof api.safeFetch).toBe('function');
});
describe('fetch', () => {
it('calls correct URL with base + path', async () => {
mockFetch.mockResolvedValue(jsonResponse({ users: [] }));
const api = createApiClient({ baseUrl: 'http://localhost:4003/api' });
await api.fetch('/users');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:4003/api/users',
expect.objectContaining({
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
})
);
});
it('returns parsed JSON on success', async () => {
mockFetch.mockResolvedValue(jsonResponse({ id: '1', name: 'Test' }));
const api = createApiClient({ baseUrl: '/api' });
const result = await api.fetch<{ id: string; name: string }>('/users/1');
expect(result).toEqual({ id: '1', name: 'Test' });
});
it('throws on HTTP error', async () => {
mockFetch.mockResolvedValue(jsonResponse({ error: 'Not found' }, 404));
const api = createApiClient({ baseUrl: '/api' });
await expect(api.fetch('/users/999')).rejects.toThrow('Not found');
});
it('injects auth token from getToken', async () => {
mockFetch.mockResolvedValue(jsonResponse({ ok: true }));
const api = createApiClient({
baseUrl: '/api',
getToken: () => 'my-jwt-token',
});
await api.fetch('/protected');
expect(mockFetch).toHaveBeenCalledWith(
'/api/protected',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer my-jwt-token',
}),
})
);
});
it('skips auth header when getToken returns null', async () => {
mockFetch.mockResolvedValue(jsonResponse({ ok: true }));
const api = createApiClient({
baseUrl: '/api',
getToken: () => null,
});
await api.fetch('/public');
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
it('merges defaultHeaders', async () => {
mockFetch.mockResolvedValue(jsonResponse({ ok: true }));
const api = createApiClient({
baseUrl: '/api',
defaultHeaders: { 'X-Custom': 'value' },
});
await api.fetch('/test');
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
expect(headers['X-Custom']).toBe('value');
expect(headers['Content-Type']).toBe('application/json');
});
});
describe('safeFetch', () => {
it('returns { data, error: null } on success', async () => {
mockFetch.mockResolvedValue(jsonResponse({ id: '1' }));
const api = createApiClient({ baseUrl: '/api' });
const result = await api.safeFetch<{ id: string }>('/items/1');
expect(result.data).toEqual({ id: '1' });
expect(result.error).toBeNull();
});
it('returns { data: null, error } on HTTP error', async () => {
mockFetch.mockResolvedValue(jsonResponse({ error: 'Forbidden' }, 403));
const api = createApiClient({ baseUrl: '/api' });
const result = await api.safeFetch('/secret');
expect(result.data).toBeNull();
expect(result.error).toBe('Forbidden');
});
it('returns { data: null, error } on network error', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const api = createApiClient({ baseUrl: '/api' });
const result = await api.safeFetch('/unreachable');
expect(result.data).toBeNull();
expect(result.error).toBe('API unavailable');
});
});
});

View File

@ -0,0 +1,137 @@
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { createJwtUtils, hashPassword, verifyPassword } from '../index.js';
describe('JWT utilities', () => {
const SECRET = 'test-jwt-secret-at-least-32-chars-long!!';
beforeAll(() => {
process.env.JWT_SECRET = SECRET;
});
afterAll(() => {
delete process.env.JWT_SECRET;
});
it('creates and verifies an access token', async () => {
const jwt = createJwtUtils({ issuer: 'test-issuer' });
const token = await jwt.createAccessToken({
sub: 'user-1',
email: 'test@example.com',
role: 'admin',
});
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3);
const payload = await jwt.verifyToken(token);
expect(payload).not.toBeNull();
expect(payload!.sub).toBe('user-1');
expect(payload!.email).toBe('test@example.com');
expect(payload!.role).toBe('admin');
expect(payload!.type).toBe('access');
});
it('creates and verifies a refresh token', async () => {
const jwt = createJwtUtils({ issuer: 'test-issuer' });
const token = await jwt.createRefreshToken({ sub: 'user-1' });
const payload = await jwt.verifyToken(token);
expect(payload).not.toBeNull();
expect(payload!.sub).toBe('user-1');
expect(payload!.type).toBe('refresh');
});
it('returns null for invalid token', async () => {
const jwt = createJwtUtils({ issuer: 'test-issuer' });
const result = await jwt.verifyToken('garbage.not.valid');
expect(result).toBeNull();
});
it('returns null for wrong issuer', async () => {
const jwt1 = createJwtUtils({ issuer: 'issuer-a' });
const jwt2 = createJwtUtils({ issuer: 'issuer-b' });
const token = await jwt1.createAccessToken({
sub: 'u1',
email: 'a@b.com',
role: 'user',
});
const result = await jwt2.verifyToken(token);
expect(result).toBeNull();
});
it('sets productId from payload or defaults to issuer', async () => {
const jwt = createJwtUtils({ issuer: 'lysnrai' });
const t1 = await jwt.createAccessToken({
sub: 'u1',
email: 'a@b.com',
role: 'user',
});
const p1 = await jwt.verifyToken(t1);
expect(p1!.productId).toBe('lysnrai');
const t2 = await jwt.createAccessToken({
sub: 'u1',
email: 'a@b.com',
role: 'user',
productId: 'mindlyst',
});
const p2 = await jwt.verifyToken(t2);
expect(p2!.productId).toBe('mindlyst');
});
it('respects custom expiry', async () => {
const jwt = createJwtUtils({
issuer: 'test',
accessTokenExpiry: '2h',
refreshTokenExpiry: '7d',
});
const access = await jwt.createAccessToken({
sub: 'u1',
email: 'a@b.com',
role: 'user',
});
const refresh = await jwt.createRefreshToken({ sub: 'u1' });
expect(typeof access).toBe('string');
expect(typeof refresh).toBe('string');
});
it('throws when JWT_SECRET is not set', async () => {
const origSecret = process.env.JWT_SECRET;
delete process.env.JWT_SECRET;
const jwt = createJwtUtils({ issuer: 'test' });
await expect(
jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' })
).rejects.toThrow('JWT_SECRET must be set');
process.env.JWT_SECRET = origSecret;
});
});
describe('password hashing', () => {
it('hashes a password and verifies it', async () => {
const hash = await hashPassword('MySecret123!');
expect(typeof hash).toBe('string');
expect(hash).not.toBe('MySecret123!');
const valid = await verifyPassword('MySecret123!', hash);
expect(valid).toBe(true);
});
it('rejects wrong password', async () => {
const hash = await hashPassword('correct-password');
const valid = await verifyPassword('wrong-password', hash);
expect(valid).toBe(false);
});
it('produces different hashes for same input', async () => {
const h1 = await hashPassword('same');
const h2 = await hashPassword('same');
expect(h1).not.toBe(h2); // different salts
});
});

View File

@ -0,0 +1,167 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import {
baseEnvSchema,
loadConfig,
loadProductIdentity,
getProductId,
_resetProductIdentity,
} from '../index.js';
describe('baseEnvSchema', () => {
it('provides defaults for PORT, HOST, NODE_ENV, COSMOS_DATABASE', () => {
const result = baseEnvSchema.parse({
SERVICE_NAME: 'test-svc',
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
COSMOS_KEY: 'key==',
});
expect(result.PORT).toBe(3000);
expect(result.HOST).toBe('0.0.0.0');
expect(result.NODE_ENV).toBe('development');
expect(result.COSMOS_DATABASE).toBe('lysnrai');
});
it('rejects missing SERVICE_NAME', () => {
expect(() =>
baseEnvSchema.parse({
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
COSMOS_KEY: 'key==',
})
).toThrow();
});
it('rejects missing COSMOS_ENDPOINT', () => {
expect(() =>
baseEnvSchema.parse({
SERVICE_NAME: 'svc',
COSMOS_KEY: 'key==',
})
).toThrow();
});
it('rejects missing COSMOS_KEY', () => {
expect(() =>
baseEnvSchema.parse({
SERVICE_NAME: 'svc',
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
})
).toThrow();
});
it('coerces PORT from string', () => {
const result = baseEnvSchema.parse({
PORT: '4003',
SERVICE_NAME: 'svc',
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
COSMOS_KEY: 'key==',
});
expect(result.PORT).toBe(4003);
});
it('accepts valid NODE_ENV values', () => {
for (const env of ['development', 'production', 'test']) {
const result = baseEnvSchema.parse({
NODE_ENV: env,
SERVICE_NAME: 'svc',
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
COSMOS_KEY: 'key==',
});
expect(result.NODE_ENV).toBe(env);
}
});
it('rejects invalid NODE_ENV', () => {
expect(() =>
baseEnvSchema.parse({
NODE_ENV: 'staging',
SERVICE_NAME: 'svc',
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
COSMOS_KEY: 'key==',
})
).toThrow();
});
});
describe('loadConfig', () => {
const origEnv = process.env;
beforeEach(() => {
process.env = {
...origEnv,
SERVICE_NAME: 'test-svc',
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
COSMOS_KEY: 'key==',
};
});
afterEach(() => {
process.env = origEnv;
});
it('parses base env without extension', () => {
const config = loadConfig();
expect(config.SERVICE_NAME).toBe('test-svc');
expect(config.PORT).toBe(3000);
});
it('extends with additional fields', async () => {
process.env.STRIPE_KEY = 'sk_test_123';
const { z } = await import('zod');
const config = loadConfig({ STRIPE_KEY: z.string().min(1) });
expect(config.STRIPE_KEY).toBe('sk_test_123');
expect(config.SERVICE_NAME).toBe('test-svc');
});
it('throws on missing required extension field', async () => {
const { z } = await import('zod');
expect(() => loadConfig({ MISSING_FIELD: z.string().min(1) })).toThrow();
});
});
describe('productIdentity', () => {
beforeEach(() => {
_resetProductIdentity();
});
it('falls back to env vars', () => {
process.env.PRODUCT_ID = 'testprod';
process.env.DISPLAY_NAME = 'TestProd';
const identity = loadProductIdentity();
expect(identity.productId).toBe('testprod');
expect(identity.displayName).toBe('TestProd');
delete process.env.PRODUCT_ID;
delete process.env.DISPLAY_NAME;
});
it('defaults to lysnrai when no env or file', () => {
delete process.env.PRODUCT_ID;
const identity = loadProductIdentity();
expect(identity.productId).toBe('lysnrai');
expect(identity.displayName).toBe('LysnrAI');
expect(identity.licensePrefix).toBe('LYSNR');
});
it('getProductId returns just the ID', () => {
_resetProductIdentity();
delete process.env.PRODUCT_ID;
expect(getProductId()).toBe('lysnrai');
});
it('caches identity after first load', () => {
delete process.env.PRODUCT_ID;
const id1 = loadProductIdentity();
process.env.PRODUCT_ID = 'changed';
const id2 = loadProductIdentity();
expect(id1).toBe(id2); // same cached object
delete process.env.PRODUCT_ID;
});
it('_resetProductIdentity clears cache', () => {
delete process.env.PRODUCT_ID;
loadProductIdentity();
_resetProductIdentity();
process.env.PRODUCT_ID = 'newprod';
const fresh = loadProductIdentity();
expect(fresh.productId).toBe('newprod');
delete process.env.PRODUCT_ID;
});
});

View File

@ -0,0 +1,152 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
// Must hoist mocks so they're available when vi.mock factory runs
const { mockDatabase, mockDatabases, MockCosmosClient } = vi.hoisted(() => {
const mockContainer = { id: 'test-container' };
const mockDatabase = {
container: vi.fn(() => mockContainer),
containers: { createIfNotExists: vi.fn() },
};
const mockDatabases = { createIfNotExists: vi.fn(() => ({ database: mockDatabase })) };
const MockCosmosClient = vi.fn(() => ({
database: vi.fn(() => mockDatabase),
databases: mockDatabases,
}));
return { mockDatabase, mockDatabases, MockCosmosClient };
});
vi.mock('@azure/cosmos', () => ({
CosmosClient: MockCosmosClient,
PartitionKeyDefinition: class {},
}));
import {
getCosmosClient,
getDatabase,
getContainer,
_resetClient,
registerContainers,
getRegisteredContainer,
initializeAllContainers,
_resetRegistry,
} from '../index.js';
describe('cosmos client', () => {
beforeEach(() => {
_resetClient();
_resetRegistry();
MockCosmosClient.mockClear();
process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/';
process.env.COSMOS_KEY = 'test-key==';
process.env.COSMOS_DATABASE = 'testdb';
});
afterEach(() => {
delete process.env.COSMOS_ENDPOINT;
delete process.env.COSMOS_KEY;
delete process.env.COSMOS_DATABASE;
});
it('getCosmosClient creates singleton', () => {
const c1 = getCosmosClient();
const c2 = getCosmosClient();
expect(c1).toBe(c2);
expect(MockCosmosClient).toHaveBeenCalledTimes(1);
expect(MockCosmosClient).toHaveBeenCalledWith({
endpoint: 'https://test.documents.azure.com:443/',
key: 'test-key==',
});
});
it('getCosmosClient throws without COSMOS_ENDPOINT', () => {
delete process.env.COSMOS_ENDPOINT;
expect(() => getCosmosClient()).toThrow('COSMOS_ENDPOINT is required');
});
it('getCosmosClient throws without COSMOS_KEY', () => {
delete process.env.COSMOS_KEY;
expect(() => getCosmosClient()).toThrow('COSMOS_KEY is required');
});
it('getDatabase uses COSMOS_DATABASE env var', () => {
const db = getDatabase();
expect(db).toBeDefined();
});
it('getDatabase defaults to lysnrai', () => {
_resetClient();
delete process.env.COSMOS_DATABASE;
getDatabase();
// Client was called, database accessed
expect(MockCosmosClient).toHaveBeenCalled();
});
it('getContainer returns container by name', () => {
const c = getContainer('users');
expect(c).toBeDefined();
});
it('_resetClient clears singleton', () => {
getCosmosClient();
expect(MockCosmosClient).toHaveBeenCalledTimes(1);
_resetClient();
getCosmosClient();
expect(MockCosmosClient).toHaveBeenCalledTimes(2);
});
});
describe('container registry', () => {
beforeEach(() => {
_resetClient();
_resetRegistry();
MockCosmosClient.mockClear();
process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/';
process.env.COSMOS_KEY = 'test-key==';
process.env.COSMOS_DATABASE = 'testdb';
});
afterEach(() => {
delete process.env.COSMOS_ENDPOINT;
delete process.env.COSMOS_KEY;
delete process.env.COSMOS_DATABASE;
});
it('registerContainers stores definitions', () => {
registerContainers({
users: { partitionKeyPath: '/productId' },
tokens: { partitionKeyPath: '/userId' },
});
// Should not throw
const c = getRegisteredContainer('users');
expect(c).toBeDefined();
});
it('getRegisteredContainer throws for unknown name', () => {
expect(() => getRegisteredContainer('nope')).toThrow("Unknown container 'nope'");
});
it('getRegisteredContainer caches container instances', () => {
registerContainers({ items: { partitionKeyPath: '/id' } });
const c1 = getRegisteredContainer('items');
const c2 = getRegisteredContainer('items');
expect(c1).toBe(c2);
});
it('initializeAllContainers creates database and containers', async () => {
registerContainers({
users: { partitionKeyPath: '/productId' },
audit: { partitionKeyPath: '/productId', defaultTtl: 86400 },
});
await initializeAllContainers();
expect(mockDatabases.createIfNotExists).toHaveBeenCalledWith({ id: 'testdb' });
expect(mockDatabase.containers.createIfNotExists).toHaveBeenCalledTimes(2);
});
it('_resetRegistry clears all', () => {
registerContainers({ x: { partitionKeyPath: '/id' } });
_resetRegistry();
expect(() => getRegisteredContainer('x')).toThrow("Unknown container 'x'");
});
});

View File

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadTokens } from '../index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const generatedDir = resolve(__dirname, '../../generated');
describe('loadTokens', () => {
it('returns a valid DesignTokens object', () => {
const tokens = loadTokens();
expect(tokens).toBeDefined();
expect(tokens.meta).toBeDefined();
expect(tokens.meta.name).toBeTruthy();
expect(tokens.meta.version).toBeTruthy();
});
it('has color palette with expected keys', () => {
const tokens = loadTokens();
expect(tokens.color).toBeDefined();
expect(tokens.color.palette).toBeDefined();
expect(tokens.color.semantic).toBeDefined();
expect(tokens.color.semantic.dark).toBeDefined();
expect(tokens.color.brain).toBeDefined();
});
it('has typography with font families', () => {
const tokens = loadTokens();
expect(tokens.typography.fontFamily).toBeDefined();
expect(Object.keys(tokens.typography.fontFamily).length).toBeGreaterThan(0);
});
it('has spacing values on 4pt grid', () => {
const tokens = loadTokens();
expect(tokens.spacing).toBeDefined();
// All spacing values should be multiples of 4
for (const [, value] of Object.entries(tokens.spacing)) {
expect(value % 4).toBe(0);
}
});
it('has radius values', () => {
const tokens = loadTokens();
expect(tokens.radius).toBeDefined();
expect(Object.keys(tokens.radius).length).toBeGreaterThan(0);
});
it('has motion durations and easings', () => {
const tokens = loadTokens();
expect(tokens.motion).toBeDefined();
expect(tokens.motion.duration).toBeDefined();
expect(tokens.motion.easing).toBeDefined();
});
it('caches after first load', () => {
const t1 = loadTokens();
const t2 = loadTokens();
expect(t1).toBe(t2);
});
});
describe('generated files', () => {
it('tokens.css exists and contains --ml- properties', () => {
const cssPath = resolve(generatedDir, 'tokens.css');
expect(existsSync(cssPath)).toBe(true);
const css = readFileSync(cssPath, 'utf-8');
expect(css).toContain('--ml-');
});
it('tokens.ts exists and exports token values', () => {
const tsPath = resolve(generatedDir, 'tokens.ts');
expect(existsSync(tsPath)).toBe(true);
const ts = readFileSync(tsPath, 'utf-8');
expect(ts).toContain('export');
});
it('MindLystTokens.kt exists and contains object declaration', () => {
const ktPath = resolve(generatedDir, 'MindLystTokens.kt');
expect(existsSync(ktPath)).toBe(true);
const kt = readFileSync(ktPath, 'utf-8');
expect(kt).toContain('object MindLystTokens');
});
it('MindLystTheme.swift exists and contains struct', () => {
const swiftPath = resolve(generatedDir, 'MindLystTheme.swift');
expect(existsSync(swiftPath)).toBe(true);
const swift = readFileSync(swiftPath, 'utf-8');
expect(swift).toContain('MindLyst');
});
});

View File

@ -0,0 +1,27 @@
{
"name": "@bytelyst/fastify-core",
"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"
},
"dependencies": {
"@bytelyst/errors": "workspace:*"
},
"peerDependencies": {
"fastify": ">=5.0.0",
"@fastify/cors": ">=10.0.0"
}
}

View File

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import { createServiceApp } from '../index.js';
describe('createServiceApp', () => {
it('returns a Fastify instance', async () => {
const app = await createServiceApp({
name: 'test-service',
version: '0.0.1',
logger: false,
});
expect(app).toBeDefined();
expect(typeof app.listen).toBe('function');
expect(typeof app.get).toBe('function');
await app.close();
});
it('has a /health endpoint returning correct shape', async () => {
const app = await createServiceApp({
name: 'my-service',
version: '1.2.3',
description: 'A test service',
logger: false,
});
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.status).toBe('ok');
expect(body.service).toBe('my-service');
expect(body.version).toBe('1.2.3');
expect(body.description).toBe('A test service');
expect(body.timestamp).toBeTruthy();
expect(body.requestId).toBeTruthy();
await app.close();
});
it('propagates x-request-id header', async () => {
const app = await createServiceApp({
name: 'test',
version: '0.1.0',
logger: false,
});
const customId = 'req-12345';
const res = await app.inject({
method: 'GET',
url: '/health',
headers: { 'x-request-id': customId },
});
expect(res.headers['x-request-id']).toBe(customId);
const body = JSON.parse(res.payload);
expect(body.requestId).toBe(customId);
await app.close();
});
it('generates x-request-id when not provided', async () => {
const app = await createServiceApp({
name: 'test',
version: '0.1.0',
logger: false,
});
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.headers['x-request-id']).toBeTruthy();
// Should be UUID format
expect(res.headers['x-request-id']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
await app.close();
});
it('handles ServiceError with correct status code', async () => {
const { NotFoundError } = await import('@bytelyst/errors');
const app = await createServiceApp({
name: 'test',
version: '0.1.0',
logger: false,
});
app.get('/fail', async () => {
throw new NotFoundError('User not found');
});
const res = await app.inject({ method: 'GET', url: '/fail' });
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.payload);
expect(body.error).toBe('User not found');
await app.close();
});
it('handles ServiceError with details', async () => {
const { BadRequestError } = await import('@bytelyst/errors');
const app = await createServiceApp({
name: 'test',
version: '0.1.0',
logger: false,
});
app.get('/bad', async () => {
throw new BadRequestError('Validation failed', { field: 'email' });
});
const res = await app.inject({ method: 'GET', url: '/bad' });
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.payload);
expect(body.error).toBe('Validation failed');
expect(body.details).toEqual({ field: 'email' });
await app.close();
});
it('handles unknown errors as 500', async () => {
const app = await createServiceApp({
name: 'test',
version: '0.1.0',
logger: false,
});
app.get('/crash', async () => {
throw new Error('unexpected');
});
const res = await app.inject({ method: 'GET', url: '/crash' });
expect(res.statusCode).toBe(500);
const body = JSON.parse(res.payload);
expect(body.error).toBe('Internal server error');
await app.close();
});
it('omits description from health when not provided', async () => {
const app = await createServiceApp({
name: 'minimal',
version: '0.1.0',
logger: false,
});
const res = await app.inject({ method: 'GET', url: '/health' });
const body = JSON.parse(res.payload);
expect(body.description).toBeUndefined();
await app.close();
});
});

View File

@ -0,0 +1,68 @@
/**
* Factory for creating a Fastify service app with standard middleware.
*
* Includes: CORS, x-request-id propagation, health endpoint, ServiceError handler.
*/
import { randomUUID } from 'node:crypto';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { ServiceError } from '@bytelyst/errors';
import type { ServiceAppOptions, FastifyApp } from './types.js';
/**
* Create a Fastify app preconfigured with common middleware.
*
* @example
* ```ts
* const app = await createServiceApp({
* name: "platform-service",
* version: "0.1.0",
* description: "Auth, audit, flags, notifications",
* corsOrigin: process.env.CORS_ORIGIN,
* });
* await app.register(authRoutes, { prefix: "/api" });
* await startService(app, { port: 4003 });
* ```
*/
export async function createServiceApp(options: ServiceAppOptions): Promise<FastifyApp> {
const { name, version, description, corsOrigin, logger = true } = options;
const app = Fastify({ logger });
// CORS
const origin = corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : true;
await app.register(cors, { origin });
// x-request-id propagation
app.addHook('onRequest', async (req, reply) => {
const requestId = (req.headers['x-request-id'] as string) || randomUUID();
req.headers['x-request-id'] = requestId;
reply.header('x-request-id', requestId);
req.log = req.log.child({ requestId });
});
// Health check
app.get('/health', async req => ({
status: 'ok',
service: name,
version,
...(description && { description }),
timestamp: new Date().toISOString(),
requestId: req.headers['x-request-id'],
}));
// ServiceError-aware error handler
app.setErrorHandler((error, _req, reply) => {
if (error instanceof ServiceError) {
const body: Record<string, unknown> = { error: error.message };
if (error.details) body.details = error.details;
reply.code(error.statusCode).send(body);
return;
}
app.log.error(error);
reply.code(500).send({ error: 'Internal server error' });
});
return app;
}

View File

@ -0,0 +1,3 @@
export { createServiceApp } from './create-app.js';
export { startService } from './start.js';
export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js';

View File

@ -0,0 +1,16 @@
/**
* Service startup helper starts the app and logs the address.
*/
import type { FastifyApp, StartOptions } from './types.js';
export async function startService(app: FastifyApp, options: StartOptions): Promise<void> {
const { port, host = '0.0.0.0' } = options;
try {
await app.listen({ port, host });
app.log.info(`Service listening on ${host}:${port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}

View File

@ -0,0 +1,16 @@
import type { FastifyInstance } from 'fastify';
export interface ServiceAppOptions {
name: string;
version: string;
description?: string;
corsOrigin?: string;
logger?: boolean;
}
export interface StartOptions {
port: number;
host?: string;
}
export type FastifyApp = FastifyInstance;

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

420
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff