home
  • Español
  • English
  • Português
  • Cómo trabajo
  • star Proyectos Destacados
  • Experiencia
  • mail Contacto
arrow_back Volver al proyecto Inicio

Testing del proyecto

Testing · Task Cloud

5 archivos markdown (visión, unitarios, integración, e2e, GitHub Actions).

En esta página Testing · visión 0% expand_more
Testing · visión Testing · unitarios PIN (apps/api/src/auth/pin.util.spec.ts) JWT (apps/api/src/auth/auth.service.spec.ts) Ownership (apps/api/src/users/users.service.spec.ts) Guard (apps/api/src/auth/auth.guard.spec.ts) Web — filtro de estado (apps/web/src/app/core/services/task.service.spec.ts) Web — lista (apps/web/src/app/tabs/tab-list/tab-list.page.spec.ts) Web — instalación (apps/web/src/app/core/services/install.service.spec.ts) Web — formularios y controles Web — tema e idioma Testing · integración Fake de base de datos Contratos que se afirman Qué no cubre esta capa Testing · e2e Specs reales Testing · GitHub Actions Job quality Job e2e Por qué no hay errores de entorno

12-testing.md

Testing · visión

Task Cloud tiene tres capas de prueba más un job de GitHub Actions. Todas se pueden correr en local y en CI sin Neon real (excepto el smoke HTTP opcional pnpm test:api).

CapaRunnerQué cubreComando
Unitario webJasmine + Karma (ChromeHeadless)Componentes y servicios Angular (77 tests en 17 specs)pnpm test:ci
Unitario APIVitestPIN, JWT, guards, ownership (21 tests en 6 ficheros)pnpm test:api
Integración APIVitest + Supertest + @nestjs/testingHTTP real contra Nest, Neon mockeado (9 tests)pnpm test:integration
E2EPlaywright (Chromium)PWA: título, crear tarea, filtro de estado, pestaña Ajustes (4 tests)pnpm test:e2e
Smoke Neon (opcional)apps/api/scripts/test-api.mjsAPI viva + DATABASE_URLpnpm --filter @task-cloud/api test:api

Además, pnpm test:scripts corre 28 tests de node:test sobre los scripts de scripts/. Comprobado el 20 de octubre de 2026: unitarios, integración y scripts pasan en local, y el e2e pasa en CI.

CI (.github/workflows/ci.yml):

  1. Job quality — lint, unit web, unit API, integración API, build:api.
  2. Job e2e — instala Chromium y lanza Playwright (el webServer arranca Angular con CI=true).

No hace falta un Postgres en Actions: la integración sustituye DatabaseService por un fake. El e2e no depende de que /api responda para pintar el header.

Siguientes documentos: unitarios · integración · e2e · CI.

13-testing-unit.md

Testing · unitarios

Los unitarios instancian la clase sin HTTP y sin Neon. En la API se usa Vitest (globals: true). En la web, Jasmine/Karma.

PIN (apps/api/src/auth/pin.util.spec.ts)

El lookup del PIN es HMAC-SHA256 con PIN_PEPPER. El hash persistido es bcrypt, con un fallback SHA-256 de la era anterior.

it('accepts exactly 8 digits', () => {
  expect(isValidPin('12345678')).toBe(true);
  expect(isValidPin('1234567')).toBe(false);
  expect(isValidPin('1234567a')).toBe(false);
});

it('verifies a bcrypt PIN hash', async () => {
  const hash = await hashPin('87654321');
  expect(await verifyPinHash('87654321', hash)).toBe(true);
  expect(await verifyPinHash('00000000', hash)).toBe(false);
});

JWT (apps/api/src/auth/auth.service.spec.ts)

AuthService se construye a mano con un ConfigService falso. Así se firma y se verifica un token sin Nest.

const token = await auth.createAccessToken({
  userId: 42,
  sessionId: 'sid-1',
  expiresAt: new Date(Date.now() + 60_000),
});
const claims = await auth.verifyAccessToken(token);
expect(claims.userId).toBe(42);
expect(claims.sessionId).toBe('sid-1');

extractBearerToken solo acepta el esquema Bearer:

expect(auth.extractBearerToken('Bearer abc.def')).toBe('abc.def');
expect(auth.extractBearerToken('Basic nope')).toBe('');

Ownership (apps/api/src/users/users.service.spec.ts)

Si el actorId no coincide, el servicio lanza NotFoundException (no 403: no se confirma que el recurso existe).

