feat: update TypeScript configurations and add frontend testing documentation

This commit is contained in:
David Arranz 2026-08-19 10:39:15 +02:00
parent 080dcaaa61
commit 01fba79e38
31 changed files with 254 additions and 58 deletions

View File

@ -2,6 +2,7 @@
"extends": "@repo/typescript-config/express.json",
"compilerOptions": {
"baseUrl": ".",
"noEmit": true,
"allowImportingTsExtensions": true,
"lib": ["ES2022"],

View File

@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
@ -16,6 +17,7 @@
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"ignoreDeprecations": "6.0",
"jsx": "react-jsx",
/* Linting */

View File

@ -2,6 +2,8 @@
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"resolveJsonModule": true,
"esModuleInterop": true,
"paths": {

View File

@ -53,6 +53,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
- [README de `modules/auth`](../modules/auth/README.md)
- [API pública de selección y alta rápida de clientes](./customers/customer-selection-and-quick-create.md)
- [Tests HTTP backend](./testing/backend-http-integration.md)
- [Harness frontend con `node:test`](./testing/frontend-node-test.md)
#### Guías y estándares

View File

@ -0,0 +1,76 @@
# Tests frontend con `node:test`
El harness frontend vigente del ERP usa:
- `node:test` como runner
- `tsx` como transpiler
- renderizado en Node puro
- `react-dom/server` para tests React sin DOM real
No existe infraestructura activa de `vitest`, `jest`, `jsdom` ni `happy-dom` en el monorepo.
## Requisitos
Ejecuta los tests en Ubuntu desde la raíz del repo:
```bash
cd /home/rodax/Documentos/uecko-erp
node --version
pnpm --version
```
El runtime esperado del repo es Node `>= 24`.
## Comandos canónicos
### Test React individual
```bash
cd /home/rodax/Documentos/uecko-erp
pnpm test:frontend -- modules/customer-invoices/src/web/proformas/update/ui/components/proforma-update-help-popover.test.tsx
```
### Tests frontend de un módulo
```bash
cd /home/rodax/Documentos/uecko-erp
pnpm test:frontend -- modules/customer-invoices/src/web/**/*.test.tsx
```
### Tests frontend de `apps/web`
```bash
cd /home/rodax/Documentos/uecko-erp
pnpm test:frontend -- apps/web/src/**/*.test.tsx
```
### Test TypeScript puro
```bash
cd /home/rodax/Documentos/uecko-erp
pnpm test:unit -- packages/rdx-utils/src/helpers/__tests__/maybe.test.ts
```
## Mocking
Los tests frontend que necesitan mocks de módulos deben usar `mock.module()` de `node:test`.
Eso obliga a ejecutar el runner con:
```bash
node --experimental-test-module-mocks --import tsx --test
```
Por eso el script oficial es `pnpm test:frontend`.
## Qué no hacer
- No ejecutar tests React con `pnpm dlx tsx --test` si el test usa `mock.module()`.
- No asumir `jsdom` o `@testing-library/react` si el test actual solo necesita SSR.
- No importar barrels amplios si el test solo necesita un componente o helper local.
## Criterios prácticos
- Usa `pnpm test:unit` para tests `.ts` o `.tsx` que no dependan de `mock.module()`.
- Usa `pnpm test:frontend` cuando el test React necesite `mock.module()`.
- Si un componente necesita traducciones, mockea `../../../../i18n` o el helper local antes de arrancar el `manifest` del módulo.

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/auth/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/catalogs/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/companies/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/core/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { SequelizeIssuedInvoiceV2DomainMapper } from "./sequelize-issued-invoice-v2-domain.mapper";
@ -25,7 +26,9 @@ type PaymentTermRoundTripReadResult = {
};
const buildMapper = () => {
const mapper = new SequelizeIssuedInvoiceV2DomainMapper({} as never) as unknown as {
const mapper = new SequelizeIssuedInvoiceV2DomainMapper({
taxCatalog: {},
} as never) as unknown as {
_itemsMapper: {
mapToPersistenceArray: () => ReturnType<typeof Result.ok<[]>>;
};
@ -267,6 +270,82 @@ test("rehidrata el snapshot persistido histórico sin depender del catálogo viv
]);
});
test("normaliza verifactu_effective_at Date a YYYY-MM-DD al rehidratar", () => {
const mapper = buildMapper();
const raw = {
verifactu_effective_at: new Date("2027-01-12T00:00:00.000Z"),
} as {
verifactu_effective_at: Date;
};
const result = mapper.mapToDomain.bind(mapper) as unknown;
assert.ok(result);
const effectiveAt = mapper as unknown as {
_mapAttributesToDomain: (
source: unknown,
params: { errors: Array<{ message: string; path: string; value?: unknown }> }
) => { hasVerifactuSnapshots: boolean };
};
const errors: Array<{ message: string; path: string; value?: unknown }> = [];
const attributes = effectiveAt._mapAttributesToDomain(
{
id: "11111111-1111-4111-8111-111111111111",
company_id: "22222222-2222-4222-8222-222222222222",
customer_id: "33333333-3333-4333-8333-333333333333",
source_proforma_id: "44444444-4444-4444-8444-444444444444",
document_series_id: null,
status: "issued",
invoice_series_code: "F26",
invoice_number: "173",
invoice_reference: "F260173",
invoice_date: "2026-08-18",
operation_date: "2026-08-06",
language_code: "es",
currency_code: "EUR",
reference: null,
description: "Factura emitida",
notes: null,
payment_term_id: null,
payment_term_name: null,
payment_term_description: null,
payment_term_snapshot_json: null,
payment_method_id: "55555555-5555-4555-8555-555555555555",
payment_method_name: "Transferencia",
payment_method_description: "Transferencia",
tax_regime_code: "04",
tax_regime_description: "04: Régimen especial del oro de inversión.",
subtotal_amount_value: 10000,
items_discount_amount_value: 0,
global_discount_percentage_value: 0,
global_discount_amount_value: 0,
total_discount_amount_value: 0,
taxable_amount_value: 10000,
iva_amount_value: 2100,
rec_amount_value: 0,
retention_amount_value: 0,
taxes_amount_value: 2100,
total_amount_value: 12100,
verifactu_profile_status: "obligated_with_verifactu",
uses_verifactu: true,
verifactu_effective_at: raw.verifactu_effective_at,
},
{ errors }
) as {
hasVerifactuSnapshots: boolean;
};
assert.equal(attributes.hasVerifactuSnapshots, true);
const utcDateResult = UtcDate.createFromISO(
raw.verifactu_effective_at.toISOString().slice(0, 10)
);
assert.equal(utcDateResult.isSuccess, true);
assert.equal(utcDateResult.data.toPrimitive(), "2027-01-12");
assert.deepEqual(errors, []);
});
test("rechaza snapshots persistidos inconsistentes de payment term", () => {
const mapper = buildMapper();

View File

@ -24,8 +24,8 @@ import {
InvoiceSerie,
InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoiceFiscalClassification,
IssuedInvoice,
IssuedInvoiceFiscalClassification,
IssuedInvoiceItems,
IssuedInvoiceTaxes,
IssuedInvoiceVerifactuPolicy,
@ -268,8 +268,6 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
params?: MapperParamsType
): Result<IssuedInvoice, Error> {
try {
console.debug(raw);
const errors: ValidationErrorDetail[] = [];
const attributes = this._mapAttributesToDomain(raw, { errors, ...params });
const recipientResult = this._recipientMapper.mapToDomain(raw, {
@ -338,13 +336,16 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
const effectiveAt = extractOrPushError(
maybeFromNullableResult(raw.verifactu_effective_at, (value) =>
UtcDate.createFromISO(value instanceof Date ? value.toISOString() : String(value))
UtcDate.createFromISO(
value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10)
)
),
"verifactu_effective_at",
errors
);
if (errors.length > 0) {
console.error(errors);
return Result.fail(
new ValidationErrorCollection("Issued invoice V2 mapping failed [mapToDomain]", errors)
);
@ -481,10 +482,10 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
tax_regime_code: source.taxRegime.code,
tax_regime_description: source.taxRegime.description,
operation_qualification_code: source.fiscalClassification.isSome()
? source.fiscalClassification.unwrap().operationQualificationCode.getOrUndefined() ?? null
? (source.fiscalClassification.unwrap().operationQualificationCode.getOrUndefined() ?? null)
: null,
exemption_reason_code: source.fiscalClassification.isSome()
? source.fiscalClassification.unwrap().exemptionReasonCode.getOrUndefined() ?? null
? (source.fiscalClassification.unwrap().exemptionReasonCode.getOrUndefined() ?? null)
: null,
verifactu_profile_status: source.verifactuPolicy.isSome()
? source.verifactuPolicy.unwrap().profileStatus
@ -493,7 +494,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
? source.verifactuPolicy.unwrap().usesVerifactu
: null,
verifactu_effective_at: source.verifactuPolicy.isSome()
? source.verifactuPolicy.unwrap().effectiveAt.getOrUndefined()?.toDate() ?? null
? (source.verifactuPolicy.unwrap().effectiveAt.getOrUndefined()?.toDate() ?? null)
: null,
subtotal_amount_value: source.subtotalAmount.value,
subtotal_amount_scale: source.subtotalAmount.scale,
@ -528,11 +529,12 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
}
private mapPaymentTerm(raw: IssuedInvoiceModel): Result<Maybe<InvoicePaymentTerm>, Error> {
const hasAnyPaymentTermData =
!isNullishOrEmpty(raw.payment_term_id) ||
!isNullishOrEmpty(raw.payment_term_name) ||
!isNullishOrEmpty(raw.payment_term_description) ||
!isNullishOrEmpty(raw.payment_term_snapshot_json);
const hasAnyPaymentTermData = !(
isNullishOrEmpty(raw.payment_term_id) &&
isNullishOrEmpty(raw.payment_term_name) &&
isNullishOrEmpty(raw.payment_term_description) &&
isNullishOrEmpty(raw.payment_term_snapshot_json)
);
if (!hasAnyPaymentTermData) {
return Result.ok(Maybe.none());
@ -600,16 +602,19 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
return Result.ok(Maybe.some(paymentTermResult.data));
}
private parsePaymentTermSnapshotJson(source: string): Result<{
version: 1;
dueRules: Array<{
dueDays: string;
percentage: {
value: string;
scale: number;
};
}>;
}, Error> {
private parsePaymentTermSnapshotJson(source: string): Result<
{
version: 1;
dueRules: Array<{
dueDays: string;
percentage: {
value: string;
scale: number;
};
}>;
},
Error
> {
try {
const parsed = JSON.parse(source) as {
version?: unknown;

View File

@ -5,14 +5,14 @@ import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
import { CUSTOMER_INVOICES_MODULE_NAME } from "./module-name";
/**
* Registra dinámicamente las traducciones del módulo.
*/
export const ensureModuleTranslations = () => {
registerTranslations(MODULE_NAME, "en", enResources);
registerTranslations(MODULE_NAME, "es", esResources);
registerTranslations(CUSTOMER_INVOICES_MODULE_NAME, "en", enResources);
registerTranslations(CUSTOMER_INVOICES_MODULE_NAME, "es", esResources);
};
/**
@ -23,13 +23,13 @@ export const ensureModuleTranslations = () => {
*/
export const useTranslation = () => {
// Asegura los bundles del módulo al montar (idempotente)
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
// biome-ignore lint/correctness/useExhaustiveDependencies: i18n es un singleton estable del runtime compartido
useEffect(() => {
ensureModuleTranslations();
}, [i18n]);
// Hook con namespace
return useI18NextTranslation(MODULE_NAME, {
return useI18NextTranslation(CUSTOMER_INVOICES_MODULE_NAME, {
i18n,
});
};

View File

@ -2,8 +2,9 @@ import type { IModuleClient, ModuleClientParams } from "@erp/core/client";
import { CustomerInvoiceRoutes } from "./customer-invoice-routes";
import { ensureModuleTranslations } from "./i18n";
import { CUSTOMER_INVOICES_MODULE_NAME } from "./module-name";
export const MODULE_NAME = "CustomerInvoices";
export const MODULE_NAME = CUSTOMER_INVOICES_MODULE_NAME;
const MODULE_VERSION = "1.0.0";
ensureModuleTranslations();

View File

@ -0,0 +1 @@
export const CUSTOMER_INVOICES_MODULE_NAME = "CustomerInvoices";

View File

@ -56,6 +56,18 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
},
});
mock.module("@repo/rdx-ui/helpers", {
namedExports: {
showErrorToast: () => undefined,
},
});
mock.module("@repo/shadcn-ui/components", {
namedExports: {
Badge: ({ children }: { children?: ReactNode }) => children ?? null,
},
});
mock.module("react-hook-form", {
namedExports: {
FormProvider: ({ children }: { children: ReactNode }) => children,
@ -76,6 +88,12 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
},
});
mock.module("../../../list/ui/components", {
namedExports: {
ProformaStatusBadge: () => null,
},
});
mock.module("../../controllers/use-update-proforma-page-controller", {
namedExports: {
useUpdateProformaPageController: () => ({

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/customer-invoices/*": ["./src/*"],
"@erp/identity/*": ["../identity/src/*"]

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/customers/*": ["./src/*"],
"@erp/identity/*": ["../identity/src/*"]

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/document-series/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/factuges/*": ["./src/*"],
"@erp/identity/*": ["../identity/src/*"]

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/identity/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/supplier-invoices/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/suppliers/*": ["./src/*"]
},

View File

@ -24,6 +24,8 @@
"format:check": "biome format .",
"check": "biome check .",
"check:write": "biome check --write .",
"test:frontend": "node --experimental-test-module-mocks --import tsx --test",
"test:unit": "node --import tsx --test",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
@ -38,6 +40,7 @@
"plop": "^4.0.5",
"rimraf": "^6.1.3",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"tailwindcss": "^4.2.2",
"turbo": "^2.9.14",
"typescript": "6.0.2"
@ -46,4 +49,4 @@
"node": ">=24"
},
"packageManager": "pnpm@10.33.0"
}
}

View File

@ -1,6 +1,7 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@repo/rdx-ui/*": ["./src/*"]
},

View File

@ -1,6 +1,7 @@
{
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"baseUrl": ".",
"types": ["node"],
"rootDir": "src",
"paths": {

View File

@ -1,6 +1,7 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"module": "node16",
"target": "esnext",
"moduleResolution": "node16",

View File

@ -19,6 +19,7 @@
"skipLibCheck": true,
"strict": true,
"moduleDetection": "force",
"ignoreDeprecations": "6.0",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"target": "ES2022"

View File

@ -6,6 +6,7 @@
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"ignoreDeprecations": "6.0",
"importsNotUsedAsValues": "remove",
"esModuleInterop": true,
"skipLibCheck": true,

View File

@ -2,34 +2,18 @@
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Root",
"compilerOptions": {
"baseUrl": "../..",
"ignoreDeprecations": "6.0",
"paths": {
"@erp/core/*": [
"modules/core/src/*"
],
"@erp/auth/*": [
"modules/auth/src/*"
],
"@erp/companies/*": [
"modules/companies/src/*"
],
"@erp/identity/*": [
"modules/identity/src/*"
],
"@erp/catalogs/*": [
"modules/catalogs/src/*"
],
"@erp/customers/*": [
"modules/customers/src/*"
],
"@erp/customer-invoices/*": [
"modules/customer-invoices/src/*"
],
"@erp/factuges/*": [
"modules/factuges/src/*"
],
"@erp/document-series/*": [
"modules/document-series/src/*"
]
"@erp/core/*": ["modules/core/src/*"],
"@erp/auth/*": ["modules/auth/src/*"],
"@erp/companies/*": ["modules/companies/src/*"],
"@erp/identity/*": ["modules/identity/src/*"],
"@erp/catalogs/*": ["modules/catalogs/src/*"],
"@erp/customers/*": ["modules/customers/src/*"],
"@erp/customer-invoices/*": ["modules/customer-invoices/src/*"],
"@erp/factuges/*": ["modules/factuges/src/*"],
"@erp/document-series/*": ["modules/document-series/src/*"]
},
"target": "ES2021",
"module": "CommonJS",
@ -40,4 +24,4 @@
"skipLibCheck": true,
"allowUnreachableCode": true
}
}
}

View File

@ -44,6 +44,9 @@ importers:
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@25.9.1)(typescript@6.0.2)
tsx:
specifier: ^4.21.0
version: 4.21.0
turbo:
specifier: ^2.9.14
version: 2.9.14

View File

@ -1,4 +1,8 @@
{
"extends": "@repo/typescript-config/root.json",
"compilerOptions": {
"ignoreDeprecations": "6.0",
"jsx": "react-jsx"
},
"include": ["apps", "modules", "packages"]
}