Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
NODE_ENV=development
PORT=3000
APP_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:3001

# Optional distributed tracing (OTLP/HTTP). See docs/TRACING.md.
OTEL_TRACING_ENABLED=false
Expand Down
540 changes: 171 additions & 369 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^5.0.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/instrumentation-express": "^0.70.0",
"@opentelemetry/instrumentation-http": "^0.222.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.68.0",
"@opentelemetry/sdk-node": "^0.222.0",
"@nestjs/swagger": "^12.0.2",
"@prisma/client": "6.19.3",
"@stellar/stellar-sdk": "^16.2.0",
"bcrypt": "^6.0.0",
Expand All @@ -63,7 +59,8 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
Expand Down Expand Up @@ -109,6 +106,9 @@
"transformIgnorePatterns": [
"/node_modules/(?!(@stellar/stellar-sdk|@noble|uint8array-extras)/)"
],
"moduleNameMapper": {
"^@nestjs/swagger$": "<rootDir>/../test/mocks/swagger.mock.ts"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
Expand Down
7 changes: 0 additions & 7 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { validate } from './config/env.validation';
Expand All @@ -25,8 +24,6 @@ import { SchedulerModule } from './scheduler/scheduler.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor';
import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor';
import { DomainExceptionFilter } from './common/filters/domain-exception.filter';
import { TracingShutdownService } from './tracing';

@Module({
imports: [
Expand Down Expand Up @@ -63,10 +60,6 @@ import { TracingShutdownService } from './tracing';
provide: APP_INTERCEPTOR,
useClass: RequestTimeoutInterceptor,
},
{
provide: APP_FILTER,
useClass: DomainExceptionFilter,
},
],
})
export class AppModule {}
4 changes: 2 additions & 2 deletions src/audit/audit.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ describe('AuditService (#209)', () => {
it('stores actor, action, entity and timestamp via prisma.auditLog.create', async () => {
const create = jest.fn().mockResolvedValue({ id: 'log-1' });
const prisma = { auditLog: { create } } as never;
const service = new AuditService(prisma as never);
const service = new AuditService(prisma);

await service.record('user-1', 'ticket.revoke', 'Ticket', 'ticket-1', {
eventId: 'event-1',
Expand All @@ -26,7 +26,7 @@ describe('AuditService (#209)', () => {
.fn()
.mockRejectedValue(new Error('audit table unavailable'));
const prisma = { auditLog: { create } } as never;
const service = new AuditService(prisma as never);
const service = new AuditService(prisma);

await expect(
service.record('user-1', 'event.create', 'Event', 'event-1'),
Expand Down
4 changes: 1 addition & 3 deletions src/audit/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ export class AuditService {
): Promise<void> {
if (!this.prisma) return;
const delegate = (this.prisma as unknown as Record<string, unknown>)
.auditLog as
| { create: (args: unknown) => Promise<unknown> }
| undefined;
.auditLog as { create: (args: unknown) => Promise<unknown> } | undefined;
if (!delegate) return;
try {
await delegate.create({
Expand Down
2 changes: 1 addition & 1 deletion src/common/cache/memory-cache.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class MemoryCacheStore implements CacheStore {
json = serializeCacheValue(value);
} catch (err) {
// Reject rather than throw, so callers see the same async contract as Redis.
return Promise.reject(err as Error);
return Promise.reject(err);
}

// Re-insert so a refreshed key counts as the newest for eviction.
Expand Down
7 changes: 7 additions & 0 deletions src/common/dto/pagination-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';

export const DEFAULT_PAGE_LIMIT = 20;
export const MAX_PAGE_LIMIT = 100;
Expand All @@ -10,6 +11,7 @@ export const MAX_PAGE_LIMIT = 100;
*/
export class PaginationQueryDto {
/** 1-based page number. */
@ApiPropertyOptional({ minimum: 1, maximum: 100_000, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
Expand All @@ -19,6 +21,11 @@ export class PaginationQueryDto {
page?: number = 1;

/** Items per page. */
@ApiPropertyOptional({
minimum: 1,
maximum: MAX_PAGE_LIMIT,
default: DEFAULT_PAGE_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt()
Expand Down
205 changes: 205 additions & 0 deletions src/common/filters/global-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { HttpStatus } from '@nestjs/common';
import type { ArgumentsHost } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { GlobalExceptionFilter } from './global-exception.filter';
import {
ListingInactiveError,
TicketTypeSoldOutError,
} from '../errors/domain.error';

describe('GlobalExceptionFilter', () => {
const filter = new GlobalExceptionFilter();

function host() {
const response = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const request = {
method: 'GET',
url: '/test',
};
const argumentsHost = {
switchToHttp: () => ({
getResponse: () => response,
getRequest: () => request,
}),
} as unknown as ArgumentsHost;
return { argumentsHost, response };
}

function createPrismaError(
code: string,
meta?: Record<string, unknown>,
): Prisma.PrismaClientKnownRequestError {
return new Prisma.PrismaClientKnownRequestError('Test error', {
code,
clientVersion: '5.0.0',
meta,
});
}

describe('Prisma errors', () => {
it('maps P2002 to 409 Conflict', () => {
const { argumentsHost, response } = host();

filter.catch(createPrismaError('P2002'), argumentsHost);

expect(response.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.CONFLICT,
code: 'PRISMA_P2002',
message: 'A record with this value already exists',
});
});

it('maps P2025 to 404 Not Found', () => {
const { argumentsHost, response } = host();

filter.catch(createPrismaError('P2025'), argumentsHost);

expect(response.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.NOT_FOUND,
code: 'PRISMA_P2025',
message: 'Record not found',
});
});

it('maps unknown Prisma codes to 500', () => {
const { argumentsHost, response } = host();

filter.catch(createPrismaError('P2003'), argumentsHost);

expect(response.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: 'PRISMA_P2003',
message: 'Database operation failed',
});
});

it('maps Prisma validation error to 400', () => {
const { argumentsHost, response } = host();

const error = new Prisma.PrismaClientValidationError('Invalid', {
clientVersion: '5.0.0',
});
filter.catch(error, argumentsHost);

expect(response.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.BAD_REQUEST,
code: 'PRISMA_VALIDATION_ERROR',
message: 'Invalid data provided',
});
});

it('maps Prisma initialization error to 503', () => {
const { argumentsHost, response } = host();

filter.catch(
new Prisma.PrismaClientInitializationError(
'Connection failed',
'5.0.0',
),
argumentsHost,
);

expect(response.status).toHaveBeenCalledWith(
HttpStatus.SERVICE_UNAVAILABLE,
);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.SERVICE_UNAVAILABLE,
code: 'PRISMA_INITIALIZATION_ERROR',
message: 'Database connection failed',
});
});
});

describe('Domain errors', () => {
it('maps LISTING_INACTIVE to 400', () => {
const { argumentsHost, response } = host();

filter.catch(new ListingInactiveError(), argumentsHost);

expect(response.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.BAD_REQUEST,
code: 'LISTING_INACTIVE',
message: 'This ticket is not listed for resale',
});
});

it('maps TICKET_TYPE_SOLD_OUT to 409', () => {
const { argumentsHost, response } = host();

filter.catch(new TicketTypeSoldOutError(), argumentsHost);

expect(response.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.CONFLICT,
code: 'TICKET_TYPE_SOLD_OUT',
message: 'This ticket type is sold out',
});
});

it('maps unknown Error subclasses to 500 without leaking internals', () => {
const { argumentsHost, response } = host();

class UnknownError extends Error {
code = 'UNKNOWN_CODE';
message = 'Unknown error';
constructor() {
super('Unknown error');
this.name = 'UnknownError';
}
}

filter.catch(new UnknownError(), argumentsHost);

expect(response.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
});
});

describe('Unknown errors', () => {
it('returns 500 without leaking internals', () => {
const { argumentsHost, response } = host();

filter.catch(new Error('Internal secret'), argumentsHost);

expect(response.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
});

it('returns 500 for non-Error values', () => {
const { argumentsHost, response } = host();

filter.catch('string error', argumentsHost);

expect(response.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
expect(response.json).toHaveBeenCalledWith({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
});
});
});
Loading