npm package: @jamx-framework/testing
Description: JAMX Framework â€” Testing utilities
Author: Stiven-21
Latest version: 1.0.0

# @jamx-framework/testing

## Descripción
Utilidades de testing para JAMX Framework. Proporciona herramientas para tests de integración y unitarios, incluyendo un cliente HTTP, un servidor HTTP minimalista, factories de objetos, y mocks de los servicios principales de JAMX (mailer, storage, cache, queue).

## Cómo funciona
El paquete ofrece tres categorías de herramientas:

1. **TestClient**: Cliente HTTP para realizar requests a servidores reales (ideal para tests de integración)
2. **TestServer**: Servidor HTTP minimalista que corre en memoria para simular endpoints
3. **Factories**: Funciones para crear objetos de test con datos predeterminados y overrides
4. **Mocks**: Implementaciones en memoria de servicios JAMX para aislar tests

## Componentes principales

### TestClient (`src/test-client.ts`)
Cliente HTTP que realiza requests reales a un servidor:
- `get(path, options)`: Realiza petición GET
- `post(path, options)`: Realiza petición POST con body JSON
- `put(path, options)`: Realiza petición PUT
- `patch(path, options)`: Realiza petición PATCH
- `delete(path, options)`: Realiza petición DELETE

Retorna `TestResponse` con métodos `json<T>()` y `text()`.

### TestServer (`src/test-server.ts`)
Servidor HTTP minimalista para tests:
- `get/post/put/delete(path, handler)`: Define rutas
- `use(handler)`: Middleware global
- `start()`: Inicia el servidor en puerto aleatorio
- `stop()`: Detiene el servidor
- `url`: URL base del servidor (ej: `http://localhost:3000`)

Los handlers reciben `(req, res)` donde `req` tiene `body` y `params` parseados.

### Factories (`src/factories.ts`)
- `createFactory<T>(defaults)`: Crea una factory de objetos
- `createSequence<T>(fn)`: Crea un generador de valores secuenciales

### Mocks
- `mockMailer()`: Mock del servicio de email
- `mockStorage(baseUrl?)`: Mock del storage (S3/local)
- `mockCache(prefix?)`: Mock del cache (Redis/memory)
- `mockQueue<T>(handler?)`: Mock de cola de jobs

## Uso básico

### TestClient
```typescript
import { TestClient } from '@jamx-framework/testing';

const client = new TestClient('http://localhost:3000');

// GET request
const response = await client.get('/api/users');
const users = response.json<User[]>();

// POST request
const createResponse = await client.post('/api/users', {
  body: { name: 'Alice', email: 'alice@example.com' },
  headers: { Authorization: 'Bearer token' },
});
const newUser = createResponse.json<User>();
```

### TestServer
```typescript
import { createTestServer } from '@jamx-framework/testing';

const server = createTestServer();

// Definir rutas
server.get('/api/users', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify([{ id: 1, name: 'Alice' }]));
});

server.post('/api/users', async (req, res) => {
  const user = req.body as { name: string };
  // Guardar en DB...
  res.writeHead(201, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ ...user, id: 1 }));
});

// Iniciar servidor
const baseUrl = await server.start();
console.log(`Server running at ${baseUrl}`);

// Hacer requests con TestClient
const client = new TestClient(baseUrl);
const response = await client.get('/api/users');

// Detener servidor
await server.stop();
```

### TestServer con middleware
```typescript
server.use(async (req, res) => {
  // Middleware de logging
  console.log(`${req.method} ${req.url}`);
  // Agregar header
  res.setHeader('X-Custom-Header', 'value');
});

server.get('/api/data', (req, res) => {
  // El middleware ya ejecutó
  res.writeHead(200);
  res.end('OK');
});
```

### TestServer con params de ruta
```typescript
server.get('/api/users/:id', (req, res) => {
  const id = req.params?.id; // Extraído automáticamente
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ id, name: 'User' }));
});

// GET /api/users/123 → req.params = { id: '123' }
```

### Factories
```typescript
import { createFactory, createSequence } from '@jamx-framework/testing';

// Factory de usuarios
const makeUser = createFactory({
  id: '1',
  name: 'Test User',
  email: 'test@example.com',
  role: 'user',
});

const user1 = makeUser(); // { id: '1', name: 'Test User', ... }
const admin = makeUser({ role: 'admin', name: 'Admin' }); // overrides

// Secuencia de IDs
const nextId = createSequence((n) => String(n));
const id1 = nextId(); // '1'
const id2 = nextId(); // '2'

// Secuencia de emails
const nextEmail = createSequence((n) => `user${n}@test.com`);
const email1 = nextEmail(); // 'user1@test.com'
```

### Mocks

