test(goals): add unit tests for GoalsService
This commit is contained in:
parent
281fb9e780
commit
19fd999441
1 changed files with 230 additions and 0 deletions
230
src/goals/goals.service.spec.ts
Normal file
230
src/goals/goals.service.spec.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { GoalsService } from './goals.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { OutboxService } from '../events/outbox.service';
|
||||||
|
|
||||||
|
// Mock the Drizzle db module so tests never touch a real database
|
||||||
|
jest.mock('../db/client', () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
goals: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { db } from '../db/client';
|
||||||
|
|
||||||
|
const mockAuditService = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const mockOutboxService = {
|
||||||
|
record: jest.fn().mockResolvedValue(undefined),
|
||||||
|
withTransaction: jest.fn().mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => fn({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeSession = {
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeGoal = {
|
||||||
|
id: 'goal-uuid-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
horizon: 'Q4 2026',
|
||||||
|
metric: 'MRR',
|
||||||
|
target: '100k EUR',
|
||||||
|
milestones: [],
|
||||||
|
status: 'not_started',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GoalsService', () => {
|
||||||
|
let service: GoalsService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
GoalsService,
|
||||||
|
{ provide: AuditService, useValue: mockAuditService },
|
||||||
|
{ provide: OutboxService, useValue: mockOutboxService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get<GoalsService>(GoalsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('returns goals for the session tenant', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findMany as jest.Mock).mockResolvedValue([fakeGoal]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.list(fakeSession as any);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual([fakeGoal]);
|
||||||
|
expect(db.query.goals.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status when provided', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findMany as jest.Mock).mockResolvedValue([]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.list(fakeSession as any, 'on_track');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(db.query.goals.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns the goal when found', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findFirst as jest.Mock).mockResolvedValue(fakeGoal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.getById(fakeSession as any, fakeGoal.id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual(fakeGoal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when goal does not exist', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findFirst as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(service.getById(fakeSession as any, 'non-existent-id')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('creates a goal and writes audit + outbox event', async () => {
|
||||||
|
// Arrange
|
||||||
|
const insertedRow = { ...fakeGoal };
|
||||||
|
mockOutboxService.withTransaction.mockImplementationOnce(
|
||||||
|
async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||||
|
// Simulate tx.insert().values().returning()
|
||||||
|
const mockTx = {
|
||||||
|
insert: jest.fn().mockReturnValue({
|
||||||
|
values: jest.fn().mockReturnValue({
|
||||||
|
returning: jest.fn().mockResolvedValue([insertedRow]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return fn(mockTx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.create(fakeSession as any, {
|
||||||
|
horizon: 'Q4 2026',
|
||||||
|
metric: 'MRR',
|
||||||
|
target: '100k EUR',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual(insertedRow);
|
||||||
|
expect(mockAuditService.record).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ action: 'goal.created', tenantId: 'tenant-1' }),
|
||||||
|
);
|
||||||
|
expect(mockOutboxService.record).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ eventType: 'goal.created', aggregateType: 'goal' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates status and records audit action with new status', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findFirst as jest.Mock).mockResolvedValue(fakeGoal);
|
||||||
|
const updatedRow = { ...fakeGoal, status: 'achieved' };
|
||||||
|
mockOutboxService.withTransaction.mockImplementationOnce(
|
||||||
|
async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||||
|
const mockTx = {
|
||||||
|
update: jest.fn().mockReturnValue({
|
||||||
|
set: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnValue({
|
||||||
|
returning: jest.fn().mockResolvedValue([updatedRow]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return fn(mockTx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.update(fakeSession as any, fakeGoal.id, { status: 'achieved' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual(updatedRow);
|
||||||
|
expect(mockAuditService.record).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ action: 'goal.status_changed:achieved' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when goal not found before update', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findFirst as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(
|
||||||
|
service.update(fakeSession as any, 'bad-id', { horizon: 'X' }),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('softDelete', () => {
|
||||||
|
it('sets deletedAt and records audit', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findFirst as jest.Mock).mockResolvedValue(fakeGoal);
|
||||||
|
mockOutboxService.withTransaction.mockImplementationOnce(
|
||||||
|
async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||||
|
const mockTx = {
|
||||||
|
update: jest.fn().mockReturnValue({
|
||||||
|
set: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return fn(mockTx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.softDelete(fakeSession as any, fakeGoal.id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ deleted: true });
|
||||||
|
expect(mockAuditService.record).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ action: 'goal.deleted' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when goal not found before delete', async () => {
|
||||||
|
// Arrange
|
||||||
|
(db.query.goals.findFirst as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(service.softDelete(fakeSession as any, 'bad-id')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue