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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,22 @@ jobs:
run: npm ci

- name: Generate Prisma Client
run: npx prisma generate
run: cd apps/api && npx prisma generate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service
DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service

- name: Deploy Prisma Migrations
run: npx prisma migrate deploy
run: cd apps/api && npx prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service
DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service

- name: Build API
run: npx nx build api
run: npx nx build shared-schema && npx nx build api

- name: Run Tests
run: npx nx run-many --target=test --all --coverage
run: npx nx run-many --target=test --all --coverage --forceExit
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service
DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service
138 changes: 111 additions & 27 deletions apps/api/src/app/commissioning/commissioning.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,120 @@ describe('CommissioningService', () => {
service = new CommissioningService(prisma as unknown as PrismaService);
});

it('flags missing required readings and out-of-range values', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([
{
boilerModelId: 'model-1',
required: true,
minValue: new Prisma.Decimal(10),
maxValue: new Prisma.Decimal(20),
property: { code: 'pressure', label: 'Pressure', unit: 'bar' },
},
]);

const result = await service.validate({
modelId: 'model-1',
readings: [{ code: 'pressure', value: 25 }],
});

expect(result.valid).toBe(false);
expect(result.issues[0].status).toBe('OUT_OF_RANGE');
describe('getReference', () => {
it('throws if model not found', async () => {
prisma.boilerModel.findFirst.mockResolvedValue(null);
await expect(service.getReference('model-1')).rejects.toThrow('Boiler model not found');
});

it('returns formatted references', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([
{
boilerModelId: 'model-1',
required: true,
minValue: new Prisma.Decimal(10),
maxValue: new Prisma.Decimal(20),
property: { code: 'pressure', label: 'Pressure', unit: 'bar' },
},
]);
const result = await service.getReference('model-1');
expect(result.items.length).toBe(1);
expect(result.items[0].code).toBe('pressure');
expect(result.items[0].min).toBe('10.00');
});
});

it('rejects invalid reading payloads', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([]);
describe('validate', () => {
it('throws if modelId is missing', async () => {
await expect(service.validate({ readings: [] } as any)).rejects.toThrow('modelId is required');
});

it('throws if readings missing or not an array', async () => {
await expect(service.validate({ modelId: 'm1' } as any)).rejects.toThrow('readings must be an array');
});

it('throws if model not found', async () => {
prisma.boilerModel.findFirst.mockResolvedValue(null);
await expect(service.validate({ modelId: 'm1', readings: [] })).rejects.toThrow('Boiler model not found');
});

it('throws if reading code is missing', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([]);
await expect(service.validate({ modelId: 'm1', readings: [{ value: 10 } as any] })).rejects.toThrow('readings.code is required');
});

it('flags missing required readings and out-of-range values', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([
{
boilerModelId: 'model-1',
required: true,
minValue: new Prisma.Decimal(10),
maxValue: new Prisma.Decimal(20),
property: { code: 'pressure', label: 'Pressure', unit: 'bar' },
},
{
boilerModelId: 'model-1',
required: true,
minValue: new Prisma.Decimal(5),
maxValue: null,
property: { code: 'flow', label: 'Flow', unit: 'l/m' },
}
]);

const result = await service.validate({
modelId: 'model-1',
readings: [{ code: 'pressure', value: 25 }],
});

expect(result.valid).toBe(false);
expect(result.issues.find((i) => i.code === 'pressure')?.status).toBe('OUT_OF_RANGE');
expect(result.issues.find((i) => i.code === 'flow')?.status).toBe('MISSING');
});

it('rejects invalid reading payloads', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([]);

await expect(
service.validate({
modelId: 'model-1',
readings: [{ code: 'temp', value: Number.NaN }],
}),
).rejects.toBeInstanceOf(BadRequestException);
});

await expect(
service.validate({
it('handles unknown readings', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([]);

const result = await service.validate({
modelId: 'model-1',
readings: [{ code: 'unknown', value: 10 }],
});
expect(result.issues[0].status).toBe('UNKNOWN');
});

it('validates correct readings successfully', async () => {
prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' });
prisma.referenceTable.findMany.mockResolvedValue([
{
boilerModelId: 'model-1',
required: true,
minValue: new Prisma.Decimal(10),
maxValue: new Prisma.Decimal(20),
property: { code: 'pressure', label: 'Pressure', unit: 'bar' },
},
]);

const result = await service.validate({
modelId: 'model-1',
readings: [{ code: 'temp', value: Number.NaN }],
}),
).rejects.toBeInstanceOf(BadRequestException);
readings: [{ code: 'pressure', value: 15 }],
});
expect(result.valid).toBe(true);
expect(result.issues[0].status).toBe('OK');
});
});
});
154 changes: 138 additions & 16 deletions apps/api/src/app/jobs/jobs.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { JobsService } from './jobs.service';
const makePrisma = () => ({
job: {
findMany: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
});

Expand All @@ -23,27 +26,146 @@ describe('JobsService', () => {
service = new JobsService(prisma as any, scheduling as any);
});

it('findAll uses manager scope for managers', async () => {
prisma.job.findMany.mockResolvedValue([]);
describe('findAll', () => {
it('findAll uses manager scope for managers', async () => {
prisma.job.findMany.mockResolvedValue([]);
await service.findAll({ role: UserRole.MANAGER, sub: 'manager-1' } as any);
expect(prisma.job.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { isDeleted: false },
}),
);
});

await service.findAll({ role: UserRole.MANAGER, sub: 'manager-1' } as any);
it('findAll scopes to technician for technicians', async () => {
prisma.job.findMany.mockResolvedValue([]);
await service.findAll({ role: UserRole.TECHNICIAN, sub: 'tech-1' } as any);
expect(prisma.job.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { technicianId: 'tech-1', isDeleted: false },
}),
);
});
});

