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