it('hides another user behind 404', () => {
  expect(() => users.getOwnUser(1, 99)).toThrow(NotFoundException);
});

El mismo criterio está en TasksService.updateTaskForUser / deleteTaskForUser.

Guard (apps/api/src/auth/auth.guard.spec.ts)

El Reflector decide si la ruta es @Public(). Sin token y sin @Public(), el guard responde 401.

it('lets public handlers through', async () => {
  const reflector = { getAllAndOverride: () => true } as unknown as Reflector;
  const guard = new AuthGuard(auth, reflector);
  await expect(guard.canActivate(ctx())).resolves.toBe(true);
});

Web — filtro de estado (apps/web/src/app/core/services/task.service.spec.ts)

Ionic Storage se sustituye por un Map. changeFilter() recorre All → Done → Pending.

expect(service.filter()).toBe(StatusEnum.All);
service.changeFilter();
expect(service.filter()).toBe(StatusEnum.Done);

Al guardar, una tarea sin priority queda en medium y tags en []. Los tags se recortan y pasan a minúsculas (" Work " → "work"). El filtro hidrata Ionic Storage:

store.set('filter', StatusEnum.Done);
await service.init();
expect(service.filter()).toBe(StatusEnum.Done);

Web — lista (apps/web/src/app/tabs/tab-list/tab-list.page.spec.ts)

TabListPage se monta con los servicios sustituidos por mocks (TaskService con signals, MatSnackBar, MatDialog, InstallService). Cubre la búsqueda por título, nota y etiqueta; los contadores de cada estado dentro del filtro activo; la prioridad escrita como palabra en la fila; añadir desde el borrador del campo de captura; la edición en línea; y el borrado con Deshacer.

El test de la tarea tachada comprueba que la fila se queda entre las pendientes mientras se dibuja el trazo y pasa a Hechas a los 650 ms. Usa el reloj de Jasmine y no fakeAsync: al final de un fakeAsync se vacían todos los temporizadores, incluido el timeout de carga de los chunks que Ionic pide al pintar la fila por primera vez, y Karma registraría ChunkLoadError.

jasmine.clock().install();
taskServiceMock.tasks.set([makeTask({ id: 1, title: 'Water the plants' })]);

component['toggleTaskState'](1);
expect(ids(component['openTasks']())).toEqual([1]);

jasmine.clock().tick(650);
expect(ids(component['doneTasks']())).toEqual([1]);
jasmine.clock().uninstall();

El botón de instalar se prueba con los tres métodos de InstallService: oculto si no se puede instalar, abre el diálogo del navegador si lo hay, y abre la guía de iOS en otro caso.

Web — instalación (apps/web/src/app/core/services/install.service.spec.ts)

Platform de Ionic se sustituye por un objeto que responde is('ios') / is('pwa'), y el evento de Chromium se simula sobre window:

it('installs through the browser prompt and stops offering it', async () => {
  const service = createService('android');
  const event = offerInstallPrompt('accepted');

  expect(await service.promptInstall()).toBeTrue();
  expect(event.prompt).toHaveBeenCalledTimes(1);
  expect(service.method()).toBe('none');
});

También cubre que el banner propio del navegador se retiene (preventDefault), que tras rechazar se espera un evento nuevo, que appinstalled oculta el botón, que dentro de la app instalada no se ofrece nada y que ocultar el botón se recuerda entre arranques. InstallGuideComponent tiene su propio spec: tres pasos en orden, «Entendido» cierra y «Ocultar el botón de instalar» lo oculta y cierra.

Web — formularios y controles

  • TaskComposerComponent: los detalles quedan plegados hasta que se escribe algo, no añade un título vacío, entrega un borrador recortado con las etiquetas ya parseadas, y tras añadir se vacía y deja el campo listo.
  • TaskEditorComponent: parte de la tarea y enfoca su título, guarda los campos editados y se cierra con Escape.
  • SegmentedControlComponent: radiogroup con nombre y una opción marcada, solo la marcada en el orden de tabulación, selección con clic y con flechas (con vuelta al principio) y contador junto a cada etiqueta.
  • TagFieldComponent: sugiere las etiquetas conocidas, filtra por la que se está escribiendo, no repite las ya escritas y añade la sugerencia lista para la siguiente.
  • Helpers de types/task.ts: etiquetas en minúsculas, sin duplicados y como máximo seis; prioridades en inglés y español con medium por defecto; orden por prioridad y después por fecha.

Web — tema e idioma