#### mockMailer
```typescript
import { mockMailer } from '@jamx-framework/testing';

const mailer = mockMailer();

// Enviar email
await mailer.send({
  to: 'user@example.com',
  subject: 'Welcome',
  html: '<h1>Hello</h1>',
});

// Enviar con template
await mailer.sendTemplate('welcome', {
  to: 'user@example.com',
  data: { name: 'Alice' },
});

// Verificar emails enviados
expect(mailer.sent).toHaveLength(2);
expect(mailer.sent[0].to).toBe('user@example.com');
expect(mailer.lastSent()?.subject).toBe('Welcome');

// Limpiar
mailer.clear();
```

#### mockStorage
```typescript
import { mockStorage } from '@jamx-framework/testing';

const storage = mockStorage('http://localhost/files');

// Subir archivo
await storage.put('avatar.png', Buffer.from('image data'), {
  contentType: 'image/png',
});

// Descargar archivo
const data = await storage.get('avatar.png');
expect(data?.toString()).toBe('image data');

// Verificar existencia
expect(await storage.exists('avatar.png')).toBe(true);

// Listar archivos
const files = await storage.list();
expect(files).toHaveLength(1);

// URL pública (para tests)
const url = await storage.url('avatar.png');
expect(url).toBe('http://localhost/files/avatar.png');

// Limpiar
storage.clear();
```

#### mockCache
```typescript
import { mockCache } from '@jamx-framework/testing';

const cache = mockCache('test:');

// Guardar
await cache.set('key1', { value: 123 }, 60); // TTL 60s

// Obtener
const data = await cache.get('key1');
expect(data?.value).toBe(123);

// Verificar existencia
expect(await cache.has('key1')).toBe(true);

// Eliminar
await cache.delete('key1');

// Limpiar todo
cache.clear();
```

#### mockQueue
```typescript
import { mockQueue } from '@jamx-framework/testing';

// Mock sin handler (solo registro)
const queue = mockQueue<{ userId: number }>();

// Añadir jobs
await queue.add('send-email', { userId: 1 });
await queue.add('send-email', { userId: 2 });

// Ver jobs encolados
expect(queue.jobs).toHaveLength(2);
expect(queue.jobs[0].name).toBe('send-email');

// Con handler para procesar
const queueWithHandler = mockQueue<{ userId: number }>(async (job) => {
  console.log(`Processing job ${job.id} for user ${job.data.userId}`);
});

await queueWithHandler.add('notify', { userId: 1 });
await queueWithHandler.processAll(); // Procesa todos los jobs

// Limpiar
queue.clear();
```

### Ejemplo completo de test de integración
```typescript
import { createTestServer, TestClient, createFactory } from '@jamx-framework/testing';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

const makeUser = createFactory({
  id: 1,
  name: 'Test User',
  email: 'test@example.com',
});

describe('User API', () => {
  let server: ReturnType<typeof createTestServer>;
  let client: TestClient;

  beforeEach(async () => {
    server = createTestServer();

    // Setup routes
    server.get('/api/users', (req, res) => {
      const users = [
        makeUser({ id: 1, name: 'Alice' }),
        makeUser({ id: 2, name: 'Bob' }),
      ];
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(users));
    });

    server.post('/api/users', async (req, res) => {
      const user = req.body as { name: string; email: string };
      const newUser = makeUser({
        id: Date.now(),
        name: user.name,
        email: user.email,
      });
      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(newUser));
    });

    const baseUrl = await server.start();
    client = new TestClient(baseUrl);
  });

  afterEach(async () => {
    await server.stop();
  });

  it('should list all users', async () => {
    const response = await client.get('/api/users');
    expect(response.status).toBe(200);

    const users = response.json<Array<{ id: number; name: string }>>();
    expect(users).toHaveLength(2);
    expect(users[0].name).toBe('Alice');
  });

  it('should create a new user', async () => {
    const response = await client.post('/api/users', {
      body: { name: 'Charlie', email: 'charlie@example.com' },
    });
    expect(response.status).toBe(201);

    const user = response.json<{ id: number; name: string; email: string }>();
    expect(user.name).toBe('Charlie');
    expect(user.email).toBe('charlie@example.com');
  });
});
```

## API Reference

### TestClient

#### Constructor
```typescript
new TestClient(baseUrl: string)
```
Crea un cliente HTTP que apunta a `baseUrl`.

#### Métodos
```typescript
get<T = unknown>(path: string, options?: RequestOptions): Promise<TestResponse>
post<T = unknown>(path: string, options?: RequestOptions): Promise<TestResponse>
put<T = unknown>(path: string, options?: RequestOptions): Promise<TestResponse>
patch<T = unknown>(path: string, options?: RequestOptions): Promise<TestResponse>
delete<T = unknown>(path: string, options?: RequestOptions): Promise<TestResponse>
```

