From 8a222afb0e3e1ad7139c65806b2e0b3411d32394 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 6 Jul 2026 09:54:25 +0200 Subject: [PATCH 1/3] chore: gitignore .worktrees/ for local parallel dev worktrees --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7868101a..f80656a9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ apps/api/uploads/ # Entorno local personal (scripts, seeds, docker extra) — no subir al repo .local/ + +# Local git worktrees for parallel agent work — never commit +.worktrees/ From d4ac199a45f3d5a24e84939bd14c843a56df9ce6 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 17 Jul 2026 10:25:13 +0200 Subject: [PATCH 2/3] chore: gitignore .claude/worktrees/ (native worktree tool output) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c7b5d6df..6f125eed 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ apps/api/uploads/ # Local git worktrees for parallel agent work — never commit .worktrees/ +.claude/worktrees/ From fd6eb01d661defe0b3d7badbf6de1d9721e798c2 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 17 Jul 2026 10:52:31 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(api,web):=20contrato=20estable=20por=20?= =?UTF-8?q?code=20para=20la=20localizaci=C3=B3n=20de=20errores=204xx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seguimiento de #338 (#348): la web matcheaba errores de dominio por substring/prefijo del `.message` en inglés, un acoplamiento frágil que degrada en silencio si el backend cambia la prosa y que los tests de `backend-error-messages.test.ts` no detectaban (hardcodeaban las mismas cadenas). Se añade un `code` estable a las excepciones de dominio detrás de los 12 patrones de KNOWN_BACKEND_ERRORS (SupplyLineValidationError, NeedResourceNotInEmergencyError, TargetNeedNotFoundError/ TargetNeedWrongEmergencyError, OfferItemsRequiredError, CapacityMustHaveWeightOrVolumeError, InvalidCapacityAmountError, InvalidCoverageError, InvalidCapacityWindowError) y se propaga en el body JSON de los filtros de excepción de needs/offers/logistics/resources/ supplies vía un helper compartido (`domainErrorResponseBody`). La web (`localizeBackendError`) ahora resuelve primero por `code` y sólo cae al matching por prosa como red de seguridad transicional (deploy-lag web/API) o para errores aún no migrados. Se añade también la cobertura de test que faltaba para `offer_items_required` en backend-error-messages.test.ts. La nota de UX opcional (limpiar el resaltado de fila inválida en el primer onChange tras un error) se deja fuera: toca el estado independiente de ~6 formularios distintos, no es un cambio trivial. --- .../http/domain-exception.filter.spec.ts | 79 +++++++++++++++++++ .../http/domain-exception.filter.ts | 3 +- .../contexts/needs/domain/need-errors.spec.ts | 11 +++ .../src/contexts/needs/domain/need-errors.ts | 3 + .../http/domain-exception.filter.spec.ts | 44 +++++++++++ .../http/domain-exception.filter.ts | 3 +- .../offers/application/submit-offer.spec.ts | 16 ++++ .../offers/application/submit-offer.ts | 6 ++ .../offers/domain/offer-errors.spec.ts | 9 +++ .../contexts/offers/domain/offer-errors.ts | 3 + .../http/domain-exception.filter.spec.ts | 56 +++++++++++++ .../http/domain-exception.filter.ts | 3 +- .../http/domain-exception.filter.spec.ts | 40 ++++++++++ .../http/domain-exception.filter.ts | 3 +- .../supplies-domain-exception.filter.spec.ts | 35 ++++++++ .../http/supplies-domain-exception.filter.ts | 3 +- .../shared/http/domain-error-response.spec.ts | 33 ++++++++ .../src/shared/http/domain-error-response.ts | 27 +++++++ .../src/app/e/[slug]/donar/ofrecer/actions.ts | 6 +- .../e/[slug]/ofrecer-transporte/actions.ts | 6 +- apps/web/src/app/e/[slug]/peticion/actions.ts | 6 +- .../web/src/app/e/[slug]/registrar/actions.ts | 6 +- .../src/app/e/[slug]/voluntario/actions.ts | 6 +- .../src/lib/backend-error-messages.test.ts | 53 +++++++++++++ apps/web/src/lib/backend-error-messages.ts | 56 +++++++++---- .../src/kernel/capacity.test.ts | 53 +++++++++++++ .../warehouse-core/src/kernel/capacity.ts | 6 ++ packages/warehouse-core/src/kernel/index.ts | 6 +- .../src/kernel/supply-line.test.ts | 29 +++++++ .../warehouse-core/src/kernel/supply-line.ts | 26 +++++- .../src/logistics/capacity-window.test.ts | 60 ++++++++++++++ .../src/logistics/capacity-window.ts | 2 + .../src/logistics/coverage.test.ts | 42 ++++++++++ .../warehouse-core/src/logistics/coverage.ts | 5 +- .../logistics/transport-capacity-errors.ts | 18 ++++- 35 files changed, 711 insertions(+), 52 deletions(-) create mode 100644 apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.spec.ts create mode 100644 apps/api/src/contexts/needs/domain/need-errors.spec.ts create mode 100644 apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.spec.ts create mode 100644 apps/api/src/contexts/offers/domain/offer-errors.spec.ts create mode 100644 apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.spec.ts create mode 100644 apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.spec.ts create mode 100644 apps/api/src/shared/http/domain-error-response.spec.ts create mode 100644 apps/api/src/shared/http/domain-error-response.ts create mode 100644 packages/warehouse-core/src/kernel/capacity.test.ts create mode 100644 packages/warehouse-core/src/logistics/capacity-window.test.ts create mode 100644 packages/warehouse-core/src/logistics/coverage.test.ts diff --git a/apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.spec.ts b/apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.spec.ts new file mode 100644 index 00000000..3af2042c --- /dev/null +++ b/apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.spec.ts @@ -0,0 +1,79 @@ +import { ArgumentsHost } from '@nestjs/common'; +import { LogisticsDomainExceptionFilter } from './domain-exception.filter'; +import { + CapacityMustHaveWeightOrVolumeError, + InvalidCapacityAmountError, + InvalidCapacityWindowError, + InvalidCoverageError, +} from '@globalemergency/warehouse-core/logistics'; +import { CapacityNotFoundError } from '../../application/capacity-not-found.error'; + +function respond(exception: Error): { body: unknown } { + const filter = new LogisticsDomainExceptionFilter(); + let body: unknown; + const res = { + status() { + return this; + }, + json(payload: unknown) { + body = payload; + return this; + }, + }; + const host = { + switchToHttp: () => ({ getResponse: () => res }), + } as unknown as ArgumentsHost; + filter.catch(exception, host); + return { body }; +} + +describe('LogisticsDomainExceptionFilter', () => { + it('incluye el code estable de CapacityMustHaveWeightOrVolumeError en el body (#348)', () => { + const { body } = respond(new CapacityMustHaveWeightOrVolumeError()); + expect(body).toMatchObject({ + code: 'capacity_weight_or_volume_required', + }); + }); + + it('incluye el code estable de InvalidCapacityAmountError en el body (#348)', () => { + const { body } = respond(new InvalidCapacityAmountError('weightKg', -3)); + expect(body).toMatchObject({ code: 'capacity_amount_invalid' }); + }); + + it('incluye el code estable de InvalidCoverageError (area) en el body (#348)', () => { + const { body } = respond( + new InvalidCoverageError( + 'Area coverage must not be empty', + 'coverage_area_required', + ), + ); + expect(body).toMatchObject({ code: 'coverage_area_required' }); + }); + + it('incluye los codes distintos de InvalidCapacityWindowError en el body (#348)', () => { + const { body: invalidDate } = respond( + new InvalidCapacityWindowError( + "Capacity window from must be a valid ISO date, got 'nope'", + 'capacity_window_invalid_date', + ), + ); + expect(invalidDate).toMatchObject({ + code: 'capacity_window_invalid_date', + }); + + const { body: orderInvalid } = respond( + new InvalidCapacityWindowError( + "Capacity window 'from' (2026-01-02) must not be after 'to' (2026-01-01)", + 'capacity_window_order_invalid', + ), + ); + expect(orderInvalid).toMatchObject({ + code: 'capacity_window_order_invalid', + }); + }); + + it('omite code cuando la excepción no expone uno', () => { + const { body } = respond(new CapacityNotFoundError('missing-id')); + expect(body).not.toHaveProperty('code'); + }); +}); diff --git a/apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.ts b/apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.ts index 1cff7b29..db54578b 100644 --- a/apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.ts +++ b/apps/api/src/contexts/logistics/infrastructure/http/domain-exception.filter.ts @@ -28,6 +28,7 @@ import { ShipmentContainerUnavailableError, } from '../../application/shipment-cargo-errors'; import { EmergencyNotAcceptingIntakeError } from '../../../emergencies/domain/emergency-not-accepting-intake.error'; +import { domainErrorResponseBody } from '../../../../shared/http/domain-error-response'; type DomainError = | CapacityNotFoundError @@ -73,7 +74,7 @@ export class LogisticsDomainExceptionFilter implements ExceptionFilter { const statusCode = this.statusFor(exception); response .status(statusCode) - .json({ statusCode, message: exception.message }); + .json(domainErrorResponseBody(statusCode, exception)); } private statusFor(exception: DomainError): HttpStatus { diff --git a/apps/api/src/contexts/needs/domain/need-errors.spec.ts b/apps/api/src/contexts/needs/domain/need-errors.spec.ts new file mode 100644 index 00000000..fa2ca59b --- /dev/null +++ b/apps/api/src/contexts/needs/domain/need-errors.spec.ts @@ -0,0 +1,11 @@ +import { NeedResourceNotInEmergencyError } from './need-errors'; + +describe('NeedResourceNotInEmergencyError', () => { + it('exposes a stable code for the web to localize (#348), independent of the message prose', () => { + const error = new NeedResourceNotInEmergencyError('some-resource-id'); + expect(error.code).toBe('resource_not_in_emergency'); + expect(error.message).toBe( + 'Resource some-resource-id does not exist in this emergency', + ); + }); +}); diff --git a/apps/api/src/contexts/needs/domain/need-errors.ts b/apps/api/src/contexts/needs/domain/need-errors.ts index 50b86ef8..6866ef4a 100644 --- a/apps/api/src/contexts/needs/domain/need-errors.ts +++ b/apps/api/src/contexts/needs/domain/need-errors.ts @@ -27,6 +27,9 @@ export class NeedTitleRequiredError extends Error { * error: the referenced resource is not part of this emergency. */ export class NeedResourceNotInEmergencyError extends Error { + /** Stable identifier for web localization (#348). */ + readonly code = 'resource_not_in_emergency' as const; + constructor(resourceId: string) { super(`Resource ${resourceId} does not exist in this emergency`); this.name = 'NeedResourceNotInEmergencyError'; diff --git a/apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.spec.ts b/apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.spec.ts new file mode 100644 index 00000000..20430e8d --- /dev/null +++ b/apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.spec.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { NeedsDomainExceptionFilter } from './domain-exception.filter'; +import { NeedResourceNotInEmergencyError } from '../../domain/need-errors'; +import { NeedNotFoundError } from '../../application/need-not-found.error'; + +function respond(exception: Error): { statusCode: number; body: unknown } { + const filter = new NeedsDomainExceptionFilter(); + let statusCode = 0; + let body: unknown; + const res = { + status(code: number) { + statusCode = code; + return this; + }, + json(payload: unknown) { + body = payload; + return this; + }, + }; + const host = { + switchToHttp: () => ({ getResponse: () => res }), + } as unknown as ArgumentsHost; + filter.catch(exception, host); + return { statusCode, body }; +} + +describe('NeedsDomainExceptionFilter', () => { + it('incluye el code estable de NeedResourceNotInEmergencyError en el body (#348)', () => { + const { statusCode, body } = respond( + new NeedResourceNotInEmergencyError('some-id'), + ); + expect(statusCode).toBe(HttpStatus.BAD_REQUEST); + expect(body).toMatchObject({ + statusCode: HttpStatus.BAD_REQUEST, + code: 'resource_not_in_emergency', + message: 'Resource some-id does not exist in this emergency', + }); + }); + + it('omite code cuando la excepción no expone uno', () => { + const { body } = respond(new NeedNotFoundError('missing-id')); + expect(body).not.toHaveProperty('code'); + }); +}); diff --git a/apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.ts b/apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.ts index 94b32130..aedbfe9f 100644 --- a/apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.ts +++ b/apps/api/src/contexts/needs/infrastructure/http/domain-exception.filter.ts @@ -15,6 +15,7 @@ import { import { EmergencyNotAcceptingIntakeError } from '../../../emergencies/domain/emergency-not-accepting-intake.error'; import { InvalidAuthorError } from '../../../../shared/domain/author'; import { SupplyLineValidationError } from '@globalemergency/warehouse-core/kernel'; +import { domainErrorResponseBody } from '../../../../shared/http/domain-error-response'; type DomainError = | NeedNotFoundError @@ -52,6 +53,6 @@ export class NeedsDomainExceptionFilter implements ExceptionFilter { : HttpStatus.CONFLICT; response .status(statusCode) - .json({ statusCode, message: exception.message }); + .json(domainErrorResponseBody(statusCode, exception)); } } diff --git a/apps/api/src/contexts/offers/application/submit-offer.spec.ts b/apps/api/src/contexts/offers/application/submit-offer.spec.ts index 784db9b4..15ea5465 100644 --- a/apps/api/src/contexts/offers/application/submit-offer.spec.ts +++ b/apps/api/src/contexts/offers/application/submit-offer.spec.ts @@ -131,6 +131,14 @@ describe('SubmitOffer', () => { ).rejects.toThrow(TargetNeedNotFoundError); }); + it('exposes a stable code on TargetNeedNotFoundError for web localization (#348)', async () => { + const lookupNotFound = new FakeNeedLookup(null); + const uc = new SubmitOffer(repo, bus, activeReader, lookupNotFound); + await expect( + uc.execute(makeCmd({ targetNeedId: NEED_ID })), + ).rejects.toMatchObject({ code: 'target_need_not_found' }); + }); + it('throws TargetNeedWrongEmergencyError when need belongs to different emergency', async () => { const lookupOtherEm = new FakeNeedLookup(OTHER_EM); const uc = new SubmitOffer(repo, bus, activeReader, lookupOtherEm); @@ -139,6 +147,14 @@ describe('SubmitOffer', () => { ).rejects.toThrow(TargetNeedWrongEmergencyError); }); + it('exposes a stable code on TargetNeedWrongEmergencyError for web localization (#348)', async () => { + const lookupOtherEm = new FakeNeedLookup(OTHER_EM); + const uc = new SubmitOffer(repo, bus, activeReader, lookupOtherEm); + await expect( + uc.execute(makeCmd({ targetNeedId: NEED_ID })), + ).rejects.toMatchObject({ code: 'target_need_wrong_emergency' }); + }); + describe('kill-switch', () => { it('throws EmergencyNotAcceptingIntakeError when emergency is paused', async () => { const uc = new SubmitOffer( diff --git a/apps/api/src/contexts/offers/application/submit-offer.ts b/apps/api/src/contexts/offers/application/submit-offer.ts index 27090fc4..1b59c993 100644 --- a/apps/api/src/contexts/offers/application/submit-offer.ts +++ b/apps/api/src/contexts/offers/application/submit-offer.ts @@ -13,6 +13,9 @@ import { EmergencyNotAcceptingIntakeError } from '../../emergencies/domain/emerg const ACTIVE_STATUS = 'active'; export class TargetNeedNotFoundError extends Error { + /** Stable identifier for web localization (#348). */ + readonly code = 'target_need_not_found' as const; + constructor(needId: string) { super(`Target need not found: ${needId}`); this.name = 'TargetNeedNotFoundError'; @@ -20,6 +23,9 @@ export class TargetNeedNotFoundError extends Error { } export class TargetNeedWrongEmergencyError extends Error { + /** Stable identifier for web localization (#348). */ + readonly code = 'target_need_wrong_emergency' as const; + constructor(needId: string, emergencyId: string) { super( `Target need '${needId}' does not belong to emergency '${emergencyId}'`, diff --git a/apps/api/src/contexts/offers/domain/offer-errors.spec.ts b/apps/api/src/contexts/offers/domain/offer-errors.spec.ts new file mode 100644 index 00000000..1003f154 --- /dev/null +++ b/apps/api/src/contexts/offers/domain/offer-errors.spec.ts @@ -0,0 +1,9 @@ +import { OfferItemsRequiredError } from './offer-errors'; + +describe('OfferItemsRequiredError', () => { + it('exposes a stable code for the web to localize (#348), independent of the message prose', () => { + const error = new OfferItemsRequiredError(); + expect(error.code).toBe('offer_items_required'); + expect(error.message).toBe('An offer must have at least one supply line'); + }); +}); diff --git a/apps/api/src/contexts/offers/domain/offer-errors.ts b/apps/api/src/contexts/offers/domain/offer-errors.ts index 9b92c2ac..711e58a6 100644 --- a/apps/api/src/contexts/offers/domain/offer-errors.ts +++ b/apps/api/src/contexts/offers/domain/offer-errors.ts @@ -29,6 +29,9 @@ export class OfferNotEditableError extends Error { /** Raised when an offer would be left without any supply line. */ export class OfferItemsRequiredError extends Error { + /** Stable identifier for web localization (#348). */ + readonly code = 'offer_items_required' as const; + constructor() { super('An offer must have at least one supply line'); this.name = 'OfferItemsRequiredError'; diff --git a/apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.spec.ts b/apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.spec.ts new file mode 100644 index 00000000..851c948e --- /dev/null +++ b/apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.spec.ts @@ -0,0 +1,56 @@ +import { ArgumentsHost } from '@nestjs/common'; +import { OffersDomainExceptionFilter } from './domain-exception.filter'; +import { OfferItemsRequiredError } from '../../domain/offer-errors'; +import { + TargetNeedNotFoundError, + TargetNeedWrongEmergencyError, +} from '../../application/submit-offer'; +import { OfferNotFoundError } from '../../application/offer-not-found.error'; + +function respond(exception: Error): { statusCode: number; body: unknown } { + const filter = new OffersDomainExceptionFilter(); + let statusCode = 0; + let body: unknown; + const res = { + status(code: number) { + statusCode = code; + return this; + }, + json(payload: unknown) { + body = payload; + return this; + }, + }; + const host = { + switchToHttp: () => ({ getResponse: () => res }), + } as unknown as ArgumentsHost; + filter.catch(exception, host); + return { statusCode, body }; +} + +describe('OffersDomainExceptionFilter', () => { + it('incluye el code estable de OfferItemsRequiredError en el body (#348)', () => { + const { body } = respond(new OfferItemsRequiredError()); + expect(body).toMatchObject({ + code: 'offer_items_required', + message: 'An offer must have at least one supply line', + }); + }); + + it('incluye el code estable de TargetNeedNotFoundError en el body (#348)', () => { + const { body } = respond(new TargetNeedNotFoundError('some-need-id')); + expect(body).toMatchObject({ code: 'target_need_not_found' }); + }); + + it('incluye el code estable de TargetNeedWrongEmergencyError en el body (#348)', () => { + const { body } = respond( + new TargetNeedWrongEmergencyError('need-id', 'emergency-id'), + ); + expect(body).toMatchObject({ code: 'target_need_wrong_emergency' }); + }); + + it('omite code cuando la excepción no expone uno', () => { + const { body } = respond(new OfferNotFoundError('missing-id')); + expect(body).not.toHaveProperty('code'); + }); +}); diff --git a/apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.ts b/apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.ts index 64c5f4c9..5c177704 100644 --- a/apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.ts +++ b/apps/api/src/contexts/offers/infrastructure/http/domain-exception.filter.ts @@ -32,6 +32,7 @@ import { } from '../../domain/donation-intake-errors'; import { SupplyLineValidationError } from '@globalemergency/warehouse-core/kernel'; import { InvalidAuthorError } from '../../../../shared/domain/author'; +import { domainErrorResponseBody } from '../../../../shared/http/domain-error-response'; type DomainError = | OfferNotFoundError @@ -120,6 +121,6 @@ export class OffersDomainExceptionFilter implements ExceptionFilter { : HttpStatus.CONFLICT; response .status(statusCode) - .json({ statusCode, message: exception.message }); + .json(domainErrorResponseBody(statusCode, exception)); } } diff --git a/apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.spec.ts b/apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.spec.ts new file mode 100644 index 00000000..80ebdcbc --- /dev/null +++ b/apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.spec.ts @@ -0,0 +1,40 @@ +import { ArgumentsHost } from '@nestjs/common'; +import { DomainExceptionFilter } from './domain-exception.filter'; +import { SupplyLineValidationError } from '@globalemergency/warehouse-core/kernel'; +import { ResourceNotFoundError } from '../../application/resource-not-found.error'; + +function respond(exception: Error): { body: unknown } { + const filter = new DomainExceptionFilter(); + let body: unknown; + const res = { + status() { + return this; + }, + json(payload: unknown) { + body = payload; + return this; + }, + }; + const host = { + switchToHttp: () => ({ getResponse: () => res }), + } as unknown as ArgumentsHost; + filter.catch(exception, host); + return { body }; +} + +describe('resources DomainExceptionFilter', () => { + it('incluye el code estable de SupplyLineValidationError en el body (#348)', () => { + const { body } = respond( + new SupplyLineValidationError( + 'SupplyLine name must not be empty', + 'supply_name_required', + ), + ); + expect(body).toMatchObject({ code: 'supply_name_required' }); + }); + + it('omite code cuando la excepción no expone uno', () => { + const { body } = respond(new ResourceNotFoundError('missing-id')); + expect(body).not.toHaveProperty('code'); + }); +}); diff --git a/apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.ts b/apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.ts index 1f4320ae..bbe80d6d 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/domain-exception.filter.ts @@ -24,6 +24,7 @@ import { import { EmergencyNotAcceptingIntakeError } from '../../../emergencies/domain/emergency-not-accepting-intake.error'; import { InvalidAuthorError } from '../../../../shared/domain/author'; import { SupplyLineValidationError } from '@globalemergency/warehouse-core/kernel'; +import { domainErrorResponseBody } from '../../../../shared/http/domain-error-response'; type DomainError = | ResourceNotFoundError @@ -93,6 +94,6 @@ export class DomainExceptionFilter implements ExceptionFilter { const statusCode = match ? match[1] : HttpStatus.BAD_REQUEST; response .status(statusCode) - .json({ statusCode, message: exception.message }); + .json(domainErrorResponseBody(statusCode, exception)); } } diff --git a/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.spec.ts b/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.spec.ts index 77b8f3e6..506e2f5c 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.spec.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.spec.ts @@ -5,6 +5,7 @@ import { CategoryProtectedError, CategoryValidationError, } from '../../application/category-admin.errors'; +import { SupplyLineValidationError } from '@globalemergency/warehouse-core/kernel'; function statusFor( exception: @@ -28,6 +29,25 @@ function statusFor( return statusCode; } +function bodyFor(exception: Error): unknown { + const filter = new SuppliesDomainExceptionFilter(); + let body: unknown; + const res = { + status() { + return this; + }, + json(payload: unknown) { + body = payload; + return this; + }, + }; + const host = { + switchToHttp: () => ({ getResponse: () => res }), + } as unknown as ArgumentsHost; + filter.catch(exception, host); + return body; +} + describe('SuppliesDomainExceptionFilter — categorías admin', () => { it('mapea el CategoryNotFoundError de application a 404 (regresión: antes 500)', () => { expect(statusFor(new CategoryNotFoundError('missing'))).toBe( @@ -46,4 +66,19 @@ describe('SuppliesDomainExceptionFilter — categorías admin', () => { HttpStatus.UNPROCESSABLE_ENTITY, ); }); + + it('incluye el code estable de SupplyLineValidationError en el body (#348)', () => { + const body = bodyFor( + new SupplyLineValidationError( + 'SupplyLine name must not be empty', + 'supply_name_required', + ), + ); + expect(body).toMatchObject({ code: 'supply_name_required' }); + }); + + it('omite code cuando la excepción no expone uno', () => { + const body = bodyFor(new CategoryNotFoundError('missing')); + expect(body).not.toHaveProperty('code'); + }); }); diff --git a/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.ts b/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.ts index be87bed7..6e9294aa 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/supplies-domain-exception.filter.ts @@ -37,6 +37,7 @@ import { CategoryValidationError, } from '../../application/category-admin.errors'; import { AttributeDefinitionNotFoundError } from '../../application/attribute-definition-admin.errors'; +import { domainErrorResponseBody } from '../../../../shared/http/domain-error-response'; type DomainError = | ContainerNotFoundError @@ -106,7 +107,7 @@ export class SuppliesDomainExceptionFilter implements ExceptionFilter { const statusCode = this.statusFor(exception); response .status(statusCode) - .json({ statusCode, message: exception.message }); + .json(domainErrorResponseBody(statusCode, exception)); } private statusFor(exception: DomainError): HttpStatus { diff --git a/apps/api/src/shared/http/domain-error-response.spec.ts b/apps/api/src/shared/http/domain-error-response.spec.ts new file mode 100644 index 00000000..81c8aa7e --- /dev/null +++ b/apps/api/src/shared/http/domain-error-response.spec.ts @@ -0,0 +1,33 @@ +import { domainErrorResponseBody } from './domain-error-response'; + +class WithCode extends Error { + readonly code = 'some_code'; +} + +class WithoutCode extends Error {} + +class WithNonStringCode extends Error { + readonly code = 42; +} + +describe('domainErrorResponseBody', () => { + it('includes code when the exception exposes a string code', () => { + expect(domainErrorResponseBody(400, new WithCode('boom'))).toEqual({ + statusCode: 400, + message: 'boom', + code: 'some_code', + }); + }); + + it('omits code when the exception does not expose one', () => { + expect(domainErrorResponseBody(404, new WithoutCode('missing'))).toEqual({ + statusCode: 404, + message: 'missing', + }); + }); + + it('omits code when it is present but not a string (defensive)', () => { + const body = domainErrorResponseBody(400, new WithNonStringCode('bad')); + expect(body).toEqual({ statusCode: 400, message: 'bad' }); + }); +}); diff --git a/apps/api/src/shared/http/domain-error-response.ts b/apps/api/src/shared/http/domain-error-response.ts new file mode 100644 index 00000000..3069ebaa --- /dev/null +++ b/apps/api/src/shared/http/domain-error-response.ts @@ -0,0 +1,27 @@ +/** + * Shared 4xx JSON body shape for the per-context domain-exception filters + * (#348). `code` is a stable, machine-readable identifier — added + * incrementally to the domain errors the web already localizes + * (`apps/web/src/lib/backend-error-messages.ts`) — so the web can match on it + * instead of on `message` prose, which drifts silently when the English text + * changes. Errors that don't (yet) expose a `.code` simply omit the field, + * keeping this backward compatible with the previous `{ statusCode, message }` + * shape. + */ +export interface DomainErrorResponseBody { + statusCode: number; + message: string; + code?: string; +} + +export function domainErrorResponseBody( + statusCode: number, + exception: Error, +): DomainErrorResponseBody { + const code = (exception as { code?: unknown }).code; + return { + statusCode, + message: exception.message, + ...(typeof code === 'string' ? { code } : {}), + }; +} diff --git a/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts b/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts index 6780486c..35478125 100644 --- a/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts +++ b/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts @@ -142,13 +142,9 @@ export async function submitOffer( } if (error !== undefined || data === undefined) { - const rawMessage = - typeof error === 'object' && error !== null && 'message' in error - ? (error as { message: unknown }).message - : undefined; return { status: 'error', - message: localizeBackendError(t.backendErrors, rawMessage, t.donar.err_submit_failed), + message: localizeBackendError(t.backendErrors, error, t.donar.err_submit_failed), }; } diff --git a/apps/web/src/app/e/[slug]/ofrecer-transporte/actions.ts b/apps/web/src/app/e/[slug]/ofrecer-transporte/actions.ts index 211e18bc..77b33d81 100644 --- a/apps/web/src/app/e/[slug]/ofrecer-transporte/actions.ts +++ b/apps/web/src/app/e/[slug]/ofrecer-transporte/actions.ts @@ -143,13 +143,9 @@ export async function submitCapacity( } if (error !== undefined || data === undefined) { - const rawMessage = - typeof error === 'object' && error !== null && 'message' in error - ? (error as { message: unknown }).message - : undefined; return { status: 'error', - message: localizeBackendError(t.backendErrors, rawMessage, tt.err_submit_failed), + message: localizeBackendError(t.backendErrors, error, tt.err_submit_failed), }; } diff --git a/apps/web/src/app/e/[slug]/peticion/actions.ts b/apps/web/src/app/e/[slug]/peticion/actions.ts index cf3e17b9..d86a37a9 100644 --- a/apps/web/src/app/e/[slug]/peticion/actions.ts +++ b/apps/web/src/app/e/[slug]/peticion/actions.ts @@ -136,13 +136,9 @@ export async function submitPeticion( } if (error !== undefined || data === undefined) { - const rawMessage = - typeof error === 'object' && error !== null && 'message' in error - ? (error as { message: unknown }).message - : undefined; return { status: 'error', - message: localizeBackendError(t.backendErrors, rawMessage, t.peticion.err_submit_failed), + message: localizeBackendError(t.backendErrors, error, t.peticion.err_submit_failed), }; } diff --git a/apps/web/src/app/e/[slug]/registrar/actions.ts b/apps/web/src/app/e/[slug]/registrar/actions.ts index 645b9abe..be8a3d63 100644 --- a/apps/web/src/app/e/[slug]/registrar/actions.ts +++ b/apps/web/src/app/e/[slug]/registrar/actions.ts @@ -122,15 +122,11 @@ export async function registerResource( } if (error !== undefined || data === undefined) { - const rawMessage = - typeof error === 'object' && error !== null && 'message' in error - ? (error as { message: unknown }).message - : undefined; return { status: 'error', message: localizeBackendError( t.backendErrors, - rawMessage, + error, t.registrar.err_register_failed, ), }; diff --git a/apps/web/src/app/e/[slug]/voluntario/actions.ts b/apps/web/src/app/e/[slug]/voluntario/actions.ts index abf8129f..f2d75bc0 100644 --- a/apps/web/src/app/e/[slug]/voluntario/actions.ts +++ b/apps/web/src/app/e/[slug]/voluntario/actions.ts @@ -122,15 +122,11 @@ export async function registerVolunteer( } if (error !== undefined || data === undefined) { - const rawMessage = - typeof error === 'object' && error !== null && 'message' in error - ? (error as { message: unknown }).message - : undefined; return { status: 'error', message: localizeBackendError( t.backendErrors, - rawMessage, + error, t.voluntario.err_register_failed, ), }; diff --git a/apps/web/src/lib/backend-error-messages.test.ts b/apps/web/src/lib/backend-error-messages.test.ts index 683a1787..b0707bd0 100644 --- a/apps/web/src/lib/backend-error-messages.test.ts +++ b/apps/web/src/lib/backend-error-messages.test.ts @@ -36,6 +36,13 @@ test('maps known SupplyLine validation messages', () => { ); }); +test('maps the "offer must have at least one supply line" domain error (#348)', () => { + assert.equal( + localizeBackendError(t, 'An offer must have at least one supply line', FALLBACK), + t.offer_items_required, + ); +}); + test('maps offer target-need domain errors', () => { assert.equal( localizeBackendError( @@ -90,6 +97,52 @@ test('maps logistics capacity domain errors', () => { ); }); +test('maps by stable `code` first, ignoring stale message prose (#348)', () => { + assert.equal( + localizeBackendError( + t, + { code: 'resource_not_in_emergency', message: 'some divergent English text' }, + FALLBACK, + ), + t.resource_not_in_emergency, + ); +}); + +test('maps every known code directly, one per KNOWN_BACKEND_ERRORS entry (#348)', () => { + const knownCodes: (keyof typeof t)[] = [ + 'supply_name_required', + 'supply_quantity_invalid', + 'supply_expiry_invalid', + 'resource_not_in_emergency', + 'target_need_not_found', + 'target_need_wrong_emergency', + 'offer_items_required', + 'capacity_weight_or_volume_required', + 'capacity_amount_invalid', + 'coverage_area_required', + 'capacity_window_invalid_date', + 'capacity_window_order_invalid', + ]; + for (const code of knownCodes) { + assert.equal(localizeBackendError(t, { code }, FALLBACK), t[code]); + } +}); + +test('falls back to message-prose matching when `code` is missing or unrecognized', () => { + assert.equal( + localizeBackendError( + t, + { code: 'some_future_code_not_localized_yet', message: 'Target need not found: x' }, + FALLBACK, + ), + t.target_need_not_found, + ); + assert.equal( + localizeBackendError(t, { message: 'Area coverage must not be empty' }, FALLBACK), + t.coverage_area_required, + ); +}); + test('falls back to the caller-provided message for anything unmapped', () => { assert.equal(localizeBackendError(t, 'boom, something exploded', FALLBACK), FALLBACK); assert.equal(localizeBackendError(t, undefined, FALLBACK), FALLBACK); diff --git a/apps/web/src/lib/backend-error-messages.ts b/apps/web/src/lib/backend-error-messages.ts index e384db65..40413e47 100644 --- a/apps/web/src/lib/backend-error-messages.ts +++ b/apps/web/src/lib/backend-error-messages.ts @@ -1,20 +1,25 @@ import type { Messages } from '../i18n/messages/es.ts'; type BackendErrorMessages = Messages['backendErrors']; +type BackendErrorCode = Exclude; /** - * Known backend domain-error messages (#296). The NestJS exception filters - * (`apps/api/src/contexts/*\/infrastructure/http/*-exception.filter.ts`) return - * `{ statusCode, message: exception.message }` with no stable error-code - * field — just the `Error`'s English text, sometimes with an id interpolated - * in the middle. So we match on a stable substring/prefix of each known - * message rather than the id-bearing parts. + * Known backend domain-error messages (#296, hardened by #348). The NestJS + * exception filters (`apps/api/src/contexts/*\/infrastructure/http/*-exception.filter.ts`) + * return `{ statusCode, message, code? }`. `code` (added in #348) is a + * stable, machine-readable identifier set on the domain error classes + * themselves — prefer it. This regex table matching a substring/prefix of the + * English `.message` is now only a **fallback** for the deploy-lag window (web + * and API roll out separately) and for any error not yet migrated to expose a + * `code`; keeping it means an untyped, hand-maintained coupling to backend + * prose that can drift silently, so new backend errors should get a `code` + * instead of relying on this table. * * Order matters: the first matching pattern wins. */ const KNOWN_BACKEND_ERRORS: readonly { pattern: RegExp; - key: keyof BackendErrorMessages; + key: BackendErrorCode; }[] = [ // needs / resources / offers — SupplyLineValidationError { pattern: /^SupplyLine name must not be empty/, key: 'supply_name_required' }, @@ -50,19 +55,42 @@ const KNOWN_BACKEND_ERRORS: readonly { { pattern: /must not be after/, key: 'capacity_window_order_invalid' }, ]; +const KNOWN_BACKEND_ERROR_CODES: ReadonlySet = new Set( + KNOWN_BACKEND_ERRORS.map((entry) => entry.key), +); + +function isKnownBackendErrorCode(code: string): code is BackendErrorCode { + return KNOWN_BACKEND_ERROR_CODES.has(code); +} + +function readStringProp(value: unknown, key: string): string | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const prop = (value as Record)[key]; + return typeof prop === 'string' ? prop : undefined; +} + /** - * Map a raw backend error message to localized copy, so a form never shows - * English text (and stray UUIDs) straight from the API (#296). Returns - * `fallback` — the caller's existing generic "couldn't submit" message — - * for anything unmapped (including a missing/non-string message), so this - * is always safe to call with whatever `error.message` the typed client gives. + * Map a backend error to localized copy, so a form never shows English text + * (and stray UUIDs) straight from the API (#296). `error` is whatever the + * typed client gives back for a failed request — safe to call with an + * `unknown` value, a plain string (treated as the message), or `undefined`. + * + * Resolution order (#348): a recognized `.code` wins first (the stable + * contract); a message-prose match is the fallback. Returns `fallback` — the + * caller's existing generic "couldn't submit" message — when neither matches. */ export function localizeBackendError( t: BackendErrorMessages, - message: unknown, + error: unknown, fallback: string, ): string { - if (typeof message !== 'string' || message.trim() === '') return fallback; + const code = readStringProp(error, 'code'); + if (code !== undefined && isKnownBackendErrorCode(code)) { + return t[code]; + } + + const message = typeof error === 'string' ? error : readStringProp(error, 'message'); + if (message === undefined || message.trim() === '') return fallback; const match = KNOWN_BACKEND_ERRORS.find((entry) => entry.pattern.test(message)); return match ? t[match.key] : fallback; } diff --git a/packages/warehouse-core/src/kernel/capacity.test.ts b/packages/warehouse-core/src/kernel/capacity.test.ts new file mode 100644 index 00000000..b236cd6d --- /dev/null +++ b/packages/warehouse-core/src/kernel/capacity.test.ts @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + Capacity, + CapacityMustHaveWeightOrVolumeError, + InvalidCapacityAmountError, +} from './capacity.js'; + +test('create acepta peso o volumen por separado', () => { + const byWeight = Capacity.create({ weightKg: 500, volumeM3: null }); + assert.equal(byWeight.weightKg, 500); + assert.equal(byWeight.volumeM3, null); + + const byVolume = Capacity.create({ weightKg: null, volumeM3: 12 }); + assert.equal(byVolume.volumeM3, 12); +}); + +test('create rechaza cuando faltan ambas dimensiones', () => { + assert.throws( + () => Capacity.create({ weightKg: null, volumeM3: null }), + CapacityMustHaveWeightOrVolumeError, + ); +}); + +test('create rechaza dimensiones no positivas', () => { + assert.throws( + () => Capacity.create({ weightKg: 0, volumeM3: null }), + InvalidCapacityAmountError, + ); + assert.throws( + () => Capacity.create({ weightKg: null, volumeM3: -1 }), + InvalidCapacityAmountError, + ); +}); + +// #348 — the web localizes by `.code`, not by matching `.message` prose. +test('los errores de Capacity exponen un code estable (#348)', () => { + try { + Capacity.create({ weightKg: null, volumeM3: null }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof CapacityMustHaveWeightOrVolumeError)) throw err; + assert.equal(err.code, 'capacity_weight_or_volume_required'); + } + + try { + Capacity.create({ weightKg: -5, volumeM3: null }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof InvalidCapacityAmountError)) throw err; + assert.equal(err.code, 'capacity_amount_invalid'); + } +}); diff --git a/packages/warehouse-core/src/kernel/capacity.ts b/packages/warehouse-core/src/kernel/capacity.ts index 47971557..4f8af947 100644 --- a/packages/warehouse-core/src/kernel/capacity.ts +++ b/packages/warehouse-core/src/kernel/capacity.ts @@ -17,6 +17,9 @@ export interface CapacityProps { /** Se lanza cuando una capacidad no declara ni peso ni volumen. */ export class CapacityMustHaveWeightOrVolumeError extends Error { + /** Stable identifier for web localization (#348); see `SupplyLineErrorCode`. */ + readonly code = 'capacity_weight_or_volume_required' as const; + constructor() { super('Transport capacity must declare at least weightKg or volumeM3'); this.name = 'CapacityMustHaveWeightOrVolumeError'; @@ -25,6 +28,9 @@ export class CapacityMustHaveWeightOrVolumeError extends Error { /** Se lanza cuando una dimensión de capacidad presente no es positiva. */ export class InvalidCapacityAmountError extends Error { + /** Stable identifier for web localization (#348); see `SupplyLineErrorCode`. */ + readonly code = 'capacity_amount_invalid' as const; + constructor(field: string, value: number) { super(`Capacity ${field} must be greater than 0, got ${value}`); this.name = 'InvalidCapacityAmountError'; diff --git a/packages/warehouse-core/src/kernel/index.ts b/packages/warehouse-core/src/kernel/index.ts index a54cfaab..e8473515 100644 --- a/packages/warehouse-core/src/kernel/index.ts +++ b/packages/warehouse-core/src/kernel/index.ts @@ -24,7 +24,11 @@ export { export { CategoryRegistry } from './category-registry.js'; export type { CategoryNode } from './category-registry.js'; export { SupplyLine, SupplyLineValidationError } from './supply-line.js'; -export type { SupplyLineProps, SupplyLineSnapshot } from './supply-line.js'; +export type { + SupplyLineProps, + SupplyLineSnapshot, + SupplyLineErrorCode, +} from './supply-line.js'; export { ScopeId, ScopeIdValidationError } from './scope-id.js'; export { ExternalCodesValidationError, diff --git a/packages/warehouse-core/src/kernel/supply-line.test.ts b/packages/warehouse-core/src/kernel/supply-line.test.ts index c5d85c18..8bdc9fd2 100644 --- a/packages/warehouse-core/src/kernel/supply-line.test.ts +++ b/packages/warehouse-core/src/kernel/supply-line.test.ts @@ -46,3 +46,32 @@ test('create sigue validando nombre y cantidad', () => { SupplyLineValidationError, ); }); + +// #348 — the web (`backend-error-messages.ts`) localizes by `.code`, not by +// matching `.message` prose, so these codes must stay stable regardless of +// wording changes above. +test('SupplyLineValidationError expone un code estable por cada invariante (#348)', () => { + try { + SupplyLine.create({ ...base, name: '' }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof SupplyLineValidationError)) throw err; + assert.equal(err.code, 'supply_name_required'); + } + + try { + SupplyLine.create({ ...base, quantity: -1 }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof SupplyLineValidationError)) throw err; + assert.equal(err.code, 'supply_quantity_invalid'); + } + + try { + SupplyLine.create({ ...base, expiresAt: 'not-a-date' }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof SupplyLineValidationError)) throw err; + assert.equal(err.code, 'supply_expiry_invalid'); + } +}); diff --git a/packages/warehouse-core/src/kernel/supply-line.ts b/packages/warehouse-core/src/kernel/supply-line.ts index 46599c30..df3787aa 100644 --- a/packages/warehouse-core/src/kernel/supply-line.ts +++ b/packages/warehouse-core/src/kernel/supply-line.ts @@ -63,20 +63,34 @@ function normalizeDateOnly(value?: string | null): string | null { if (trimmed === '') return null; const errorMessage = 'SupplyLine expiresAt must be a valid YYYY-MM-DD date'; if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { - throw new SupplyLineValidationError(errorMessage); + throw new SupplyLineValidationError(errorMessage, 'supply_expiry_invalid'); } const parsed = new Date(`${trimmed}T00:00:00.000Z`); if ( Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== trimmed ) { - throw new SupplyLineValidationError(errorMessage); + throw new SupplyLineValidationError(errorMessage, 'supply_expiry_invalid'); } return trimmed; } +/** + * Stable, machine-readable identifier for each SupplyLine validation failure + * (#348). Consumers (e.g. the web's `localizeBackendError`) should key + * localized copy off `code`, not off `.message` prose, so a wording change + * here can't silently degrade the client to a generic error. + */ +export type SupplyLineErrorCode = + | 'supply_name_required' + | 'supply_quantity_invalid' + | 'supply_expiry_invalid'; + export class SupplyLineValidationError extends Error { - constructor(msg: string) { + constructor( + msg: string, + public readonly code: SupplyLineErrorCode, + ) { super(msg); this.name = 'SupplyLineValidationError'; } @@ -103,11 +117,15 @@ export class SupplyLine { static create(props: SupplyLineProps): SupplyLine { if (!props.name || props.name.trim().length === 0) { - throw new SupplyLineValidationError('SupplyLine name must not be empty'); + throw new SupplyLineValidationError( + 'SupplyLine name must not be empty', + 'supply_name_required', + ); } if (!Number.isInteger(props.quantity) || props.quantity < 1) { throw new SupplyLineValidationError( 'SupplyLine quantity must be a positive integer', + 'supply_quantity_invalid', ); } // Valida el *formato* del slug (trim + lowercase + snake_case) y lo diff --git a/packages/warehouse-core/src/logistics/capacity-window.test.ts b/packages/warehouse-core/src/logistics/capacity-window.test.ts new file mode 100644 index 00000000..19bc5eda --- /dev/null +++ b/packages/warehouse-core/src/logistics/capacity-window.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CapacityWindow } from './capacity-window.js'; +import { InvalidCapacityWindowError } from './transport-capacity-errors.js'; + +test('create acepta un rango válido', () => { + const window = CapacityWindow.create({ + from: '2026-01-01T00:00:00.000Z', + to: '2026-01-02T00:00:00.000Z', + }); + assert.equal(window.from, '2026-01-01T00:00:00.000Z'); + assert.equal(window.to, '2026-01-02T00:00:00.000Z'); +}); + +test('empty no declara límites', () => { + const window = CapacityWindow.empty(); + assert.equal(window.from, null); + assert.equal(window.to, null); +}); + +test('create rechaza fechas no ISO', () => { + assert.throws( + () => CapacityWindow.create({ from: 'nope', to: null }), + InvalidCapacityWindowError, + ); +}); + +test('create rechaza from posterior a to', () => { + assert.throws( + () => + CapacityWindow.create({ + from: '2026-01-02T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }), + InvalidCapacityWindowError, + ); +}); + +// #348 — the web localizes by `.code`, not by matching `.message` prose, so +// these two distinct failures within the same class need distinct codes. +test('InvalidCapacityWindowError expone un code distinto por cada invariante (#348)', () => { + try { + CapacityWindow.create({ from: 'nope', to: null }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof InvalidCapacityWindowError)) throw err; + assert.equal(err.code, 'capacity_window_invalid_date'); + } + + try { + CapacityWindow.create({ + from: '2026-01-02T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof InvalidCapacityWindowError)) throw err; + assert.equal(err.code, 'capacity_window_order_invalid'); + } +}); diff --git a/packages/warehouse-core/src/logistics/capacity-window.ts b/packages/warehouse-core/src/logistics/capacity-window.ts index 908b331f..852fc447 100644 --- a/packages/warehouse-core/src/logistics/capacity-window.ts +++ b/packages/warehouse-core/src/logistics/capacity-window.ts @@ -12,6 +12,7 @@ function parseIso(value: string, field: string): Date { if (Number.isNaN(date.getTime())) { throw new InvalidCapacityWindowError( `Capacity window ${field} must be a valid ISO date, got '${value}'`, + 'capacity_window_invalid_date', ); } return date; @@ -41,6 +42,7 @@ export class CapacityWindow { if (fromDate !== null && toDate !== null && fromDate > toDate) { throw new InvalidCapacityWindowError( `Capacity window 'from' (${from}) must not be after 'to' (${to})`, + 'capacity_window_order_invalid', ); } diff --git a/packages/warehouse-core/src/logistics/coverage.test.ts b/packages/warehouse-core/src/logistics/coverage.test.ts new file mode 100644 index 00000000..4c8ef47b --- /dev/null +++ b/packages/warehouse-core/src/logistics/coverage.test.ts @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Coverage } from './coverage.js'; +import { InvalidCoverageError } from './transport-capacity-errors.js'; + +test('area crea una cobertura por área', () => { + const coverage = Coverage.area('Estado Vargas'); + assert.equal(coverage.kind, 'area'); + assert.deepEqual(coverage.toPlain(), { kind: 'area', area: 'Estado Vargas' }); +}); + +test('area rechaza texto vacío', () => { + assert.throws(() => Coverage.area(' '), InvalidCoverageError); +}); + +// #348 — the web localizes by `.code`, not by matching `.message` prose. +test('area expone un code estable para el error de cobertura vacía (#348)', () => { + try { + Coverage.area(''); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof InvalidCoverageError)) throw err; + assert.equal(err.code, 'coverage_area_required'); + } +}); + +test('corridor sin origen ni destino no expone code (aún sin copy en la web)', () => { + try { + Coverage.corridor({ + originResourceId: null, + destinationResourceId: null, + originLat: null, + originLng: null, + destinationLat: null, + destinationLng: null, + }); + assert.fail('should have thrown'); + } catch (err) { + if (!(err instanceof InvalidCoverageError)) throw err; + assert.equal(err.code, undefined); + } +}); diff --git a/packages/warehouse-core/src/logistics/coverage.ts b/packages/warehouse-core/src/logistics/coverage.ts index 9013a5ea..113cf8d1 100644 --- a/packages/warehouse-core/src/logistics/coverage.ts +++ b/packages/warehouse-core/src/logistics/coverage.ts @@ -99,7 +99,10 @@ export class Coverage { static area(area: string): Coverage { if (!area || area.trim().length === 0) { - throw new InvalidCoverageError('Area coverage must not be empty'); + throw new InvalidCoverageError( + 'Area coverage must not be empty', + 'coverage_area_required', + ); } return new Coverage({ kind: 'area', area: area.trim() }); } diff --git a/packages/warehouse-core/src/logistics/transport-capacity-errors.ts b/packages/warehouse-core/src/logistics/transport-capacity-errors.ts index 987ca692..2c612573 100644 --- a/packages/warehouse-core/src/logistics/transport-capacity-errors.ts +++ b/packages/warehouse-core/src/logistics/transport-capacity-errors.ts @@ -7,14 +7,28 @@ export { } from '../kernel/capacity.js'; export class InvalidCoverageError extends Error { - constructor(message: string) { + /** + * Stable identifier for web localization (#348), set only for the subset of + * validation failures the web has copy for (currently just the empty-area + * case) — `undefined` for the rest, which still degrade gracefully to the + * caller's generic fallback message. + */ + constructor( + message: string, + public readonly code?: 'coverage_area_required', + ) { super(message); this.name = 'InvalidCoverageError'; } } export class InvalidCapacityWindowError extends Error { - constructor(message: string) { + constructor( + message: string, + public readonly code: + | 'capacity_window_invalid_date' + | 'capacity_window_order_invalid', + ) { super(message); this.name = 'InvalidCapacityWindowError'; }