ThemeService activa a la vez la paleta oscura de la app y la de Ionic, y ajusta el color de la barra del navegador al de la cabecera. LanguageService actualiza lang en <html> para los lectores de pantalla e ignora idiomas que la app no incluye.

14-testing-integration.md

Testing · integración

Archivo: apps/api/test/app.integration.spec.ts.
Comando: pnpm test:integration.

Levanta Nest de verdad (Test.createTestingModule + configureApp) y habla por HTTP con Supertest. Neon no entra: se hace overrideProvider(DatabaseService).

Antes de importar AppModule se fuerzan las env que validateEnv exige al boot (apps/api/test/setup-env.ts). No se usa ||=: un JWT corto heredado del .env local rompería el arranque.

process.env.DATABASE_URL = 'postgresql://task:cloud@localhost/taskcloud';
process.env.JWT_SECRET = 'integration-jwt-secret-32-chars-ok';
process.env.PIN_PEPPER = 'integration-pin-pepper-32-chars-ok';
process.env.ALLOWED_ORIGINS = 'http://127.0.0.1:4200';

Vitest transpila los decoradores de Nest con SWC (unplugin-swc + decoratorMetadata: true en vitest.integration.config.ts). Sin metadata, Reflector y DatabaseService llegan undefined al TestingModule.

Fake de base de datos

const fakeDb = {
  getSql: () => Object.assign(async () => [], {}) as never,
  ping: async () => undefined,
  ensureAuthSchema: async () => undefined,
};

configureApp aplica el mismo ValidationPipe, filtro, helmet y request-id que producción. Si un test no llama a configureApp, el prefix /api y el pipe no existen — ese es el fallo clásico de e2e Nest.

Contratos que se afirman

it('GET /api/health returns the product payload', async () => {
  const res = await request(app.getHttpServer()).get('/api/health').expect(200);
  expect(res.body).toEqual({ ok: true, service: 'task-cloud-nest-api' });
  expect(res.headers['x-request-id']).toBeTruthy();
});

it('rejects an unauthenticated task list', async () => {
  await request(app.getHttpServer()).get('/api/tasks').expect(401);
});

it('rejects a short PIN at the DTO boundary', async () => {
  await request(app.getHttpServer())
    .post('/api/auth/login')
    .send({ pin: '12' })
    .expect(400);
});

it('rejects extra fields on login (forbidNonWhitelisted)', async () => {
  await request(app.getHttpServer())
    .post('/api/auth/login')
    .send({ pin: '12345678', admin: true })
    .expect(400);
});

Un PIN válido pero sin filas en el fake ([]) produce 401 — el login llega al servicio, no se queda en el DTO.

También se afirma GET /api (mismo payload que health), GET /api/auth/me y POST /api/tasks sin Bearer (401), y el header Helmet x-content-type-options: nosniff.

Qué no cubre esta capa

El smoke apps/api/scripts/test-api.mjs sí habla con Neon real (register → tasks → logout). No corre en GitHub Actions para no depender de secretos. Úsalo en local con pnpm api + pnpm --filter @task-cloud/api test:api.

15-testing-e2e.md

Testing · e2e

Playwright en la raíz del monorepo. Config: playwright.config.ts. Specs: e2e/home.spec.ts.

pnpm test:e2e

En CI, Playwright arranca Angular solo:

webServer: {
  command: 'pnpm --filter @task-cloud/web start',
  url: 'http://127.0.0.1:4200',
  env: { CI: 'true', API_BASE_URL: '/api' },
}

CI=true hace que apps/web/scripts/set-env.js genere environment.local.ts sin copiar .env (no hay secretos en Actions).

Specs reales

async function openPwa(page: Page) {
  await page.goto('/');
  await expect(page.locator('ion-tab-bar')).toBeVisible({ timeout: 20_000 });
}

test('renders the product title in the header', async ({ page }) => {
  await openPwa(page);
  await expect(page.locator('h1.header-title')).toContainText(/Task Cloud/i);
});

test('shows the All filter control once there is a task', async ({ page }) => {
  await openPwa(page);
  const title = page.locator('form.task-form .composer-title');
  await title.fill('E2E filter');
  await title.press('Enter');
  await expect(page.getByRole('radio', { name: /All/ })).toBeVisible();
});

test('can open the Options tab', async ({ page }) => {
  await openPwa(page);
  await page.locator('ion-tab-button[tab="options"]').click();
  await expect(page).toHaveURL(/options/i);
  await expect(page.locator('app-tab-options h1')).toContainText(/settings|ajustes/i);
});

