Files
impuestospy/e2e/ingestion.spec.ts
T
MichilisandClaude Opus 5 6620650e9e phase-5: declarations, from generated numbers to a PDF you file yourself
Formulario 120 and 515 assembly, the review screen, approval, the PDF, and the
guided checklist that walks the user through presenting it in Marangatu.

Approval is a promise about what the user actually saw: the numbers are
recomputed on the way in, and if the documents moved since the declaration was
generated the answer is a 409 with the fresh figures stored as a draft, not a
silent approval of numbers nobody read. An approved declaration is never
regenerated or invalidated.

Editing, confirming, rejecting or reclassifying a document flips any ready
declaration covering its period back to draft, which is what the dashboard
reads to stop offering it for review.

The PDF is rendered once and cached against the declaration, so two downloads
are byte for byte the same document; every write that changes the numbers
clears the cache. Approval pre-renders it, and the route renders on demand, so
a failed job costs a wait rather than a missing file. It stays Spanish in both
locales, like the form it mirrors, and carries a BORRADOR watermark until it is
approved.

The seeded declarations are computed through the same code path the product
uses, so their figures agree with the documents behind them.

Three defects the screenshots caught: the floating scan button swallowed the
tap meant for Aprobar on a phone, the summary breakdown reused labels meant for
other screens, and five tab labels collided at 390px.

Tests that can avoid depending on a fresh seed now do; the ones that cannot say
so in the assertion rather than timing out. db:reset warns that the API has to
be stopped first, having learned that the hard way.

293 vitest tests, 59 Playwright tests, rules coverage still 100%, typecheck and
lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 05:50:29 +00:00

149 lines
6.9 KiB
TypeScript

