From 6b4920ce4132e999d0ebb6b79ac9439e3733aaa7 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 28 May 2026 01:07:15 +0530 Subject: [PATCH] feat(api): add RoutesModule with GET/POST/DELETE /routes endpoints Implements RoutesService and RoutesController for SyncRoute CRUD, wires GroupsModule and RoutesModule into AppModule; 11 new tests, all 31 pass. --- apps/api/src/app.module.ts | 4 + .../modules/routes/routes.controller.spec.ts | 51 ++++++++++ .../src/modules/routes/routes.controller.ts | 23 +++++ apps/api/src/modules/routes/routes.module.ts | 9 ++ .../src/modules/routes/routes.service.spec.ts | 93 +++++++++++++++++++ apps/api/src/modules/routes/routes.service.ts | 41 ++++++++ 6 files changed, 221 insertions(+) create mode 100644 apps/api/src/modules/routes/routes.controller.spec.ts create mode 100644 apps/api/src/modules/routes/routes.controller.ts create mode 100644 apps/api/src/modules/routes/routes.module.ts create mode 100644 apps/api/src/modules/routes/routes.service.spec.ts create mode 100644 apps/api/src/modules/routes/routes.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 80fa5b1..0e9c473 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,6 +3,8 @@ import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './modules/health/health.module'; import { SearchModule } from './modules/search/search.module'; +import { GroupsModule } from './modules/groups/groups.module'; +import { RoutesModule } from './modules/routes/routes.module'; @Module({ imports: [ @@ -10,6 +12,8 @@ import { SearchModule } from './modules/search/search.module'; PrismaModule, HealthModule, SearchModule, + GroupsModule, + RoutesModule, ], }) export class AppModule {} diff --git a/apps/api/src/modules/routes/routes.controller.spec.ts b/apps/api/src/modules/routes/routes.controller.spec.ts new file mode 100644 index 0000000..20d4993 --- /dev/null +++ b/apps/api/src/modules/routes/routes.controller.spec.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RoutesController } from './routes.controller'; +import { RoutesService } from './routes.service'; + +const mockRoute = { + id: 'rt_1', + sourceGroupId: 'grp_1', + targetGroupId: 'grp_2', + sourceGroup: { name: 'Alpha' }, + targetGroup: { name: 'Beta' }, +}; +const mockService = { + list: jest.fn().mockResolvedValue([mockRoute]), + create: jest.fn().mockResolvedValue(mockRoute), + remove: jest.fn().mockResolvedValue(undefined), +}; + +describe('RoutesController', () => { + let controller: RoutesController; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + controllers: [RoutesController], + providers: [{ provide: RoutesService, useValue: mockService }], + }).compile(); + controller = module.get(RoutesController); + }); + + it('list() delegates to service with no filter', async () => { + const result = await controller.list(undefined); + expect(result).toEqual([mockRoute]); + expect(mockService.list).toHaveBeenCalledWith(undefined); + }); + + it('list() passes sourceGroupId filter to service', async () => { + await controller.list('grp_1'); + expect(mockService.list).toHaveBeenCalledWith('grp_1'); + }); + + it('create() extracts body fields and delegates to service', async () => { + const result = await controller.create({ sourceGroupId: 'grp_1', targetGroupId: 'grp_2' }); + expect(result).toEqual(mockRoute); + expect(mockService.create).toHaveBeenCalledWith('grp_1', 'grp_2'); + }); + + it('remove() delegates id to service', async () => { + await controller.remove('rt_1'); + expect(mockService.remove).toHaveBeenCalledWith('rt_1'); + }); +}); diff --git a/apps/api/src/modules/routes/routes.controller.ts b/apps/api/src/modules/routes/routes.controller.ts new file mode 100644 index 0000000..c4e9af5 --- /dev/null +++ b/apps/api/src/modules/routes/routes.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common'; +import { RoutesService } from './routes.service'; + +@Controller('routes') +export class RoutesController { + constructor(private readonly routesService: RoutesService) {} + + @Get() + list(@Query('sourceGroupId') sourceGroupId?: string) { + return this.routesService.list(sourceGroupId); + } + + @Post() + create(@Body() body: { sourceGroupId?: string; targetGroupId?: string }) { + return this.routesService.create(body.sourceGroupId ?? '', body.targetGroupId ?? ''); + } + + @Delete(':id') + @HttpCode(204) + remove(@Param('id') id: string) { + return this.routesService.remove(id); + } +} diff --git a/apps/api/src/modules/routes/routes.module.ts b/apps/api/src/modules/routes/routes.module.ts new file mode 100644 index 0000000..e19d174 --- /dev/null +++ b/apps/api/src/modules/routes/routes.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RoutesController } from './routes.controller'; +import { RoutesService } from './routes.service'; + +@Module({ + controllers: [RoutesController], + providers: [RoutesService], +}) +export class RoutesModule {} diff --git a/apps/api/src/modules/routes/routes.service.spec.ts b/apps/api/src/modules/routes/routes.service.spec.ts new file mode 100644 index 0000000..af7ae2c --- /dev/null +++ b/apps/api/src/modules/routes/routes.service.spec.ts @@ -0,0 +1,93 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { RoutesService } from './routes.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +const mockRoute = { + id: 'rt_1', + sourceGroupId: 'grp_1', + targetGroupId: 'grp_2', + isActive: true, + createdAt: new Date(), + sourceGroup: { name: 'Alpha' }, + targetGroup: { name: 'Beta' }, +}; + +describe('RoutesService', () => { + let service: RoutesService; + const mockPrisma = { + syncRoute: { + findMany: jest.fn().mockResolvedValue([mockRoute]), + create: jest.fn().mockResolvedValue(mockRoute), + delete: jest.fn().mockResolvedValue(mockRoute), + }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RoutesService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + service = module.get(RoutesService); + }); + + describe('list', () => { + it('returns all routes with group names', async () => { + const result = await service.list(); + expect(result).toHaveLength(1); + expect(result[0].sourceGroup.name).toBe('Alpha'); + expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith({ + where: undefined, + include: { + sourceGroup: { select: { name: true } }, + targetGroup: { select: { name: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('filters by sourceGroupId when provided', async () => { + await service.list('grp_1'); + expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { sourceGroupId: 'grp_1' } }), + ); + }); + }); + + describe('create', () => { + it('creates a route and returns it with group names', async () => { + const result = await service.create('grp_1', 'grp_2'); + expect(result).toEqual(mockRoute); + expect(mockPrisma.syncRoute.create).toHaveBeenCalledWith({ + data: { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' }, + include: { + sourceGroup: { select: { name: true } }, + targetGroup: { select: { name: true } }, + }, + }); + }); + + it('throws BadRequestException when sourceGroupId is empty', async () => { + await expect(service.create('', 'grp_2')).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when targetGroupId is empty', async () => { + await expect(service.create('grp_1', '')).rejects.toThrow(BadRequestException); + }); + }); + + describe('remove', () => { + it('deletes a route by id', async () => { + await service.remove('rt_1'); + expect(mockPrisma.syncRoute.delete).toHaveBeenCalledWith({ where: { id: 'rt_1' } }); + }); + + it('throws NotFoundException when route does not exist (Prisma P2025)', async () => { + mockPrisma.syncRoute.delete.mockRejectedValueOnce({ code: 'P2025' }); + await expect(service.remove('bad_id')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/modules/routes/routes.service.ts b/apps/api/src/modules/routes/routes.service.ts new file mode 100644 index 0000000..6acb6eb --- /dev/null +++ b/apps/api/src/modules/routes/routes.service.ts @@ -0,0 +1,41 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +const routeInclude = { + sourceGroup: { select: { name: true } }, + targetGroup: { select: { name: true } }, +} as const; + +@Injectable() +export class RoutesService { + constructor(private readonly prisma: PrismaService) {} + + list(sourceGroupId?: string) { + return this.prisma.syncRoute.findMany({ + where: sourceGroupId ? { sourceGroupId } : undefined, + include: routeInclude, + orderBy: { createdAt: 'desc' }, + }); + } + + async create(sourceGroupId: string, targetGroupId: string) { + if (!sourceGroupId || !targetGroupId) { + throw new BadRequestException('sourceGroupId and targetGroupId are required'); + } + return this.prisma.syncRoute.create({ + data: { sourceGroupId, targetGroupId }, + include: routeInclude, + }); + } + + async remove(id: string) { + try { + await this.prisma.syncRoute.delete({ where: { id } }); + } catch (e) { + if ((e as { code?: string })?.code === 'P2025') { + throw new NotFoundException(`Route ${id} not found`); + } + throw e; + } + } +}