Skip to content

Commit

Permalink
Merge pull request #7 from Innovix-Matrix-Systems/IMS-19
Browse files Browse the repository at this point in the history
minor bugs fix and improvements
  • Loading branch information
AHS12 authored Oct 24, 2024
2 parents fea4018 + c67aa6d commit 227433f
Show file tree
Hide file tree
Showing 18 changed files with 562 additions and 86 deletions.
16 changes: 13 additions & 3 deletions ims-nest-api-starter.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "# IMS-NEST-API-STARTER API Documentation\n\n## Introduction\n\nWelcome to the api documentation.\n`ims-nest-api-starter` is a backend API starter template using [NestJS](https://nestjs.com/) and [MikroORM](https://mikro-orm.io/) designed for scalable applications. This starter includes authentication, authorization, user management, role management, and role/permission-based access",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "29444436",
"_collection_link": "https://innovix-matrix-system.postman.co/workspace/c0ff4f3a-4d52-490d-81f4-c33d60e51246/collection/29444436-0f10bdbf-368f-47c9-8a59-e7d1586fae52?action=share&source=collection_link&creator=29444436"
"_collection_link": "https://innovix-matrix-system.postman.co/workspace/Innovix-Matrix-System-Workspace~c0ff4f3a-4d52-490d-81f4-c33d60e51246/collection/29444436-0f10bdbf-368f-47c9-8a59-e7d1586fae52?action=share&source=collection_link&creator=29444436"
},
"item": [
{
Expand Down Expand Up @@ -284,7 +284,7 @@
"query": [
{
"key": "search",
"value": "trevor",
"value": "john",
"description": "search my name, email",
"disabled": true
},
Expand All @@ -301,6 +301,16 @@
{
"key": "perPage",
"value": "10"
},
{
"key": "orderBy",
"value": "createdAt",
"disabled": true
},
{
"key": "orderDirection",
"value": "desc",
"disabled": true
}
]
}
Expand Down Expand Up @@ -467,7 +477,7 @@
"variable": [
{
"key": "id",
"value": "48"
"value": "4"
}
]
}
Expand Down
13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"xsecurity:install": "cross-env CLI_PATH=./dist/cli.js npx nestjs-command xsecurity:install",
"prepare": "husky",
"create:module": "cross-env CLI_PATH=./dist/cli.js npx nestjs-command create:module"

},
"dependencies": {
"@faker-js/faker": "^9.0.3",
Expand Down Expand Up @@ -97,20 +96,24 @@
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"testRegex": "modules/.*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
"modules/**/*.(t|j)s",
"!**/*.d.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"modulePathIgnorePatterns": [
"types.d.ts"
]
},
"mikro-orm": {
"useTsNode": true,
"configPaths": [
"./src/config/mikro-orm.config.ts"
]
}
}
}
4 changes: 1 addition & 3 deletions src/common/controllers/base.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Response } from 'express';
export class BaseController {
sendPaginatedResponse(
data: any[],
pageData: PaginatedMeta,
pageData: PaginationMeta,
message = '',
statusCode = HttpStatus.OK,
res: Response,
Expand All @@ -18,8 +18,6 @@ export class BaseController {
currentPage: pageData.currentPage,
from: pageData.from,
lastPage: pageData.lastPage,
// links: pageData.links || [],
// path: pageData.path || '',
perPage: pageData.perPage,
to: pageData.to,
total: pageData.total,
Expand Down
190 changes: 190 additions & 0 deletions src/modules/misc/filter.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { EntityRepository } from '@mikro-orm/core';
import { Test, TestingModule } from '@nestjs/testing';
import { FilterService } from './filter.service';
import { MiscModule } from './misc.module';
import { PaginationService } from './pagination.service';

describe('FilterService', () => {
let service: FilterService;
let mockPaginationService: jest.Mocked<PaginationService>;
let mockRepository: jest.Mocked<EntityRepository<any>>;

beforeEach(async () => {
mockRepository = {
findAndCount: jest.fn(),
} as any;

mockPaginationService = {
buildPaginationOptions: jest.fn().mockReturnValue({
limit: 10,
offset: 0,
}),
buildPaginationMeta: jest.fn().mockReturnValue({
currentPage: 1,
from: 1,
lastPage: 1,
perPage: 10,
to: 10,
total: 10,
}),
} as any;

const module: TestingModule = await Test.createTestingModule({
imports: [MiscModule],
providers: [
FilterService,
{
provide: PaginationService,
useValue: mockPaginationService,
},
],
}).compile();

service = module.get<FilterService>(FilterService);
});

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

describe('filter', () => {
it('should return paginated results with default parameters', async () => {
const mockData = [{ id: 1, name: 'Test' }];
mockRepository.findAndCount.mockResolvedValue([mockData, 1]);

const result = await service.filter(
mockRepository,
{},
['name'],
['roles'],
);

expect(result).toEqual({
data: mockData,
meta: expect.any(Object),
});
expect(mockRepository.findAndCount).toHaveBeenCalledWith(
{},
expect.objectContaining({
limit: 10,
offset: 0,
orderBy: { createdAt: 'ASC' },
}),
);
});

it('should apply search filters correctly', async () => {
const mockData = [{ id: 1, name: 'Test' }];
mockRepository.findAndCount.mockResolvedValue([mockData, 1]);

await service.filter(
mockRepository,
{
search: 'test',
searchFields: ['name', 'email'],
},
['name'],
['roles'],
);

expect(mockRepository.findAndCount).toHaveBeenCalledWith(
{
$or: [
{ name: { $ilike: '%test%' } },
{ email: { $ilike: '%test%' } },
],
},
expect.any(Object),
);
});

it('should apply select filters correctly', async () => {
const mockData = [{ id: 1, name: 'Test' }];
mockRepository.findAndCount.mockResolvedValue([mockData, 1]);

await service.filter(
mockRepository,
{
selectFields: [{ status: 'active' }],
},
['name'],
['roles'],
);

expect(mockRepository.findAndCount).toHaveBeenCalledWith(
{ status: 'active' },
expect.any(Object),
);
});

it('should apply ordering correctly', async () => {
const mockData = [{ id: 1, name: 'Test' }];
mockRepository.findAndCount.mockResolvedValue([mockData, 1]);

await service.filter(
mockRepository,
{
orderBy: 'name',
orderDirection: 'DESC',
},
['name'],
['roles'],
);

expect(mockRepository.findAndCount).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
orderBy: { name: 'DESC' },
}),
);
});

it('should use default ordering when invalid order field is provided', async () => {
const mockData = [{ id: 1, name: 'Test' }];
mockRepository.findAndCount.mockResolvedValue([mockData, 1]);

await service.filter(
mockRepository,
{
orderBy: 'invalid_field',
orderDirection: 'DESC',
},
['name'],
['roles'],
);

expect(mockRepository.findAndCount).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
orderBy: { createdAt: 'ASC' },
}),
);
});

it('should combine search and select filters with $and', async () => {
const mockData = [{ id: 1, name: 'Test' }];
mockRepository.findAndCount.mockResolvedValue([mockData, 1]);

await service.filter(
mockRepository,
{
search: 'test',
searchFields: ['name'],
selectFields: [{ status: 'active' }],
},
['name'],
['roles'],
);

expect(mockRepository.findAndCount).toHaveBeenCalledWith(
{
$and: [
{ $or: [{ name: { $ilike: '%test%' } }] },
{ status: 'active' },
],
},
expect.any(Object),
);
});
});
});
105 changes: 105 additions & 0 deletions src/modules/misc/filter.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
EntityRepository,
FilterQuery,
OrderDefinition,
Populate,
} from '@mikro-orm/core';
import { Injectable } from '@nestjs/common';
import { PaginationService } from './pagination.service';