#### TestResponse
```typescript
interface TestResponse {
  status: number;
  headers: Record<string, string>;
  body: string;
  json<T = unknown>(): T;
  text(): string;
}
```

#### RequestOptions
```typescript
interface RequestOptions {
  headers?: Record<string, string>;
  body?: unknown; // Se serializa como JSON automáticamente
}
```

### TestServer

#### Interface
```typescript
interface TestServer {
  get(path: string, handler: TestHandler): this;
  post(path: string, handler: TestHandler): this;
  put(path: string, handler: TestHandler): this;
  delete(path: string, handler: TestHandler): this;
  use(handler: TestHandler): this;
  start(): Promise<string>; // retorna baseUrl
  stop(): Promise<void>;
  url: string;
}
```

#### TestHandler
```typescript
type TestHandler = (
  req: http.IncomingMessage & {
    body?: unknown;
    params?: Record<string, string>;
  },
  res: http.ServerResponse,
) => void | Promise<void>;
```

### Factories

#### createFactory
```typescript
function createFactory<T extends object>(defaults: T): (overrides?: DeepPartial<T>) => T
```
Crea una función que retorna objetos con valores por defecto, fusionando overrides.

#### createSequence
```typescript
function createSequence<T>(fn: (n: number) => T): () => T
```
Crea un generador que retorna valores secuenciales únicos.

### Mocks

#### mockMailer
```typescript
function mockMailer(): MockMailer
```

#### MockMailer
```typescript
interface MockMailer {
  send(msg: MockMailMessage): Promise<void>;
  sendTemplate(template: string, options: { to: string; data?: Record<string, unknown> }): Promise<void>;
  sent: MockMailMessage[];
  clear(): void;
  hasSent(to: string): boolean;
  lastSent(): MockMailMessage | null;
}
```

#### mockStorage
```typescript
function mockStorage(baseUrl?: string): MockStorage
```

#### MockStorage
```typescript
interface MockStorage {
  put(key: string, data: Buffer, options?: { contentType?: string }): Promise<void>;
  get(key: string): Promise<Buffer | null>;
  delete(key: string): Promise<boolean>;
  exists(key: string): Promise<boolean>;
  url(key: string): Promise<string>;
  list(prefix?: string): Promise<MockStorageFile[]>;
  files: Map<string, MockStorageFile>;
  clear(): void;
}
```

#### mockCache
```typescript
function mockCache(prefix?: string): Cache
```
Retorna una instancia de `Cache` con driver memory.

#### mockQueue
```typescript
function mockQueue<T = unknown>(handler?: MockJobHandler<T>): MockQueue<T>
```

#### MockQueue
```typescript
interface MockQueue<T = unknown> {
  add(name: string, data: T): Promise<MockJob<T>>;
  processAll(): Promise<void>;
  jobs: MockJob<T>[];
  clear(): void;
}
```

## Configuración

### tsconfig.json
```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}
```

### Scripts disponibles
- `pnpm build` - Compila TypeScript a JavaScript
- `pnpm dev` - Compilación en watch mode
- `pnpm test` - Ejecuta tests unitarios
- `pnpm test:watch` - Tests en watch mode
- `pnpm type-check` - Verifica tipos sin compilar
- `pnpm clean` - Limpia archivos compilados

## Testing
Tests en `packages/testing/tests/unit/`:
- `factories.test.ts`: Tests de createFactory y createSequence
- `mocks.test.ts`: Tests de todos los mocks
- `test-server.test.ts`: Tests de TestServer

Ejecutar tests:
```bash
pnpm test
```

## Dependencias
- `@jamx-framework/cache` - Para mockCache
- `@jamx-framework/core` - Para tipos base
- `@jamx-framework/server` - Para TestServer
- `@jamx-framework/validator` - Para validación (opcional)
- `@types/node` - Tipos de Node.js
- `vitest` - Framework de testing

## Casos de uso

### Tests de integración con API real
```typescript
// Usar TestClient para probar un servidor JAMX real
const client = new TestClient('http://localhost:3000');

test('should create user', async () => {
  const res = await client.post('/users', {
    body: { name: 'Alice', email: 'alice@test.com' },
  });
  expect(res.status).toBe(201);
  const user = res.json<User>();
  expect(user.id).toBeDefined();
});
```

### Tests de endpoints aislados
```typescript
// Usar TestServer para probar lógica de endpoints sin levantar servidor completo
const server = createTestServer();
server.get('/health', (req, res) => {
  res.writeHead(200);
  res.end(JSON.stringify({ status: 'ok' }));
});

await server.start();
const client = new TestClient(server.url);
const res = await client.get('/health');
expect(res.json()).toEqual({ status: 'ok' });
await server.stop();
```

