workspace/projects/gamilit/orchestration/directivas/ESTANDARES-TESTING-API.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- Configure workspace Git repository with comprehensive .gitignore
- Add Odoo as submodule for ERP reference code
- Include documentation: SETUP.md, GIT-STRUCTURE.md
- Add gitignore templates for projects (backend, frontend, database)
- Structure supports independent repos per project/subproject level

Workspace includes:
- core/ - Reusable patterns, modules, orchestration system
- projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.)
- knowledge-base/ - Reference code and patterns (includes Odoo submodule)
- devtools/ - Development tools and templates
- customers/ - Client implementations template

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:44:23 -06:00

20 KiB

ESTÁNDARES DE TESTING PARA APIs

Versión: 1.0 Fecha: 2025-11-23 Autor: Architecture-Analyst Motivación: Garantizar que bugs de configuración de rutas API se detecten en fase de testing


PROBLEMA IDENTIFICADO

El bug de duplicación de rutas (/api/api/) no fue detectado porque:

  1. Falta de tests de integración que validen URLs completas
  2. Tests unitarios insuficientes que no validaban endpoints
  3. Ausencia de tests E2E que ejecuten requests HTTP reales
  4. Falta de validación en Network tab durante testing manual
  5. Mocks incorrectos que ocultaban problemas de configuración

ESTÁNDARES Y MEJORES PRÁCTICAS

1. PIRÁMIDE DE TESTING PARA APIs

        /\
       /  \        E2E Tests (10%)
      /____\       - Requests HTTP reales
     /      \      - Validación de URLs completas
    /        \     - Flujos end-to-end
   /----------\
  /            \   Integration Tests (30%)
 /              \  - Servicios + API Client
/________________\ - Controladores + Servicios

                   Unit Tests (60%)
                   - Validación de endpoints
                   - Lógica de negocio
                   - DTOs y validaciones

2. UNIT TESTS

2.1. Tests de Servicios (Frontend)

Archivo de test: apps/frontend/web/src/services/healthService.spec.ts

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { apiClient } from '@/lib/apiClient';
import { healthService } from './healthService';

describe('healthService', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('checkHealth', () => {
    it('should call correct endpoint without /api prefix', async () => {
      const mockResponse = {
        data: { status: 'ok', timestamp: new Date().toISOString() },
      };

      const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue(mockResponse);

      await healthService.checkHealth();

      // ✅ CRÍTICO: Validar que endpoint NO incluye /api
      expect(getSpy).toHaveBeenCalledWith('/health');
      expect(getSpy).not.toHaveBeenCalledWith('/api/health');
    });

    it('should return health status', async () => {
      const mockData = { status: 'ok', timestamp: '2025-11-23T00:00:00Z' };
      vi.spyOn(apiClient, 'get').mockResolvedValue({ data: mockData });

      const result = await healthService.checkHealth();

      expect(result).toEqual(mockData);
      expect(result.status).toBe('ok');
    });

    it('should handle errors', async () => {
      vi.spyOn(apiClient, 'get').mockRejectedValue(
        new Error('Network error')
      );

      await expect(healthService.checkHealth()).rejects.toThrow();
    });
  });

  describe('checkDatabase', () => {
    it('should call correct endpoint', async () => {
      const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue({
        data: { database: 'connected' },
      });

      await healthService.checkDatabase();

      expect(getSpy).toHaveBeenCalledWith('/health/database');
    });
  });
});

2.2. Tests de Servicios con Parámetros

describe('userService', () => {
  describe('findById', () => {
    it('should call endpoint with correct ID parameter', async () => {
      const userId = '123';
      const mockUser = { id: userId, name: 'John Doe' };

      const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue({
        data: mockUser,
      });

      await userService.findById(userId);

      // ✅ Validar template literal correcto
      expect(getSpy).toHaveBeenCalledWith(`/users/${userId}`);
      expect(getSpy).not.toHaveBeenCalledWith('/users/123');
    });
  });

  describe('searchUsers', () => {
    it('should call endpoint with query parameters', async () => {
      const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue({
        data: [],
      });

      await userService.searchUsers('john', 2);

      // ✅ Validar que query params se pasan correctamente
      expect(getSpy).toHaveBeenCalledWith('/users', {
        params: { q: 'john', page: 2 },
      });
    });
  });
});

2.3. Tests de Controladores (Backend)

Archivo de test: apps/backend/src/modules/health/health.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';

