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:
parent
81452bc157
commit
832eccafed
133
packages/api-client/src/__tests__/api-client.test.ts
Normal file
133
packages/api-client/src/__tests__/api-client.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
137
packages/auth/src/__tests__/auth.test.ts
Normal file
137
packages/auth/src/__tests__/auth.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
167
packages/config/src/__tests__/config.test.ts
Normal file
167
packages/config/src/__tests__/config.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
152
packages/cosmos/src/__tests__/cosmos.test.ts
Normal file
152
packages/cosmos/src/__tests__/cosmos.test.ts
Normal 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'");
|
||||
});
|
||||
});
|
||||
91
packages/design-tokens/src/__tests__/tokens.test.ts
Normal file
91
packages/design-tokens/src/__tests__/tokens.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
27
packages/fastify-core/package.json
Normal file
27
packages/fastify-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
150
packages/fastify-core/src/__tests__/fastify-core.test.ts
Normal file
150
packages/fastify-core/src/__tests__/fastify-core.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
68
packages/fastify-core/src/create-app.ts
Normal file
68
packages/fastify-core/src/create-app.ts
Normal 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;
|
||||
}
|
||||
3
packages/fastify-core/src/index.ts
Normal file
3
packages/fastify-core/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { createServiceApp } from './create-app.js';
|
||||
export { startService } from './start.js';
|
||||
export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js';
|
||||
16
packages/fastify-core/src/start.ts
Normal file
16
packages/fastify-core/src/start.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
packages/fastify-core/src/types.ts
Normal file
16
packages/fastify-core/src/types.ts
Normal 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;
|
||||
9
packages/fastify-core/tsconfig.json
Normal file
9
packages/fastify-core/tsconfig.json
Normal 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
420
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user