Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ apps/api/uploads/

# Local git worktrees for parallel agent work — never commit
.worktrees/
.claude/worktrees/
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/contexts/needs/domain/need-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
3 changes: 3 additions & 0 deletions apps/api/src/contexts/needs/domain/need-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,6 +53,6 @@ export class NeedsDomainExceptionFilter implements ExceptionFilter {
: HttpStatus.CONFLICT;
response
.status(statusCode)
.json({ statusCode, message: exception.message });
.json(domainErrorResponseBody(statusCode, exception));
}
}
16 changes: 16 additions & 0 deletions apps/api/src/contexts/offers/application/submit-offer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/contexts/offers/application/submit-offer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,19 @@ 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';
}
}

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}'`,
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/contexts/offers/domain/offer-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
3 changes: 3 additions & 0 deletions apps/api/src/contexts/offers/domain/offer-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -120,6 +121,6 @@ export class OffersDomainExceptionFilter implements ExceptionFilter {
: HttpStatus.CONFLICT;
response
.status(statusCode)
.json({ statusCode, message: exception.message });
.json(domainErrorResponseBody(statusCode, exception));
}
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}
Loading
Loading