import { fileURLToPath } from 'node:url';
import { es } from '@impuestos/i18n';
import { expect, test, type Page } from '@playwright/test';
/**
* Golden paths 2 and 3 (SPEC.md section 13). Both run through the real UI: the browser
* decodes the fixture's QR itself, uploads the image, and the document comes back through
* the same pipeline a phone would use.
*/
// Serial: these tests confirm and reject out of one account's bandeja, so running two of
// them at once has each pulling cards out from under the other.
test.describe.configure({ mode: 'serial' });
const fixture = (name: string) =>
fileURLToPath(new URL(`../fixtures/${name}.png`, import.meta.url));
async function signIn(page: Page): Promise<void> {
await page.goto('/es/login');
await page.getByLabel(es['auth.register.email']).fill('maria@demo.local');
await page.getByLabel(es['auth.login.password']).fill('demo-maria-1');
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
await expect(page).toHaveURL(/\/es\/inicio$/);
}
/**
* These confirm and reject documents out of one shared demo account, so running them in
* two projects at once has the two workers racing for the same card. They run on desktop
* only; bandeja rendering on a phone viewport is covered by bandeja.spec.ts, which reads
* and never mutates.
*/
test.describe('golden path 2: scan a QR comprobante and confirm it', () => {
test.beforeEach(() => {
test.skip(test.info().project.name !== 'desktop', 'mutates shared seed data');
});
test('reads the fixture, then confirms it out of the bandeja', async ({ page }) => {
await signIn(page);
await page.goto('/es/escanear');
// "Subir archivo" is the same code path as the camera: decode, then upload.
await page.locator('input[type="file"]').setInputFiles(fixture('factura-qr-supermercado'));
// Whether this is the first scan of the fixture or a repeat depends on what the
// stack has seen before, and path 2 is about the data, not about which of the two
// headings appears. The duplicate case has its own test below.
await expect(page.getByText(es['scan.registered'])).toBeVisible({ timeout: 20_000 });
// A QR carries the emitter's RUC but not its name, so that is what is shown.
await expect(page.getByText('RUC 80011223')).toBeVisible();
await expect(page.getByText('Gs. 385.000')).toBeVisible();
// Exact: the duplicate note also names the date, in a sentence.
await expect(page.getByText('14 de agosto', { exact: true })).toBeVisible();
await page.getByRole('link', { name: es['scan.goToBandeja'] }).click();
await expect(page).toHaveURL(/\/es\/bandeja$/);
// Confirming empties the bandeja, so a suite run consumes what it needs. The reset in
// the README is the precondition; this says so rather than timing out on a locator.
const card = page.getByTestId('bandeja-card');
await expect(
card,
'the bandeja is empty: run pnpm db:reset with the stack stopped before the suite',
).toBeVisible();
const emitter = (await card.locator('p').first().textContent()) ?? '';
await page.getByRole('button', { name: es['bandeja.confirm'] }).click();
// The confirmed card leaves the stack.
await expect(async () => {
const next = await page.getByTestId('bandeja-card').locator('p').first().textContent();
expect(next).not.toBe(emitter);
}).toPass({ timeout: 10_000 });
});
// CONTRACTS.md 5.1, through the UI this time. Scanning twice inside the test makes the
// second outcome deterministic whatever the stack has seen before.
test('scanning the same fixture twice says so instead of duplicating it', async ({ page }) => {
await signIn(page);
await page.goto('/es/escanear');
await page.locator('input[type="file"]').setInputFiles(fixture('factura-qr-farmacia'));
await expect(page.getByText(es['scan.registered'])).toBeVisible({ timeout: 20_000 });
await page.goto('/es/escanear');
await page.locator('input[type="file"]').setInputFiles(fixture('factura-qr-farmacia'));
await expect(page.getByRole('heading', { name: es['scan.duplicate.title'] })).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText(es['scan.duplicate.body'].split('{')[0] as string)).toBeVisible();
});
test('a photo with no QR sends you to the manual form with the file attached', async ({ page }) => {
await signIn(page);
await page.goto('/es/escanear');
await page.locator('input[type="file"]').setInputFiles(fixture('factura-sin-qr-ferreteria'));
await expect(page).toHaveURL(/\/es\/comprobantes\/nuevo\?fileId=/, { timeout: 20_000 });
await expect(page.getByText(es['scan.needsManual'])).toBeVisible();
});
});
test.describe('golden path 3: enter a factura by hand and change its category', () => {
test.beforeEach(() => {
test.skip(test.info().project.name !== 'desktop', 'mutates shared seed data');
});
test('saves it, classifies it, and takes a correction', async ({ page }) => {
await signIn(page);
await page.goto('/es/comprobantes/nuevo');
// The dedupe key is the issuer, the date and the total, not the name, so the total
// has to differ per run or this correctly merges into a previous run's document.
const emitter = `PANADERIA PRUEBA ${Date.now()}`;
const total = 170_000 + (Date.now() % 9_000);
await page.getByLabel(es['manual.emitterRuc']).fill('80022114-1');
await page.getByLabel(es['manual.emitterName']).fill(emitter);
await page.getByLabel(es['manual.total']).fill(String(total));
await page.getByRole('button', { name: es['manual.submit'] }).click();
await expect(page).toHaveURL(/\/es\/bandeja$/, { timeout: 15_000 });
// PANADERIA is an alimentacion keyword, so that is what the rules suggested.
const card = page.getByTestId('bandeja-card');
await expect(card).toContainText(emitter);
await expect(card).toContainText(es['categories.alimentacion']);
// Correct it to Salud, which is the user overriding the classifier.
await page.getByRole('button', { name: es['scan.result.changeCat'] }).click();
await page.getByRole('button', { name: es['categories.salud'] }).click();
await expect(card).toContainText(es['categories.salud']);
await page.getByRole('button', { name: es['bandeja.confirm'] }).click();
// The correction survives into the document detail.
await page.goto('/es/comprobantes');
await page.getByRole('link').filter({ hasText: emitter }).first().click();
await expect(page.getByTestId('document-category')).toHaveText(es['categories.salud']);
});
test('refuses a RUC whose check digit does not match', async ({ page }) => {
await signIn(page);
await page.goto('/es/comprobantes/nuevo');
await page.getByLabel(es['manual.emitterRuc']).fill('4123456-9');
await expect(page.getByText(es['landing.invalidDoc'])).toBeVisible();
await expect(page.getByRole('button', { name: es['manual.submit'] })).toBeDisabled();
});
});