describe('HealthController', () => {
  let controller: HealthController;
  let service: HealthService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [HealthController],
      providers: [
        {
          provide: HealthService,
          useValue: {
            checkHealth: jest.fn(),
            checkDatabase: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = module.get<HealthController>(HealthController);
    service = module.get<HealthService>(HealthService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  describe('checkHealth', () => {
    it('should return health status', async () => {
      const mockResult = { status: 'ok', timestamp: new Date() };
      jest.spyOn(service, 'checkHealth').mockResolvedValue(mockResult);

      const result = await controller.checkHealth();

      expect(result).toEqual(mockResult);
      expect(service.checkHealth).toHaveBeenCalled();
    });
  });

  describe('checkDatabase', () => {
    it('should return database status', async () => {
      const mockResult = { database: 'connected' };
      jest.spyOn(service, 'checkDatabase').mockResolvedValue(mockResult);

      const result = await controller.checkDatabase();

      expect(result).toEqual(mockResult);
    });
  });
});

3. INTEGRATION TESTS

3.1. Tests de Servicios con API Client Real

// apps/frontend/web/src/services/__integration__/healthService.integration.spec.ts

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import axios from 'axios';
import { healthService } from '../healthService';

// Mock server para tests de integración
let mockServer: any;

beforeAll(async () => {
  // Setup mock server (usando MSW o similar)
  mockServer = setupMockServer();
  mockServer.listen();
});

afterAll(() => {
  mockServer.close();
});

describe('healthService Integration Tests', () => {
  it('should make actual HTTP request to correct URL', async () => {
    // Mock de respuesta del servidor
    mockServer.use(
      rest.get('http://localhost:3000/api/health', (req, res, ctx) => {
        return res(ctx.json({ status: 'ok' }));
      })
    );

    const result = await healthService.checkHealth();

    expect(result.status).toBe('ok');
  });

  it('should NOT call /api/api/health', async () => {
    // Este endpoint NO debe ser llamado
    mockServer.use(
      rest.get('http://localhost:3000/api/api/health', (req, res, ctx) => {
        // Si este endpoint es llamado, el test debe fallar
        throw new Error('Invalid endpoint /api/api/health was called');
      })
    );

    // No debe lanzar error
    await expect(healthService.checkHealth()).resolves.toBeDefined();
  });
});

3.2. Tests de Controladores (Backend E2E)

Archivo de test: apps/backend/test/health.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('HealthController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();

    // ✅ CRÍTICO: Configurar prefijo global igual que en main.ts
    app.setGlobalPrefix('api');

    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('/api/health (GET)', () => {
    it('should return health status', () => {
      return request(app.getHttpServer())
        .get('/api/health')  // ✅ Ruta completa con /api
        .expect(200)
        .expect((res) => {
          expect(res.body).toHaveProperty('status');
          expect(res.body.status).toBe('ok');
        });
    });

    it('should have correct content-type', () => {
      return request(app.getHttpServer())
        .get('/api/health')
        .expect('Content-Type', /json/)
        .expect(200);
    });
  });

  describe('/api/health/database (GET)', () => {
    it('should return database status', () => {
      return request(app.getHttpServer())
        .get('/api/health/database')
        .expect(200)
        .expect((res) => {
          expect(res.body).toHaveProperty('database');
        });
    });
  });

  describe('Invalid routes', () => {
    it('should NOT respond to /api/api/health', () => {
      // ✅ Validar que ruta duplicada NO existe
      return request(app.getHttpServer())
        .get('/api/api/health')
        .expect(404);
    });

    it('should NOT respond to /health without /api prefix', () => {
      return request(app.getHttpServer())
        .get('/health')
        .expect(404);
    });
  });
});

4. E2E TESTS

4.1. Tests con Playwright

// apps/frontend/web/e2e/health.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Health Check Page', () => {
  test.beforeEach(async ({ page }) => {
    // Interceptar requests para validar URLs
    page.on('request', (request) => {
      const url = request.url();

      // ✅ CRÍTICO: Validar que NO hay /api/api/
      if (url.includes('/api/api/')) {
        throw new Error(`Invalid URL detected: ${url}`);
      }

      console.log(`Request: ${request.method()} ${url}`);
    });

    await page.goto('http://localhost:5173/health');
  });

  test('should load health status', async ({ page }) => {
    // Esperar que el request se complete
    const response = await page.waitForResponse(
      (response) => response.url().includes('/api/health') && response.status() === 200
    );

    expect(response.ok()).toBeTruthy();

    // Validar que la URL es correcta
    expect(response.url()).toBe('http://localhost:3000/api/health');
    expect(response.url()).not.toContain('/api/api/');
  });

  test('should display health status on page', async ({ page }) => {
    await page.waitForSelector('[data-testid="health-status"]');

    const status = await page.textContent('[data-testid="health-status"]');
    expect(status).toBe('ok');
  });

  test('should handle API errors', async ({ page }) => {
    // Mock error response
    await page.route('**/api/health', (route) => {
      route.fulfill({
        status: 500,
        body: JSON.stringify({ error: 'Internal server error' }),
      });
    });

    await page.goto('http://localhost:5173/health');

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
  });
});

4.2. Tests con Cypress

// cypress/e2e/health.cy.ts

describe('Health Check API', () => {
  beforeEach(() => {
    // Interceptar requests
    cy.intercept('GET', '/api/health').as('healthCheck');
  });

  it('should call correct API endpoint', () => {
    cy.visit('/health');

    cy.wait('@healthCheck').then((interception) => {
      // ✅ Validar URL correcta
      expect(interception.request.url).to.include('/api/health');
      expect(interception.request.url).to.not.include('/api/api/');

      // ✅ Validar respuesta
      expect(interception.response.statusCode).to.eq(200);
      expect(interception.response.body).to.have.property('status', 'ok');
    });
  });

  it('should display health status', () => {
    cy.visit('/health');

    cy.get('[data-testid="health-status"]')
      .should('be.visible')
      .and('contain', 'ok');
  });
});

5. NETWORK TAB VALIDATION

5.1. Manual Testing Checklist

Antes de marcar feature como completa:

  1. Abrir DevTools

    Chrome/Edge: F12 o Ctrl+Shift+I
    Firefox: F12 o Ctrl+Shift+K
    Safari: Cmd+Option+I
    
  2. Ir a Network Tab

    • Activar "Preserve log"
    • Filtrar por "Fetch/XHR"
    • Limpiar logs existentes
  3. Ejecutar la acción que hace el request

    • Click en botón
    • Navegar a página
    • Hacer submit de form
  4. Validar en Network Tab:

    ✅ Checklist de validación:
    
    Request URL:
    [ ] Debe ser: http://localhost:3000/api/health
    [ ] NO debe ser: http://localhost:3000/api/api/health
    [ ] NO debe ser: http://localhost:3000/health
    
    Request Method:
    [ ] GET, POST, PUT, DELETE, etc. (correcto para la acción)
    
    Status Code:
    [ ] 200 OK (para GET exitoso)
    [ ] 201 Created (para POST exitoso)
    [ ] 204 No Content (para DELETE exitoso)
    [ ] NO 404 Not Found
    [ ] NO 500 Internal Server Error
    
    Request Headers:
    [ ] Content-Type: application/json
    [ ] Authorization: Bearer ... (si requiere auth)
    
    Response:
    [ ] Body tiene estructura esperada
    [ ] NO hay errores en response
    
  5. Tomar Screenshot

    • Guardar screenshot del Network tab
    • Adjuntar al PR como evidencia

5.2. Automated Network Validation

// apps/frontend/web/src/tests/helpers/networkValidator.ts

/**
 * Validates that a URL does not have duplicate /api/ prefixes
 */
export function validateApiUrl(url: string): void {
  if (url.includes('/api/api/')) {
    throw new Error(
      `Invalid API URL detected: ${url}\n` +
      `URL contains duplicate /api/ prefix.\n` +
      `This indicates incorrect endpoint configuration.`
    );
  }

  if (!url.includes('/api/')) {
    console.warn(
      `Warning: URL does not contain /api/ prefix: ${url}\n` +
      `This might be a non-API request or incorrect configuration.`
    );
  }
}

/**
 * Setup global request interceptor for validation
 */
export function setupNetworkValidation(): void {
  const originalFetch = window.fetch;

  window.fetch = function(...args) {
    const url = args[0] as string;

    if (url.startsWith('http://') || url.startsWith('https://')) {
      validateApiUrl(url);
    }

    return originalFetch.apply(this, args);
  };
}

// Usar en tests
beforeAll(() => {
  setupNetworkValidation();
});

6. MOCK SERVER TESTING

6.1. MSW (Mock Service Worker)

// apps/frontend/web/src/mocks/handlers.ts

import { rest } from 'msw';

export const handlers = [
  rest.get('http://localhost:3000/api/health', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        status: 'ok',
        timestamp: new Date().toISOString(),
      })
    );
  }),

  rest.get('http://localhost:3000/api/health/database', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        database: 'connected',
        tables: 42,
      })
    );
  }),

  // ✅ Handler para detectar requests inválidos
  rest.get('http://localhost:3000/api/api/*', (req, res, ctx) => {
    console.error(`[MSW] Invalid request to /api/api/: ${req.url}`);

    return res(
      ctx.status(404),
      ctx.json({
        error: 'Invalid endpoint',
        message: 'Endpoint contains duplicate /api/ prefix',
      })
    );
  }),
];
// apps/frontend/web/src/mocks/server.ts

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// Setup en tests
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

