feat(tests): setup e2e-spec files for route testing. shifts, expenses, timesheets

This commit is contained in:
Matthieu Haineault 2025-08-13 09:57:05 -04:00
parent fd3b9334e3
commit 1949731773
13 changed files with 478 additions and 48 deletions

View File

@ -835,11 +835,24 @@
] ]
}, },
"get": { "get": {
"operationId": "ShiftsController_getSummary", "operationId": "ShiftsController_findAll",
"parameters": [], "parameters": [],
"responses": { "responses": {
"200": { "201": {
"description": "" "description": "List of shifts found",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CreateShiftDto"
}
}
}
}
},
"400": {
"description": "List of shifts not found"
} }
}, },
"security": [ "security": [
@ -847,6 +860,7 @@
"access-token": [] "access-token": []
} }
], ],
"summary": "Find all shifts",
"tags": [ "tags": [
"Shifts" "Shifts"
] ]
@ -1003,6 +1017,25 @@
] ]
} }
}, },
"/shifts/summary": {
"get": {
"operationId": "ShiftsController_getSummary",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Shifts"
]
}
},
"/shifts/export.csv": { "/shifts/export.csv": {
"get": { "get": {
"operationId": "ShiftsController_exportCsv", "operationId": "ShiftsController_exportCsv",
@ -2361,7 +2394,6 @@
"description": "ID number of an bank code (link with bank-codes)" "description": "ID number of an bank code (link with bank-codes)"
}, },
"date": { "date": {
"format": "date-time",
"type": "string", "type": "string",
"example": "3018-10-20T00:00:00.000Z", "example": "3018-10-20T00:00:00.000Z",
"description": "Date where the expense was made" "description": "Date where the expense was made"
@ -2417,7 +2449,6 @@
"description": "ID number of an bank code (link with bank-codes)" "description": "ID number of an bank code (link with bank-codes)"
}, },
"date": { "date": {
"format": "date-time",
"type": "string", "type": "string",
"example": "3018-10-20T00:00:00.000Z", "example": "3018-10-20T00:00:00.000Z",
"description": "Date where the expense was made" "description": "Date where the expense was made"

View File

@ -1,14 +1,14 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { IsBoolean, IsDate, IsDateString, IsInt, IsOptional, IsString } from "class-validator"; import { Allow, IsBoolean, IsDate, IsDateString, IsInt, IsNumber, IsOptional, IsString } from "class-validator";
export class CreateExpenseDto { export class CreateExpenseDto {
@ApiProperty({ @ApiProperty({
example: 1, example: 1,
description: 'Unique ID of the expense (auto-generated)', description: 'Unique ID of the expense (auto-generated)',
}) })
id: number; @Allow()
id?: number;
@ApiProperty({ @ApiProperty({
example: 101, example: 101,
@ -31,17 +31,15 @@ export class CreateExpenseDto {
description: 'Date where the expense was made', description: 'Date where the expense was made',
}) })
@IsDateString() @IsDateString()
@Type(() => Date) date: string;
@IsDate()
date: Date;
@ApiProperty({ @ApiProperty({
example: 17.82, example: 17.82,
description: 'amount in $ for a refund', description: 'amount in $ for a refund',
}) })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsNumber()
amount: number amount: number;
@ApiProperty({ @ApiProperty({
example:'Spent for mileage between A and B', example:'Spent for mileage between A and B',
@ -63,5 +61,6 @@ export class CreateExpenseDto {
description:'Supervisro`s justification for the spending of an employee' description:'Supervisro`s justification for the spending of an employee'
}) })
@IsString() @IsString()
@IsOptional()
supervisor_comment?: string; supervisor_comment?: string;
} }

View File

@ -73,7 +73,7 @@ export class ShiftsController {
return this.shiftsApprovalService.updateApproval(id, isApproved); return this.shiftsApprovalService.updateApproval(id, isApproved);
} }
@Get() @Get('summary')
async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> { async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
return this.shiftsValidationService.getSummary(query.period_id); return this.shiftsValidationService.getSummary(query.period_id);
} }

View File

@ -1,12 +1,13 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { IsDate, IsDateString, IsInt, IsString } from "class-validator"; import { Allow, IsDate, IsDateString, IsInt, IsString } from "class-validator";
export class CreateShiftDto { export class CreateShiftDto {
@ApiProperty({ @ApiProperty({
example: 1, example: 1,
description: 'Unique ID of the shift (auto-generated)', description: 'Unique ID of the shift (auto-generated)',
}) })
@Allow()
id: number; id: number;
@ApiProperty({ @ApiProperty({

View File

@ -29,11 +29,11 @@ export class ShiftsQueryService {
) {} ) {}
async create(dto: CreateShiftDto): Promise<Shifts> { async create(dto: CreateShiftDto): Promise<Shifts> {
const { timesheet_id, bank_code_id, date, start_time, end_time } = dto; const { timesheet_id, bank_code_id, date, start_time, end_time, description } = dto;
//shift creation //shift creation
const shift = await this.prisma.shifts.create({ const shift = await this.prisma.shifts.create({
data: { timesheet_id, bank_code_id, date, start_time, end_time }, data: { timesheet_id, bank_code_id, date, start_time, end_time, description },
include: { timesheet: { include: { employee: { include: { user: true } } } }, include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true, bank_code: true,
}, },
@ -92,7 +92,7 @@ export class ShiftsQueryService {
async update(id: number, dto: UpdateShiftsDto): Promise<Shifts> { async update(id: number, dto: UpdateShiftsDto): Promise<Shifts> {
await this.findOne(id); await this.findOne(id);
const { timesheet_id, bank_code_id, date,start_time,end_time} = dto; const { timesheet_id, bank_code_id, date,start_time,end_time, description} = dto;
return this.prisma.shifts.update({ return this.prisma.shifts.update({
where: { id }, where: { id },
data: { data: {
@ -101,6 +101,7 @@ export class ShiftsQueryService {
...(date !== undefined && { date }), ...(date !== undefined && { date }),
...(start_time !== undefined && { start_time }), ...(start_time !== undefined && { start_time }),
...(end_time !== undefined && { end_time }), ...(end_time !== undefined && { end_time }),
...(description !== undefined && { description }),
}, },
include: { timesheet: { include: { employee: { include: { user: true } } } }, include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true, bank_code: true,

View File

@ -1,13 +1,14 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional } from "class-validator"; import { Allow, IsBoolean, IsInt, IsOptional } from "class-validator";
export class CreateTimesheetDto { export class CreateTimesheetDto {
@ApiProperty({ @ApiProperty({
example: 1, example: 1,
description: 'timesheet`s unique ID (auto-generated)', description: 'timesheet`s unique ID (auto-generated)',
}) })
id: number; @Allow()
id?: number;
@ApiProperty({ @ApiProperty({
example: 426433, example: 426433,

View File

@ -1,25 +1,120 @@
import * as request from 'supertest'; import * as request from 'supertest';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { createApp } from './utils/testing-app'; import { createApp } from './utils/testing-app';
// import { resetDb } from './utils/reset-db';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { makeExpense, makeInvalidExpense } from './factories/expense.factory';
describe('Expenses (e2e)', () => { const BASE = '/Expenses';
describe('Expenses (e2e) — autonome', () => {
let app: INestApplication; let app: INestApplication;
const BASE = '/expenses'; let prisma: PrismaService;
let createdId: number | null = null;
beforeAll(async () => { app = await createApp(); }); let tsId: number;
beforeEach(async () => { let bcExpenseId: number; // categorie='EXPENSE' & type != 'MILEAGE'
// await resetDb(app);
beforeAll(async () => {
app = await createApp();
prisma = app.get(PrismaService);
const ts = await prisma.timesheets.findFirst({ select: { id: true } });
if (!ts) throw new Error('No timesheet found — seed timesheets first.');
tsId = ts.id;
const bc = await prisma.bankCodes.findFirst({
where: {
categorie: 'EXPENSE',
NOT: { type: 'MILEAGE' }, // évite la branche mileageService
},
select: { id: true },
}); });
if (!bc) throw new Error("No non-MILEAGE EXPENSE bank code found — seed bank codes first.");
bcExpenseId = bc.id;
});
afterAll(async () => { afterAll(async () => {
const prisma = app.get(PrismaService); if (createdId) {
try { await prisma.expenses.delete({ where: { id: createdId } }); } catch {}
}
await app.close(); await app.close();
await prisma.$disconnect(); await prisma.$disconnect();
}); });
it(`GET ${BASE} → 200`, async () => { it(`GET ${BASE} → 200 (array)`, async () => {
const res = await request(app.getHttpServer()).get(BASE); const res = await request(app.getHttpServer()).get(BASE);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it(`POST ${BASE} (valid) → 201 puis GET /:id → 200`, async () => {
const payload = makeExpense(tsId, bcExpenseId);
const createRes = await request(app.getHttpServer()).post(BASE).send(payload);
if (createRes.status !== 201) {
console.log('Create error:', createRes.body || createRes.text);
}
expect(createRes.status).toBe(201);
expect(createRes.body).toEqual(
expect.objectContaining({
id: expect.any(Number),
timesheet_id: tsId,
bank_code_id: bcExpenseId,
})
);
createdId = createRes.body.id;
const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`);
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual(expect.objectContaining({ id: createdId }));
});
it(`PATCH ${BASE}/:id → 200 (amount/description mis à jour)`, async () => {
if (!createdId) {
const created = await request(app.getHttpServer())
.post(BASE)
.send(makeExpense(tsId, bcExpenseId));
expect(created.status).toBe(201);
createdId = created.body.id;
}
const updated = { amount: 123, description: 'Updated expense' }; // amount INT
const patchRes = await request(app.getHttpServer())
.patch(`${BASE}/${createdId}`)
.send(updated);
expect([200, 204]).toContain(patchRes.status);
const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`);
expect(getRes.status).toBe(200);
if (getRes.body?.amount !== undefined) {
expect(Number(getRes.body.amount)).toBe(updated.amount);
}
if (getRes.body?.description !== undefined) expect(getRes.body.description).toBe(updated.description);
});
it(`GET ${BASE}/999999 (not found) → 404/400`, async () => {
const res = await request(app.getHttpServer()).get(`${BASE}/999999`);
expect([404, 400]).toContain(res.status);
});
it(`POST ${BASE} (invalid payload) → 400`, async () => {
const res = await request(app.getHttpServer()).post(BASE).send(makeInvalidExpense());
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
it(`DELETE ${BASE}/:id → 200/204`, async () => {
let id = createdId;
if (!id) {
const created = await request(app.getHttpServer())
.post(BASE)
.send(makeExpense(tsId, bcExpenseId));
expect(created.status).toBe(201);
id = created.body.id;
}
const del = await request(app.getHttpServer()).delete(`${BASE}/${id}`);
expect([200, 204]).toContain(del.status);
if (createdId === id) createdId = null;
}); });
}); });

View File

@ -0,0 +1,37 @@
// test/factories/expense.factory.ts
export type ExpensePayload = {
timesheet_id: number;
bank_code_id: number; // bank code categorie='EXPENSE' and type != 'MILEAGE'
date: string; // ISO date
amount: number; // INT (DTO: @IsInt)
description: string;
is_approved?: boolean;
supervisor_comment?: string;
};
const randInt = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1)) + min;
const isoDate = (y: number, m: number, d: number) =>
new Date(Date.UTC(y, m - 1, d, 0, 0, 0)).toISOString();
export function makeExpense(
tsId: number,
bcId: number,
overrides: Partial<ExpensePayload> = {}
): ExpensePayload {
return {
timesheet_id: tsId,
bank_code_id: bcId,
date: isoDate(2024, 6, randInt(1, 28)),
amount: randInt(5, 200),
description: `Expense ${randInt(100, 999)}`,
supervisor_comment: 'N/A',
...overrides,
};
}
// volontairement invalide pour 400 via ValidationPipe
export function makeInvalidExpense(): Record<string, unknown> {
return { amount: 'oops', timesheet_id: 'nope' };
}

View File

@ -0,0 +1,43 @@
// test/factories/shift.factory.ts
export type ShiftPayload = {
timesheet_id: number;
bank_code_id: number;
date: string; // ISO date (jour)
start_time: string; // ISO datetime (heure)
end_time: string; // ISO datetime (heure)
description: string; // requis par le DTO
};
const randInt = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1)) + min;
const isoDate = (y: number, m: number, d: number) =>
new Date(Date.UTC(y, m - 1, d, 0, 0, 0)).toISOString();
const isoTime = (h: number, m = 0) =>
new Date(Date.UTC(1970, 0, 1, h, m, 0)).toISOString();
export function makeShift(
tsId: number,
bcId: number,
overrides: Partial<ShiftPayload> = {}
): ShiftPayload {
// 8h pile pour ne pas dépasser DAILY_LIMIT_HOURS (8 par défaut)
const startH = 8;
const endH = 16;
return {
timesheet_id: tsId,
bank_code_id: bcId,
date: isoDate(2024, 5, randInt(1, 28)),
start_time: isoTime(startH),
end_time: isoTime(endH),
description: `Shift ${randInt(100, 999)}`,
...overrides,
};
}
// payload invalide pour 400 via ValidationPipe
export function makeInvalidShift(): Record<string, unknown> {
return { timesheet_id: 'nope' }; // types volontairement faux
}

View File

@ -0,0 +1,39 @@
// test/factories/timesheet.factory.ts
export type TimesheetPayload = {
employee_id: number;
is_approved?: boolean;
};
/**
* Construit un payload valide pour POST /timesheets.
* Par défaut, is_approved=false.
*/
export function makeTimesheet(
employeeId: number,
overrides: Partial<TimesheetPayload> = {}
): TimesheetPayload {
return {
employee_id: employeeId,
is_approved: false,
...overrides,
};
}
/**
* Payload délibérément invalide pour déclencher un 400 via ValidationPipe.
*/
export function makeInvalidTimesheet(): Record<string, unknown> {
return { employee_id: 'not-a-number' };
}
/**
* Helper pour récupérer un employee_id existant
* sans importer les types Prisma dans le factory.
*/
export async function pickAnyEmployeeId(
prisma: { employees: { findFirst: (args?: any) => Promise<{ id: number } | null> } }
): Promise<number | null> {
const emp = await prisma.employees.findFirst({ select: { id: true } });
return emp?.id ?? null;
}

View File

@ -1,25 +1,121 @@
import * as request from 'supertest'; import * as request from 'supertest';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { createApp } from './utils/testing-app'; import { createApp } from './utils/testing-app';
// import { resetDb } from './utils/reset-db';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { makeShift, makeInvalidShift } from './factories/shift.factory';
describe('Shifts (e2e)', () => { const BASE = '/shifts';
describe('Shifts (e2e) — autonome', () => {
let app: INestApplication; let app: INestApplication;
const BASE = '/shifts'; let prisma: PrismaService;
let createdId: number | null = null;
beforeAll(async () => { app = await createApp(); }); // FKs existants
beforeEach(async () => { let tsId: number; // any timesheet
// await resetDb(app); let bcShiftId: number; // any bank code with categorie='SHIFT'
beforeAll(async () => {
app = await createApp();
prisma = app.get(PrismaService);
// récupère un timesheet existant
const ts = await prisma.timesheets.findFirst({ select: { id: true } });
if (!ts) throw new Error('No timesheet found — seed timesheets first.');
tsId = ts.id;
// récupère un bank code SHIFT (ta seed utilise 'SHIFT' en MAJ)
const bc = await prisma.bankCodes.findFirst({
where: { categorie: 'SHIFT' },
select: { id: true },
}); });
if (!bc) throw new Error('No SHIFT bank code found — seed bank codes first.');
bcShiftId = bc.id;
});
afterAll(async () => { afterAll(async () => {
const prisma = app.get(PrismaService); if (createdId) {
try {
await prisma.shifts.delete({ where: { id: createdId } });
} catch {}
}
await app.close(); await app.close();
await prisma.$disconnect(); await prisma.$disconnect();
}); });
it(`GET ${BASE} → 200`, async () => { it(`POST ${BASE} (valid) → 201 puis GET /:id → 200`, async () => {
const res = await request(app.getHttpServer()).get(BASE); const payload = makeShift(tsId, bcShiftId);
expect(res.status).toBe(200);
const createRes = await request(app.getHttpServer())
.post(BASE)
.send(payload);
if (createRes.status !== 201) {
console.log('Create error:', createRes.body || createRes.text);
}
expect(createRes.status).toBe(201);
expect(createRes.body).toEqual(
expect.objectContaining({
id: expect.any(Number),
timesheet_id: tsId,
bank_code_id: bcShiftId,
})
);
createdId = createRes.body.id;
const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`);
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual(expect.objectContaining({ id: createdId }));
});
it(`PATCH ${BASE}/:id → 200 (description mise à jour)`, async () => {
if (!createdId) {
const created = await request(app.getHttpServer())
.post(BASE)
.send(makeShift(tsId, bcShiftId));
expect(created.status).toBe(201);
createdId = created.body.id;
}
const patchRes = await request(app.getHttpServer())
.patch(`${BASE}/${createdId}`)
.send({ description: 'Updated shift description' });
expect([200, 204]).toContain(patchRes.status);
const getRes = await request(app.getHttpServer()).get(`${BASE}/${createdId}`);
expect(getRes.status).toBe(200);
// on tolère labsence, mais si elle est là, on vérifie la valeur.
if (getRes.body?.description !== undefined) {
expect(getRes.body.description).toBe('Updated shift description');
}
});
it(`GET ${BASE}/999999 (not found) → 404/400`, async () => {
const res = await request(app.getHttpServer()).get(`${BASE}/999999`);
expect([404, 400]).toContain(res.status);
});
it(`POST ${BASE} (invalid payload) → 400`, async () => {
const res = await request(app.getHttpServer())
.post(BASE)
.send(makeInvalidShift());
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
it(`DELETE ${BASE}/:id → 200/204`, async () => {
let id = createdId;
if (!id) {
const created = await request(app.getHttpServer())
.post(BASE)
.send(makeShift(tsId, bcShiftId));
expect(created.status).toBe(201);
id = created.body.id;
}
const del = await request(app.getHttpServer()).delete(`${BASE}/${id}`);
expect([200, 204]).toContain(del.status);
if (createdId === id) createdId = null;
}); });
}); });

View File

@ -1,17 +1,34 @@
import * as request from 'supertest'; // test/timesheets.e2e-spec.ts
const request = require('supertest');
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { createApp } from './utils/testing-app';
// import { resetDb } from './utils/reset-db';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import {
makeTimesheet,
makeInvalidTimesheet,
} from './factories/timesheet.factory';
import { makeEmployee } from './factories/employee.factory';
import { createApp } from './utils/testing-app';
describe('Timesheets (e2e)', () => { describe('Timesheets (e2e) — autonome', () => {
let app: INestApplication;
const BASE = '/timesheets'; const BASE = '/timesheets';
let app: INestApplication;
let employeeIdForCreation: number;
beforeAll(async () => { app = await createApp(); }); beforeAll(async () => {
beforeEach(async () => { app = await createApp();
// await resetDb(app);
// ✅ Crée un employé dédié pour cette suite
const empRes = await request(app.getHttpServer())
.post('/employees')
.send(makeEmployee());
if (empRes.status !== 201) {
// eslint-disable-next-line no-console
console.warn('Impossible de créer un employé pour les tests timesheets:', empRes.body || empRes.text);
throw new Error('Setup employé échoué');
}
employeeIdForCreation = empRes.body.id;
}); });
afterAll(async () => { afterAll(async () => {
const prisma = app.get(PrismaService); const prisma = app.get(PrismaService);
await app.close(); await app.close();
@ -21,5 +38,75 @@ describe('Timesheets (e2e)', () => {
it(`GET ${BASE} → 200`, async () => { it(`GET ${BASE} → 200`, async () => {
const res = await request(app.getHttpServer()).get(BASE); const res = await request(app.getHttpServer()).get(BASE);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it(`POST ${BASE} (valid) → 201 puis GET /:id → 200`, async () => {
const payload = makeTimesheet(employeeIdForCreation, { is_approved: true });
const createRes = await request(app.getHttpServer()).post(BASE).send(payload);
if (createRes.status !== 201) {
// eslint-disable-next-line no-console
console.log('Create error:', createRes.body || createRes.text);
}
expect(createRes.status).toBe(201);
expect(createRes.body).toEqual(
expect.objectContaining({
id: expect.any(Number),
employee_id: payload.employee_id,
}),
);
const id = createRes.body.id;
const getRes = await request(app.getHttpServer()).get(`${BASE}/${id}`);
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual(
expect.objectContaining({
id,
employee_id: payload.employee_id,
}),
);
});
it(`PATCH ${BASE}/:id → 200 (is_approved toggled)`, async () => {
const created = await request(app.getHttpServer())
.post(BASE)
.send(makeTimesheet(employeeIdForCreation));
expect(created.status).toBe(201);
const id = created.body.id;
const updated = await request(app.getHttpServer())
.patch(`${BASE}/${id}`)
.send({ is_approved: true });
expect(updated.status).toBe(200);
expect(updated.body).toEqual(
expect.objectContaining({
id,
is_approved: true,
}),
);
});
it(`POST ${BASE} (invalid payload) → 400`, async () => {
const bad = await request(app.getHttpServer())
.post(BASE)
.send(makeInvalidTimesheet());
expect(bad.status).toBeGreaterThanOrEqual(400);
expect(bad.status).toBeLessThan(500);
});
it(`DELETE ${BASE}/:id → 200/204`, async () => {
const created = await request(app.getHttpServer())
.post(BASE)
.send(makeTimesheet(employeeIdForCreation));
expect(created.status).toBe(201);
const id = created.body.id;
const delRes = await request(app.getHttpServer()).delete(`${BASE}/${id}`);
expect([200, 204]).toContain(delRes.status);
const getRes = await request(app.getHttpServer()).get(`${BASE}/${id}`);
expect(getRes.status).toBe(404);
}); });
}); });