describe('create', () => {
it('throws if scheduledDate is invalid', async () => {
await expect(service.create({ scheduledDate: 'invalid' } as any)).rejects.toThrow('scheduledDate must be a valid date string');
});

it('throws if estimatedDuration is invalid', async () => {
await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: -5 } as any)).rejects.toThrow('estimatedDuration must be a positive integer');
});

it('throws if technicianId is missing', async () => {
await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120 } as any)).rejects.toThrow('technicianId is required');
});

it('throws if siteId is missing', async () => {
await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1' } as any)).rejects.toThrow('siteId is required');
});

it('throws if status is invalid', async () => {
await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1', siteId: 's1', status: 'INVALID' } as any)).rejects.toThrow('status is invalid');
});

expect(prisma.job.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { isDeleted: false },
}),
);
it('throws if there is a scheduling conflict', async () => {
scheduling.hasConflict.mockResolvedValue(true);
await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1', siteId: 's1' } as any)).rejects.toThrow('Job conflicts with existing schedule');
});

it('creates job successfully', async () => {
scheduling.hasConflict.mockResolvedValue(false);
prisma.job.create.mockResolvedValue({ id: 'j1' } as any);
const res = await service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1', siteId: 's1' } as any);
expect(res.id).toBe('j1');
expect(prisma.job.create).toHaveBeenCalled();
});
});

it('findAll scopes to technician for technicians', async () => {
prisma.job.findMany.mockResolvedValue([]);
describe('findOne', () => {
it('returns job if found for manager', async () => {
prisma.job.findFirst.mockResolvedValue({ id: 'j1' });
const res = await service.findOne('j1', { role: UserRole.MANAGER, sub: 'm1' } as any);
expect(res.id).toBe('j1');
});

it('throws if not found', async () => {
prisma.job.findFirst.mockResolvedValue(null);
await expect(service.findOne('j1', { role: UserRole.MANAGER, sub: 'm1' } as any)).rejects.toThrow('Job not found');
});
});

describe('update', () => {
it('throws if scheduledDate is invalid', async () => {
await expect(service.update('j1', { scheduledDate: 'invalid' } as any, {} as any)).rejects.toThrow('scheduledDate must be a valid date string');
});

it('throws if estimatedDuration is invalid', async () => {
await expect(service.update('j1', { estimatedDuration: -5 } as any, {} as any)).rejects.toThrow('estimatedDuration must be a positive integer');
});

it('throws if status is invalid', async () => {
await expect(service.update('j1', { status: 'INVALID' } as any, {} as any)).rejects.toThrow('status is invalid');
});

it('updates job for manager', async () => {
prisma.job.update.mockResolvedValue({ id: 'j1' });
await service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.MANAGER } as any);
expect(prisma.job.update).toHaveBeenCalled();
});

it('throws NotFound if update fails for manager', async () => {
prisma.job.update.mockRejectedValue(new Error());
await expect(service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.MANAGER } as any)).rejects.toThrow('Job not found');
});

it('throws if technician tries to update job they cannot see', async () => {
prisma.job.findFirst.mockResolvedValue(null);
await expect(service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.TECHNICIAN, sub: 't1' } as any)).rejects.toThrow('Job not found');
});

it('updates job for technician if they can see it', async () => {
prisma.job.findFirst.mockResolvedValue({ id: 'j1' });
prisma.job.update.mockResolvedValue({ id: 'j1' });
await service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.TECHNICIAN, sub: 't1' } as any);
expect(prisma.job.update).toHaveBeenCalled();
});
});

describe('updateStatus', () => {
it('throws if status is missing or invalid', async () => {
await expect(service.updateStatus('j1', {} as any, {} as any)).rejects.toThrow('status is invalid');
});

it('throws if job not found', async () => {
prisma.job.findFirst.mockResolvedValue(null);
await expect(service.updateStatus('j1', { status: 'IN_PROGRESS' } as any, { role: UserRole.MANAGER } as any)).rejects.toThrow('Job not found');
});

it('updates status', async () => {
prisma.job.findFirst.mockResolvedValue({ id: 'j1' });
prisma.job.update.mockResolvedValue({ id: 'j1' });
await service.updateStatus('j1', { status: 'IN_PROGRESS' } as any, { role: UserRole.MANAGER } as any);
expect(prisma.job.update).toHaveBeenCalled();
});
});

await service.findAll({ role: UserRole.TECHNICIAN, sub: 'tech-1' } as any);
describe('remove', () => {
it('throws if job not found', async () => {
prisma.job.findFirst.mockResolvedValue(null);
await expect(service.remove('j1')).rejects.toThrow('Job not found');
});

expect(prisma.job.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { technicianId: 'tech-1', isDeleted: false },
}),
);
it('soft deletes job', async () => {
prisma.job.findFirst.mockResolvedValue({ id: 'j1' });
prisma.job.update.mockResolvedValue({ id: 'j1' });
await service.remove('j1');
expect(prisma.job.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ isDeleted: true }),
})
);
});
});
});
Loading
Loading