6.2. Tests con MSW

// apps/frontend/web/src/services/healthService.msw.spec.ts

import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '@/mocks/server';
import { rest } from 'msw';
import { healthService } from './healthService';

describe('healthService with MSW', () => {
  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());

  it('should call correct endpoint', async () => {
    const result = await healthService.checkHealth();

    expect(result).toEqual({
      status: 'ok',
      timestamp: expect.any(String),
    });
  });

  it('should handle server errors', async () => {
    server.use(
      rest.get('http://localhost:3000/api/health', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ error: 'Server error' }));
      })
    );

    await expect(healthService.checkHealth()).rejects.toThrow();
  });

  it('should fail if calling /api/api/health', async () => {
    // Modificar temporalmente el servicio para probar endpoint incorrecto
    const originalGet = apiClient.get;

    apiClient.get = vi.fn().mockImplementation((url) => {
      return originalGet(`/api${url}`);  // Simular bug de duplicación
    });

    await expect(healthService.checkHealth()).rejects.toThrow();

    // Restaurar
    apiClient.get = originalGet;
  });
});

7. COVERAGE REQUIREMENTS

7.1. Minimum Coverage

Requisitos mínimos de cobertura:

Unit Tests:
  - Servicios (Frontend): 90%
  - Controladores (Backend): 85%
  - Lógica de negocio: 95%