test('creates a local task from the capture field', async ({ page }) => {
  await openPwa(page);
  const form = page.locator('form.task-form');
  await expect(form).toBeVisible();
  await form.locator('.composer-title').fill('E2E milk');
  await form.evaluate((el) => (el as HTMLFormElement).requestSubmit());

  await expect(page.locator('.task-title').filter({ hasText: 'E2E milk' })).toBeVisible({
    timeout: 15_000,
  });
});

Ionic no expone role="tab" de forma fiable en ion-tab-button, por eso el locator usa el atributo tab="options" (la ruta sigue siendo /tabs/options, aunque la pestaña se llame Ajustes). El título se afirma con h1.header-title (el h1 dentro de ion-toolbar no siempre entra en el árbol de heading). El filtro de estado es un control segmentado con role="radiogroup", así que se busca por getByRole('radio'); solo aparece cuando hay al menos una tarea.

La creación e2e es offline: escribe en el campo de captura de la cabecera y afirma .task-title. No hace falta Neon.

Esta suite también pilla regresiones de arranque:

  • provideTranslateHttpLoader() va en providers de AppModule, no en TranslateModule.forRoot({ loader }). Si no, NG0201: No provider found for TranslateLoader.
  • En pnpm, preserveSymlinks: true en angular.json + PathLocationStrategy evitan NG0203 al inyectar LocationStrategy (la PWA quedaba en <ion-app></ion-app> vacío).

La PWA pinta lista y ajustes offline. No se afirma sync/PIN en e2e de CI (eso es el smoke Neon).

16-testing-ci.md

Testing · GitHub Actions

Workflow: .github/workflows/ci.yml. Se dispara en push y pull_request a main.

Job quality

- name: Lint web
  run: pnpm lint:ci
- name: Run web unit tests
  run: pnpm test:ci
- name: Run API unit tests
  run: pnpm test:api
- name: Run API integration tests
  run: pnpm test:integration
- name: Build Nest API
  run: pnpm build:api

pnpm/action-setup@v4 lee packageManager: pnpm@11.21.0. Node 22 + cache de pnpm. pnpm install --frozen-lockfile falla si el lock no está commiteado.

Job e2e

- name: Install Playwright Chromium
  run: pnpm exec playwright install --with-deps chromium
- name: Run end-to-end tests
  run: pnpm test:e2e
  env:
    CI: true
    API_BASE_URL: /api

Chromium se instala en el runner; no se sube al repo. retries: 2 solo en CI (playwright.config.ts).

Por qué no hay errores de entorno

RiesgoMitigación
validateEnv exige secretostest/setup-env.ts asigna JWT/PIN de 32+ caracteres antes de importar AppModule
Decoradores Nest sin metadataunplugin-swc con decoratorMetadata: true en vitest.integration.config.ts
Build nativo de SWCpnpm-workspace.yaml → allowBuilds["@swc/core"] = true
Neon no está en ActionsDatabaseService mockeado
set-env.js pide .envCI=true genera environments y sale 0
Angular tarda en servirwebServer.timeout: 180000 y toBeVisible({ timeout: 20000 })
Lockfile desfasado--frozen-lockfile + commit de pnpm-lock.yaml

Si un job rojo es flaky de Playwright, el primer sitio a mirar es el locator de Ionic (preferir ion-tab-button[tab=…] a getByRole('tab')).

En esta página

Testing · visión Testing · unitarios PIN (apps/api/src/auth/pin.util.spec.ts) JWT (apps/api/src/auth/auth.service.spec.ts) Ownership (apps/api/src/users/users.service.spec.ts) Guard (apps/api/src/auth/auth.guard.spec.ts) Web — filtro de estado (apps/web/src/app/core/services/task.service.spec.ts) Web — lista (apps/web/src/app/tabs/tab-list/tab-list.page.spec.ts) Web — instalación (apps/web/src/app/core/services/install.service.spec.ts) Web — formularios y controles Web — tema e idioma Testing · integración Fake de base de datos Contratos que se afirman Qué no cubre esta capa Testing · e2e Specs reales Testing · GitHub Actions Job quality Job e2e Por qué no hay errores de entorno

0% leído

folder_zip Descargar todos (ZIP)
Pregunta sobre Cristian Asistente con IA, solo datos públicos

Es un asistente automático y puede equivocarse. Para algo importante, escribe a Cristian.