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.
This commit is contained in:
@@ -3,6 +3,8 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { SearchModule } from './modules/search/search.module';
|
import { SearchModule } from './modules/search/search.module';
|
||||||
|
import { GroupsModule } from './modules/groups/groups.module';
|
||||||
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -10,6 +12,8 @@ import { SearchModule } from './modules/search/search.module';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
GroupsModule,
|
||||||
|
RoutesModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -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>(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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user