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
6 changes: 5 additions & 1 deletion meridian-api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler';
import { APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
import { CustomThrottlerGuard } from './common/guards/custom-throttler.guard';
import { DataSource } from 'typeorm';
Expand Down Expand Up @@ -81,6 +81,8 @@ import { EventsModule } from './events/events.module';
ssl: {
rejectUnauthorized: false,
},
retryAttempts: process.env.NODE_ENV === 'test' ? 1 : 10,
retryDelay: process.env.NODE_ENV === 'test' ? 100 : 3000,
};
}

Expand All @@ -94,6 +96,8 @@ import { EventsModule } from './events/events.module';
database: config.get<string>('POSTGRES_DB'),
synchronize: config.get<string>('POSTGRES_SYNC') === 'true',
autoLoadEntities: config.get<string>('POSTGRES_LOAD') === 'true',
retryAttempts: process.env.NODE_ENV === 'test' ? 1 : 10,
retryDelay: process.env.NODE_ENV === 'test' ? 100 : 3000,
};
},
}),
Expand Down
4 changes: 1 addition & 3 deletions meridian-api/src/auth/providers/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ jest.mock('src/users/user.entity', () => ({ User: class User {} }), {
virtual: true,
});

import {
UnauthorizedException,
} from '@nestjs/common';
import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

describe('AuthService - email verification (issue #435)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
export const VERIFICATION_TTL_MS: number =
(() => {
const hours = Number(process.env.VERIFICATION_TOKEN_TTL_HOURS);
return Number.isFinite(hours) && hours > 0
? hours
: 24;
})() * 60 * 60 * 1000;
return Number.isFinite(hours) && hours > 0 ? hours : 24;
})() *
60 *
60 *
1000;
6 changes: 1 addition & 5 deletions meridian-api/src/auth/providers/verify-email.provider.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, MoreThan, Not, Repository } from 'typeorm';
import { User } from 'src/users/user.entity';
Expand Down
54 changes: 38 additions & 16 deletions meridian-api/src/common/exceptions/validation.exception.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,34 +63,42 @@ describe('flattenValidationErrors', () => {
expect(result).toEqual(
expect.arrayContaining([
{ field: 'password', message: 'weak', constraint: 'matches' },
{ field: 'email', message: 'email must be an email', constraint: 'isEmail' },
{
field: 'email',
message: 'email must be an email',
constraint: 'isEmail',
},
]),
);
});

it('walks nested children with dot-notation paths', () => {
const result = flattenValidationErrors([
vErr('user', {}, [
vErr('profile', {}, [
vErr('email', { isEmail: 'must be valid' }),
]),
vErr('profile', {}, [vErr('email', { isEmail: 'must be valid' })]),
]),
]);
expect(result).toEqual([
{ field: 'user.profile.email', message: 'must be valid', constraint: 'isEmail' },
{
field: 'user.profile.email',
message: 'must be valid',
constraint: 'isEmail',
},
]);
});

it('uses bracket notation for numeric indices (array items)', () => {
const result = flattenValidationErrors([
vErr('users', {}, [
vErr('0', {}, [
vErr('email', { isEmail: 'must be valid' }),
]),
vErr('0', {}, [vErr('email', { isEmail: 'must be valid' })]),
]),
]);
expect(result).toEqual([
{ field: 'users[0].email', message: 'must be valid', constraint: 'isEmail' },
{
field: 'users[0].email',
message: 'must be valid',
constraint: 'isEmail',
},
]);
});

Expand All @@ -99,7 +107,11 @@ describe('flattenValidationErrors', () => {
vErr('user', { isObject: 'user must be an object' }),
]);
expect(result).toEqual([
{ field: 'user', message: 'user must be an object', constraint: 'isObject' },
{
field: 'user',
message: 'user must be an object',
constraint: 'isObject',
},
]);
});