@Injectable()
export class FilterService {
constructor(private readonly paginationService: PaginationService) {}

async filter<T extends object>(
repository: EntityRepository<T>,
params: FilterWithPaginationParams,
validOrderFields: string[] = [],
populate: string[] = [],
): Promise<PaginatedResult<T>> {
const where = this.buildWhereClause<T>(
params.search,
params.searchFields,
params.selectFields,
);

const orderBy = this.buildOrderOptions(
validOrderFields,
params.orderBy,
params.orderDirection,
);

const { limit, offset } = this.paginationService.buildPaginationOptions({
page: params.page,
perPage: params.perPage,
});

const [results, totalCount] = await repository.findAndCount(where, {
populate: populate as unknown as Populate<T>,
limit,
offset,
orderBy,
});

const meta = this.paginationService.buildPaginationMeta(
params.page || 1,
params.perPage || 10,
totalCount,
);

return {
data: results,
meta,
};
}

private buildWhereClause<T>(
search?: string,
searchFields?: string[],
selectFields?: Array<{ [key: string]: boolean | number | string }>,
): FilterQuery<T> {
if (!search && (!selectFields || selectFields.length === 0)) {
return {} as FilterQuery<T>;
}

const conditions: any[] = [];

if (search && searchFields?.length > 0) {
const searchConditions = searchFields.map((field) => ({
[field]: { $ilike: `%${search}%` },
}));
conditions.push({ $or: searchConditions });
}

if (selectFields?.length > 0) {
selectFields.forEach((field) => {
conditions.push(field);
});
}

if (conditions.length > 1) {
return { $and: conditions } as FilterQuery<T>;
}

if (conditions.length === 1) {
return conditions[0] as FilterQuery<T>;
}

return {} as FilterQuery<T>;
}

private buildOrderOptions<T>(
validOrderFields: string[],
orderBy?: string,
orderDirection: 'ASC' | 'DESC' = 'ASC',
): OrderDefinition<T> {
const defaultOrderField = 'createdAt';
const defaultOrderDirection = 'ASC' as const;

if (orderBy && validOrderFields.includes(orderBy)) {
return { [orderBy]: orderDirection } as OrderDefinition<T>;
}

return { [defaultOrderField]: defaultOrderDirection } as OrderDefinition<T>;
}
}
Loading

0 comments on commit 227433f

Please sign in to comment.