From 19fd999441e09c4895d03b05ad13205717bb7bba Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Fri, 31 Jul 2026 10:47:15 +0000 Subject: [PATCH] test(goals): add unit tests for GoalsService --- src/goals/goals.service.spec.ts | 230 ++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/goals/goals.service.spec.ts diff --git a/src/goals/goals.service.spec.ts b/src/goals/goals.service.spec.ts new file mode 100644 index 0000000..90d827f --- /dev/null +++ b/src/goals/goals.service.spec.ts @@ -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) => 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); + }); + + 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) => { + // 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) => { + 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) => { + 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, + ); + }); + }); +});