### Tests con datos de ejemplo
```typescript
const makeProduct = createFactory({
  id: 0,
  name: 'Product',
  price: 0,
  inStock: true,
});

test('should filter products', () => {
  const products = [
    makeProduct({ id: 1, name: 'Apple', price: 1.5 }),
    makeProduct({ id: 2, name: 'Banana', price: 0.8 }),
    makeProduct({ id: 3, name: 'Carrot', price: 0.5 }),
  ];

  const cheap = products.filter(p => p.price < 1);
  expect(cheap).toHaveLength(2);
});
```

### Tests de servicios con mocks
```typescript
import { mockMailer, mockStorage } from '@jamx-framework/testing';

test('should send welcome email on signup', async () => {
  const mailer = mockMailer();
  const user = { id: 1, email: 'alice@example.com', name: 'Alice' };

  // Lógica de signup que usa mailer
  await mailer.send({
    to: user.email,
    subject: 'Welcome!',
    html: `<h1>Hello ${user.name}</h1>`,
  });

  expect(mailer.sent).toHaveLength(1);
  expect(mailer.sent[0].to).toBe(user.email);
});

test('should upload file to storage', async () => {
  const storage = mockStorage();
  const fileData = Buffer.from('file content');

  await storage.put('documents/file.txt', fileData, {
    contentType: 'text/plain',
  });

  expect(await storage.exists('documents/file.txt')).toBe(true);
  const retrieved = await storage.get('documents/file.txt');
  expect(retrieved?.toString()).toBe('file content');
});
```

## Limitaciones
- **TestServer**: No soporta HTTPS (solo HTTP)
- **TestServer**: No tiene soporte para WebSocket
- **TestClient**: Bloqueante en Node.js (pero usa async/await)
- **Mocks**: Todos en memoria, no persisten entre tests
- **mockQueue**: Procesa jobs secuencialmente, no en paralelo

## Buenas prácticas

1. **Siempre limpiar mocks**:
```typescript
afterEach(() => {
  mailer.clear();
  storage.clear();
  cache.clear();
  queue.clear();
});
```

2. **Usar factories para datos de test**:
```typescript
const makeUser = createFactory({ id: 1, name: 'User', email: 'user@test.com' });
const user = makeUser({ name: 'Alice' }); // más legible
```

3. **Asegurar puertos libres**:
```typescript
// TestServer ya usa puerto 0 (aleatorio), no hay conflicto
```

4. **Mockear dependencias externas**:
```typescript
// En lugar de llamar a API real, usar mockStorage
const storage = mockStorage();
await storage.put('key', data);
```

5. **Tests asincrónicos**:
```typescript
// TestServer.start() y stop() son async
beforeEach(async () => {
  await server.start();
});

afterEach(async () => {
  await server.stop();
});
```

## Integración con otros paquetes
- `@jamx-framework/server`: TestServer puede simular endpoints de JAMX server
- `@jamx-framework/cache`: mockCache retorna instancia de Cache real
- `@jamx-framework/storage`: mockStorage implementa StorageAdapter
- `@jamx-framework/queue`: mockQueue simula Queue
- `@jamx-framework/mailer`: mockMailer simula Mailer

## Ejemplo: Test completo de API JAMX
```typescript
import { createTestServer } from '@jamx-framework/testing';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

// Simular una API JAMX completa
const server = createTestServer();

server.use((req, res) => {
  // Middleware de logging
  console.log(`${req.method} ${req.url}`);
  next();
});

server.get('/api/health', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() }));
});

server.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body as { email: string; password: string };
  if (email === 'test@example.com' && password === 'pass') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ token: 'jwt-token', user: { id: 1, email } }));
  } else {
    res.writeHead(401);
    res.end(JSON.stringify({ error: 'Invalid credentials' }));
  }
});

describe('API Integration Tests', () => {
  let client: TestClient;

  beforeEach(async () => {
    await server.start();
    client = new TestClient(server.url);
  });

  afterEach(async () => {
    await server.stop();
  });

  it('health check', async () => {
    const res = await client.get('/api/health');
    expect(res.status).toBe(200);
    expect(res.json()).toHaveProperty('status', 'ok');
  });

  it('login success', async () => {
    const res = await client.post('/api/auth/login', {
      body: { email: 'test@example.com', password: 'pass' },
    });
    expect(res.status).toBe(200);
    const data = res.json<{ token: string; user: { id: number; email: string } }>();
    expect(data.token).toBe('jwt-token');
  });

  it('login failure', async () => {
    const res = await client.post('/api/auth/login', {
      body: { email: 'wrong@example.com', password: 'wrong' },
    });
    expect(res.status).toBe(401);
  });
});