Expand Down Expand Up @@ -155,9 +167,7 @@ describe('validationExceptionFactory (ValidationPipe drop-in)', () => {
}),
vErr('email', { isEmail: 'email must be an email' }),
vErr('users', {}, [
vErr('0', {}, [
vErr('firstName', { minLength: 'must be longer' }),
]),
vErr('0', {}, [vErr('firstName', { minLength: 'must be longer' })]),
]),
]);
const body = ex.getResponse() as Record<string, unknown>;
Expand All @@ -168,9 +178,21 @@ describe('validationExceptionFactory (ValidationPipe drop-in)', () => {
expect(errors).toHaveLength(3);
expect(errors).toEqual(
expect.arrayContaining([
{ field: 'password', message: 'Password must be 8-16 …', constraint: 'matches' },
{ field: 'email', message: 'email must be an email', constraint: 'isEmail' },
{ field: 'users[0].firstName', message: 'must be longer', constraint: 'minLength' },
{
field: 'password',
message: 'Password must be 8-16 …',
constraint: 'matches',
},
{
field: 'email',
message: 'email must be an email',
constraint: 'isEmail',
},
{
field: 'users[0].firstName',
message: 'must be longer',
constraint: 'minLength',
},
]),
);
});
Expand Down
4 changes: 1 addition & 3 deletions meridian-api/src/common/guards/custom-throttler.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { ThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
protected async handleRequest(
requestProps: any,
): Promise<boolean> {
protected async handleRequest(requestProps: any): Promise<boolean> {
const { context, throttler } = requestProps;

const req = context.switchToHttp().getRequest();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,12 @@ export class PaginationQueryDto {
@IsOptional()
@IsPositive()
page?: number = 1;

@ApiPropertyOptional({
description: 'Cursor for keyset pagination (ID of the last item)',
example: 10,
})
@IsOptional()
@IsPositive()
cursor?: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Pagination } from './pagination.provider';
import { Repository } from 'typeorm';
import { REQUEST } from '@nestjs/core';

describe('Pagination Provider (Cursor)', () => {
let provider: Pagination;
let mockRepository: any;
let mockQueryBuilder: any;

beforeEach(async () => {
mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([
{ id: 10, title: 'Post 10' },
{ id: 9, title: 'Post 9' },
{ id: 8, title: 'Post 8' },
]),
getCount: jest.fn().mockResolvedValue(12),
};

mockRepository = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
Pagination,
{
provide: REQUEST,
useValue: {
protocol: 'http',
headers: { host: 'localhost:3000' },
url: '/posts',
},
},
],
}).compile();

provider = module.get<Pagination>(Pagination);
});

it('should be defined', () => {
expect(provider).toBeDefined();
});

describe('paginatedCursorQuery', () => {
it('should load specified relations using leftJoinAndSelect and paginate with cursor', async () => {
const query = { limit: 2, cursor: 12 };
const relations = ['tags', 'author', 'metaOptions'];

const result = await provider.paginatedCursorQuery(
query,
mockRepository as Repository<any>,
relations,
);

// Check query builder setup
expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('entity');

// Verify that N+1 is eliminated by loading relations using joins
for (const rel of relations) {
expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith(
`entity.${rel}`,
rel,
);
}

// Check cursor where condition (id < cursor for DESC order)
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'entity.id < :cursor',
{ cursor: 12 },
);

// Check order and limit (+1 to detect more pages)
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith(
'entity.id',
'DESC',
);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(3);

// Verify query counts: exactly 1 for getMany, 1 for getCount. No N+1!
expect(mockQueryBuilder.getMany).toHaveBeenCalledTimes(1);
expect(mockQueryBuilder.getCount).toHaveBeenCalledTimes(1);

// Check return format
expect(result).toEqual({
data: [
{ id: 10, title: 'Post 10' },
{ id: 9, title: 'Post 9' },
],
nextCursor: 9,
total: 12,
});
});

it('should handle pagination without cursor and date range filtering', async () => {
const startDate = new Date('2026-07-01');
const endDate = new Date('2026-07-18');
const query = { limit: 5, startDate, endDate };

mockQueryBuilder.getMany.mockResolvedValue([
{ id: 1, title: 'First Post' },
]);

const result = await provider.paginatedCursorQuery(
query,
mockRepository as Repository<any>,
);

expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'entity.publishedDate >= :startDate',
{ startDate },
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'entity.publishedDate <= :endDate',
{ endDate },
);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(6);

expect(result).toEqual({
data: [{ id: 1, title: 'First Post' }],
nextCursor: null,
total: 12,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export class Pagination {
take: paginationQueryDto.limit,
});

const baseUrl = this.request.protocol;
+'://' + this.request.headers.host + '/';
const baseUrl =
this.request.protocol + '://' + this.request.headers.host + '/';

const newUrl = new URL(this.request.url, baseUrl);

Expand Down Expand Up @@ -66,4 +66,64 @@ export class Pagination {

return finalResponse;
}

public async paginatedCursorQuery<T extends ObjectLiteral>(
query: {
limit?: number;
cursor?: number;
startDate?: Date;
endDate?: Date;
},
repository: Repository<T>,
relations: string[] = [],
): Promise<{ data: T[]; nextCursor: number | null; total: number }> {
const limit = query.limit || 10;
const { cursor, startDate, endDate } = query;

const queryBuilder = repository.createQueryBuilder('entity');

for (const rel of relations) {
queryBuilder.leftJoinAndSelect(`entity.${rel}`, rel);
}

queryBuilder.orderBy('entity.id', 'DESC');

if (cursor) {
queryBuilder.andWhere('entity.id < :cursor', { cursor });
}

if (startDate) {
queryBuilder.andWhere('entity.publishedDate >= :startDate', {
startDate,
});
}
if (endDate) {
queryBuilder.andWhere('entity.publishedDate <= :endDate', { endDate });
}

queryBuilder.take(limit + 1);

const data = await queryBuilder.getMany();

const hasMore = data.length > limit;
const items = hasMore ? data.slice(0, limit) : data;
const nextCursor = hasMore ? (items[items.length - 1] as any).id : null;

const countBuilder = repository.createQueryBuilder('entity');
if (startDate) {
countBuilder.andWhere('entity.publishedDate >= :startDate', {
startDate,
});
}
if (endDate) {
countBuilder.andWhere('entity.publishedDate <= :endDate', { endDate });
}
const total = await countBuilder.getCount();

return {
data: items,
nextCursor,
total,
};
}
}
Loading
Loading