Integration Tests:
  - Flujos críticos: 80%
  - Endpoints principales: 100%

E2E Tests:
  - User flows críticos: 100%
  - Happy paths: 100%
  - Error handling: 70%

7.2. Coverage Configuration

// vitest.config.ts (Frontend)

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.ts', 'src/**/*.tsx'],
      exclude: [
        'src/**/*.spec.ts',
        'src/**/*.test.ts',
        'src/mocks/**',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
});
// jest.config.js (Backend)

module.exports = {
  coverageThreshold: {
    global: {
      branches: 75,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.spec.ts',
    '!src/**/*.e2e-spec.ts',
  ],
};

CHECKLIST DE VALIDACIÓN

Pre-Commit Checklist (Developer)

  • Unit tests escritos y pasando
  • Integration tests para endpoints críticos
  • Tests validan endpoints correctos (sin /api en services)
  • Coverage mínimo alcanzado
  • Probado en Network tab del navegador
  • No hay errores en consola
  • Todos los tests pasan localmente

Pre-Merge Checklist (Reviewer)

  • Revisar que tests existen para nuevos endpoints
  • Revisar que tests validan URLs correctas
  • Revisar que hay tests E2E si es feature crítica
  • Ejecutar tests localmente
  • Validar coverage report
  • Probar manualmente en navegador

Pre-Deploy Checklist (QA)

  • Smoke tests en staging
  • Validar endpoints en Network tab
  • Tests E2E en staging
  • Performance tests
  • Security tests
  • Regression tests

HERRAMIENTAS DE AUTOMATIZACIÓN

1. CI/CD Pipeline

# .github/workflows/test.yml

name: Test Suite

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Install dependencies
        run: npm install

      - name: Run linter
        run: npm run lint

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Check coverage
        run: npm run test:coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v2
        with:
          files: ./coverage/coverage-final.json

2. Pre-commit Hook

#!/bin/bash
# .git/hooks/pre-commit

echo "Running tests before commit..."

# Run unit tests
npm run test:unit

if [ $? -ne 0 ]; then
  echo "Unit tests failed. Commit aborted."
  exit 1
fi

# Run linter
npm run lint

if [ $? -ne 0 ]; then
  echo "Linter failed. Commit aborted."
  exit 1
fi

echo "Tests passed. Proceeding with commit."

REFERENCIAS


Uso: Referencia obligatoria para escribir tests de APIs Validación: Obligatoria antes de merge y deploy Actualización: Mantener sincronizado con cambios en arquitectura