Skip to content

Commit 1993b0b

Browse files
josunday002jhayniffy
authored andcommitted
fix(subscriptions): return consistent error shape on failure
Add SubscriptionsExceptionFilter that normalizes all errors to a consistent {statusCode, error, message, timestamp, path} shape. Applied at controller level via @UseFilters.
1 parent c262717 commit 1993b0b

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import {
2+
BadRequestException,
3+
HttpException,
4+
HttpStatus,
5+
NotFoundException,
6+
} from '@nestjs/common';
7+
import {
8+
SubscriptionsExceptionFilter,
9+
SubscriptionErrorResponse,
10+
} from './subscriptions-exception.filter';
11+
12+
describe('SubscriptionsExceptionFilter', () => {
13+
let filter: SubscriptionsExceptionFilter;
14+
let mockJson: jest.Mock;
15+
let mockStatus: jest.Mock;
16+
let mockHost: any;
17+
18+
beforeEach(() => {
19+
filter = new SubscriptionsExceptionFilter();
20+
mockJson = jest.fn();
21+
mockStatus = jest.fn().mockReturnValue({ json: mockJson });
22+
mockHost = {
23+
switchToHttp: () => ({
24+
getResponse: () => ({ status: mockStatus }),
25+
getRequest: () => ({ url: '/v1/subscriptions/checkout', method: 'POST' }),
26+
}),
27+
};
28+
});
29+
30+
function getResponseBody(): SubscriptionErrorResponse {
31+
return mockJson.mock.calls[0][0];
32+
}
33+
34+
it('returns consistent shape for BadRequestException', () => {
35+
filter.catch(new BadRequestException('Invalid plan'), mockHost);
36+
37+
const body = getResponseBody();
38+
expect(mockStatus).toHaveBeenCalledWith(400);
39+
expect(body).toEqual({
40+
statusCode: 400,
41+
error: 'BAD_REQUEST',
42+
message: 'Invalid plan',
43+
timestamp: expect.any(String),
44+
path: '/v1/subscriptions/checkout',
45+
});
46+
});
47+
48+
it('returns consistent shape for NotFoundException', () => {
49+
filter.catch(new NotFoundException('Checkout not found'), mockHost);
50+
51+
const body = getResponseBody();
52+
expect(mockStatus).toHaveBeenCalledWith(404);
53+
expect(body.statusCode).toBe(404);
54+
expect(body.error).toBe('NOT_FOUND');
55+
expect(body.message).toBe('Checkout not found');
56+
expect(body.path).toBe('/v1/subscriptions/checkout');
57+
});
58+
59+
it('returns consistent shape for HttpException with object response', () => {
60+
filter.catch(
61+
new HttpException(
62+
{ error: 'NETWORK_MISMATCH', message: 'Wallet network mismatch' },
63+
HttpStatus.BAD_REQUEST,
64+
),
65+
mockHost,
66+
);
67+
68+
const body = getResponseBody();
69+
expect(body.statusCode).toBe(400);
70+
expect(body.error).toBe('NETWORK_MISMATCH');
71+
expect(body.message).toBe('Wallet network mismatch');
72+
});
73+
74+
it('returns consistent shape for HttpException with string response', () => {
75+
filter.catch(
76+
new HttpException('Something went wrong', HttpStatus.FORBIDDEN),
77+
mockHost,
78+
);
79+
80+
const body = getResponseBody();
81+
expect(body.statusCode).toBe(403);
82+
expect(body.error).toBe('FORBIDDEN');
83+
expect(body.message).toBe('Something went wrong');
84+
});
85+
86+
it('returns 500 for unknown errors', () => {
87+
filter.catch(new Error('unexpected'), mockHost);
88+
89+
const body = getResponseBody();
90+
expect(mockStatus).toHaveBeenCalledWith(500);
91+
expect(body.statusCode).toBe(500);
92+
expect(body.error).toBe('INTERNAL_ERROR');
93+
expect(body.message).toBe('unexpected');
94+
});
95+
96+
it('returns 500 for non-Error thrown values', () => {
97+
filter.catch('string error', mockHost);
98+
99+
const body = getResponseBody();
100+
expect(body.statusCode).toBe(500);
101+
expect(body.error).toBe('INTERNAL_ERROR');
102+
expect(body.message).toBe('Internal server error');
103+
});
104+
105+
it('includes timestamp in ISO format', () => {
106+
filter.catch(new BadRequestException('test'), mockHost);
107+
108+
const body = getResponseBody();
109+
expect(() => new Date(body.timestamp)).not.toThrow();
110+
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
111+
});
112+
113+
it('includes request path', () => {
114+
filter.catch(new BadRequestException('test'), mockHost);
115+
116+
const body = getResponseBody();
117+
expect(body.path).toBe('/v1/subscriptions/checkout');
118+
});
119+
120+
it('handles 429 Too Many Requests', () => {
121+
filter.catch(
122+
new HttpException('Too many requests', HttpStatus.TOO_MANY_REQUESTS),
123+
mockHost,
124+
);
125+
126+
const body = getResponseBody();
127+
expect(body.statusCode).toBe(429);
128+
expect(body.error).toBe('TOO_MANY_REQUESTS');
129+
});
130+
131+
it('joins array messages into single string', () => {
132+
filter.catch(
133+
new HttpException(
134+
{ message: ['field1 is required', 'field2 must be a number'], error: 'Bad Request' },
135+
HttpStatus.BAD_REQUEST,
136+
),
137+
mockHost,
138+
);
139+
140+
const body = getResponseBody();
141+
expect(body.message).toBe('field1 is required; field2 must be a number');
142+
});
143+
144+
it('all error responses have exactly the same keys', () => {
145+
const exceptions = [
146+
new BadRequestException('bad'),
147+
new NotFoundException('missing'),
148+
new HttpException('forbidden', 403),
149+
new Error('crash'),
150+
];
151+
152+
const expectedKeys = ['statusCode', 'error', 'message', 'timestamp', 'path'];
153+
154+
for (const exception of exceptions) {
155+
mockJson.mockClear();
156+
mockStatus.mockClear().mockReturnValue({ json: mockJson });
157+
filter.catch(exception, mockHost);
158+
const body = getResponseBody();
159+
expect(Object.keys(body).sort()).toEqual(expectedKeys.sort());
160+
}
161+
});
162+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
ArgumentsHost,
3+
Catch,
4+
ExceptionFilter,
5+
HttpException,
6+
HttpStatus,
7+
Logger,
8+
} from '@nestjs/common';
9+
import { Request, Response } from 'express';
10+
11+
export interface SubscriptionErrorResponse {
12+
statusCode: number;
13+
error: string;
14+
message: string;
15+
timestamp: string;
16+
path: string;
17+
}
18+
19+
@Catch()
20+
export class SubscriptionsExceptionFilter implements ExceptionFilter {
21+
private readonly logger = new Logger(SubscriptionsExceptionFilter.name);
22+
23+
catch(exception: unknown, host: ArgumentsHost): void {
24+
const ctx = host.switchToHttp();
25+
const res = ctx.getResponse<Response>();
26+
const req = ctx.getRequest<Request>();
27+
28+
const status =
29+
exception instanceof HttpException
30+
? exception.getStatus()
31+
: HttpStatus.INTERNAL_SERVER_ERROR;
32+
33+
const exceptionResponse =
34+
exception instanceof HttpException ? exception.getResponse() : null;
35+
36+
let message = 'Internal server error';
37+
let error = 'INTERNAL_ERROR';
38+
39+
if (exception instanceof HttpException) {
40+
if (typeof exceptionResponse === 'string') {
41+
message = exceptionResponse;
42+
} else if (
43+
typeof exceptionResponse === 'object' &&
44+
exceptionResponse !== null
45+
) {
46+
const resp = exceptionResponse as Record<string, unknown>;
47+
message =
48+
typeof resp.message === 'string'
49+
? resp.message
50+
: Array.isArray(resp.message)
51+
? resp.message.join('; ')
52+
: exception.message;
53+
error = typeof resp.error === 'string' ? resp.error : this.statusToError(status);
54+
}
55+
} else if (exception instanceof Error) {
56+
message = exception.message;
57+
}
58+
59+
if (!error || error === message) {
60+
error = this.statusToError(status);
61+
}
62+
63+
const body: SubscriptionErrorResponse = {
64+
statusCode: status,
65+
error,
66+
message,
67+
timestamp: new Date().toISOString(),
68+
path: req.url,
69+
};
70+
71+
this.logger.warn(
72+
`${req.method} ${req.url} ${status}${message}`,
73+
);
74+
75+
res.status(status).json(body);
76+
}
77+
78+
private statusToError(status: number): string {
79+
const map: Record<number, string> = {
80+
400: 'BAD_REQUEST',
81+
401: 'UNAUTHORIZED',
82+
403: 'FORBIDDEN',
83+
404: 'NOT_FOUND',
84+
409: 'CONFLICT',
85+
422: 'UNPROCESSABLE_ENTITY',
86+
429: 'TOO_MANY_REQUESTS',
87+
500: 'INTERNAL_ERROR',
88+
};
89+
return map[status] ?? 'UNKNOWN_ERROR';
90+
}
91+
}

backend/src/subscriptions/subscriptions.controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Post,
88
Query,
99
Req,
10+
UseFilters,
1011
UseGuards,
1112
UseInterceptors,
1213
} from '@nestjs/common';
@@ -36,8 +37,10 @@ import { SubscriptionsService } from './subscriptions.service';
3637
import { RequireFeatureFlag } from '../feature-flags/feature-flag.decorator';
3738
import { FeatureFlagGuard } from '../feature-flags/feature-flag.guard';
3839
import { Deprecated, DeprecationInterceptor } from '../common/deprecation';
40+
import { SubscriptionsExceptionFilter } from './filters/subscriptions-exception.filter';
3941

4042
@ApiTags('subscriptions')
43+
@UseFilters(new SubscriptionsExceptionFilter())
4144
@UseGuards(ThrottlerGuard)
4245
@Controller({ path: 'subscriptions', version: '1' })
4346
export class SubscriptionsController {

0 commit comments

Comments
 (0)