From 9bbdc94e7626b68d65f8165ae946d34cce64adf5 Mon Sep 17 00:00:00 2001 From: Gracora Date: Wed, 26 Aug 2026 13:41:11 +0100 Subject: [PATCH] test(vendors,po): add co-located unit tests for VendorsService and PurchaseOrdersService Covers findAll with pagination/search, findById, create, update, delete (vendor), and full PO lifecycle: create, update, submit, approve, receive, cancel. Tests all state-transition guard paths and the open-PO guard on vendor delete. Replaces the single misplaced assertions in ibinola.spec.ts. Closes #1288 --- .../purchase-orders.service.spec.ts | 378 ++++++++++++++++++ backend/src/vendors/vendors.service.spec.ts | 155 +++++++ 2 files changed, 533 insertions(+) create mode 100644 backend/src/purchase-orders/purchase-orders.service.spec.ts create mode 100644 backend/src/vendors/vendors.service.spec.ts diff --git a/backend/src/purchase-orders/purchase-orders.service.spec.ts b/backend/src/purchase-orders/purchase-orders.service.spec.ts new file mode 100644 index 00000000..b8e6337f --- /dev/null +++ b/backend/src/purchase-orders/purchase-orders.service.spec.ts @@ -0,0 +1,378 @@ +import { PurchaseOrdersService } from './purchase-orders.service'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { POStatus } from './entities/purchase-order.entity'; + +describe('PurchaseOrdersService', () => { + let service: PurchaseOrdersService; + let mockPoRepo: any; + let mockLineItemRepo: any; + + beforeEach(() => { + mockPoRepo = { + findAndCount: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + count: jest.fn(), + delete: jest.fn(), + }; + mockLineItemRepo = { + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + service = new PurchaseOrdersService(mockPoRepo, mockLineItemRepo); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('returns paginated purchase orders', async () => { + const pos = [ + { id: 'po-1', poNumber: 'PO-00001', status: POStatus.DRAFT }, + ]; + mockPoRepo.findAndCount.mockResolvedValue([pos, 1]); + + const result = await service.findAll({ page: 1, limit: 20 }); + expect(result.items).toEqual(pos); + expect(result.total).toBe(1); + expect(result.totalPages).toBe(1); + }); + + it('applies search filter on poNumber', async () => { + mockPoRepo.findAndCount.mockResolvedValue([[], 0]); + await service.findAll({ page: 1, limit: 20, search: 'PO-00001' }); + expect(mockPoRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ poNumber: expect.any(Object) }), + }), + ); + }); + }); + + describe('findById', () => { + it('returns a PO with line items', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + lineItems: [], + }; + mockPoRepo.findOne.mockResolvedValue(po); + const result = await service.findById('po-1'); + expect(result).toEqual(po); + expect(mockPoRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'po-1' }, + relations: ['lineItems'], + }); + }); + + it('throws NotFoundException when PO does not exist', async () => { + mockPoRepo.findOne.mockResolvedValue(null); + await expect(service.findById('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('create', () => { + it('creates a PO with auto-generated poNumber', async () => { + mockPoRepo.count.mockResolvedValue(0); + const poData = { id: 'po-1', poNumber: 'PO-00001', status: POStatus.DRAFT }; + mockPoRepo.create.mockReturnValue(poData); + mockPoRepo.save.mockResolvedValue(poData); + mockPoRepo.findOne.mockResolvedValue({ ...poData, lineItems: [] }); + + const result = await service.create({ + vendorId: 'v-1', + totalAmount: 1000, + }); + expect(mockPoRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ poNumber: 'PO-00001' }), + ); + }); + + it('creates a PO with line items', async () => { + mockPoRepo.count.mockResolvedValue(0); + const poData = { id: 'po-1', poNumber: 'PO-00001', status: POStatus.DRAFT }; + mockPoRepo.create.mockReturnValue(poData); + mockPoRepo.save.mockResolvedValue(poData); + + const lineItems = [ + { description: 'Widget A', quantity: 10, unitCost: 50 }, + { description: 'Widget B', quantity: 5, unitCost: 100 }, + ]; + + const savedLineItems = lineItems.map((item, i) => ({ + id: `li-${i}`, + ...item, + purchaseOrderId: 'po-1', + })); + mockLineItemRepo.create.mockImplementation((dto) => dto); + mockLineItemRepo.save.mockResolvedValue(savedLineItems); + mockPoRepo.findOne.mockResolvedValue({ + ...poData, + lineItems: savedLineItems, + totalAmount: 1000, + }); + + const result = await service.create({ + vendorId: 'v-1', + lineItems, + }); + expect(mockLineItemRepo.save).toHaveBeenCalled(); + }); + + it('sets initial status to DRAFT', async () => { + mockPoRepo.count.mockResolvedValue(0); + mockPoRepo.create.mockReturnValue({ + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + }); + mockPoRepo.save.mockResolvedValue({}); + mockPoRepo.findOne.mockResolvedValue({ + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + lineItems: [], + }); + + await service.create({ vendorId: 'v-1' }); + expect(mockPoRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ status: POStatus.DRAFT }), + ); + }); + }); + + describe('update', () => { + it('updates a DRAFT PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + lineItems: [], + }; + mockPoRepo.findOne.mockResolvedValue(po); + mockPoRepo.save.mockResolvedValue(po); + + const result = await service.update('po-1', { vendorId: 'v-2' }); + expect(mockPoRepo.save).toHaveBeenCalled(); + }); + + it('throws NotFoundException for non-existent PO', async () => { + mockPoRepo.findOne.mockResolvedValue(null); + await expect( + service.update('nonexistent', { vendorId: 'v-2' }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when updating non-DRAFT PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.SUBMITTED, + lineItems: [], + }; + mockPoRepo.findOne.mockResolvedValue(po); + + await expect( + service.update('po-1', { vendorId: 'v-2' }), + ).rejects.toThrow(BadRequestException); + await expect( + service.update('po-1', { vendorId: 'v-2' }), + ).rejects.toThrow('Only draft purchase orders can be edited'); + }); + + it('replaces line items on update', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + lineItems: [], + }; + mockPoRepo.findOne.mockResolvedValue(po); + mockPoRepo.save.mockResolvedValue(po); + mockLineItemRepo.delete.mockResolvedValue(undefined); + mockLineItemRepo.create.mockImplementation((dto) => dto); + mockLineItemRepo.save.mockResolvedValue([]); + + await service.update('po-1', { + lineItems: [{ description: 'New Item', quantity: 1, unitCost: 100 }], + }); + expect(mockLineItemRepo.delete).toHaveBeenCalledWith({ + purchaseOrderId: 'po-1', + }); + expect(mockLineItemRepo.save).toHaveBeenCalled(); + }); + }); + + describe('submit', () => { + it('submits a DRAFT PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + }; + mockPoRepo.findOne.mockResolvedValue(po); + mockPoRepo.save.mockImplementation((dto) => + Promise.resolve({ ...po, ...dto }), + ); + + const result = await service.submit('po-1'); + expect(result.status).toBe(POStatus.SUBMITTED); + }); + + it('throws BadRequestException when submitting non-DRAFT PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.SUBMITTED, + }; + mockPoRepo.findOne.mockResolvedValue(po); + + await expect(service.submit('po-1')).rejects.toThrow(BadRequestException); + await expect(service.submit('po-1')).rejects.toThrow( + 'Only draft purchase orders can be submitted', + ); + }); + + it('throws NotFoundException for non-existent PO', async () => { + mockPoRepo.findOne.mockResolvedValue(null); + await expect(service.submit('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('approve', () => { + it('approves a SUBMITTED PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.SUBMITTED, + }; + mockPoRepo.findOne.mockResolvedValue(po); + mockPoRepo.save.mockImplementation((dto) => + Promise.resolve({ ...po, ...dto }), + ); + + const result = await service.approve('po-1', 'approver-1'); + expect(result.status).toBe(POStatus.APPROVED); + expect(result.createdByUserId).toBe('approver-1'); + }); + + it('throws BadRequestException when approving non-SUBMITTED PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + }; + mockPoRepo.findOne.mockResolvedValue(po); + + await expect(service.approve('po-1', 'a-1')).rejects.toThrow( + BadRequestException, + ); + await expect(service.approve('po-1', 'a-1')).rejects.toThrow( + 'Only submitted purchase orders can be approved', + ); + }); + }); + + describe('receive', () => { + it('receives an APPROVED PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.APPROVED, + }; + mockPoRepo.findOne.mockResolvedValue(po); + mockPoRepo.save.mockImplementation((dto) => + Promise.resolve({ ...po, ...dto }), + ); + + const result = await service.receive('po-1'); + expect(result.status).toBe(POStatus.RECEIVED); + }); + + it('throws BadRequestException when receiving non-APPROVED PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.SUBMITTED, + }; + mockPoRepo.findOne.mockResolvedValue(po); + + await expect(service.receive('po-1')).rejects.toThrow(BadRequestException); + await expect(service.receive('po-1')).rejects.toThrow( + 'Only approved purchase orders can be received', + ); + }); + }); + + describe('cancel', () => { + it('cancels a DRAFT PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.DRAFT, + }; + mockPoRepo.findOne.mockResolvedValue(po); + mockPoRepo.save.mockImplementation((dto) => + Promise.resolve({ ...po, ...dto }), + ); + + const result = await service.cancel('po-1'); + expect(result.status).toBe(POStatus.CANCELLED); + }); + + it('cancels a SUBMITTED PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.SUBMITTED, + }; + mockPoRepo.findOne.mockResolvedValue(po); + mockPoRepo.save.mockImplementation((dto) => + Promise.resolve({ ...po, ...dto }), + ); + + const result = await service.cancel('po-1'); + expect(result.status).toBe(POStatus.CANCELLED); + }); + + it('throws BadRequestException when cancelling APPROVED PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.APPROVED, + }; + mockPoRepo.findOne.mockResolvedValue(po); + + await expect(service.cancel('po-1')).rejects.toThrow(BadRequestException); + await expect(service.cancel('po-1')).rejects.toThrow( + 'Only draft or submitted purchase orders can be cancelled', + ); + }); + + it('throws BadRequestException when cancelling RECEIVED PO', async () => { + const po = { + id: 'po-1', + poNumber: 'PO-00001', + status: POStatus.RECEIVED, + }; + mockPoRepo.findOne.mockResolvedValue(po); + + await expect(service.cancel('po-1')).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException for non-existent PO', async () => { + mockPoRepo.findOne.mockResolvedValue(null); + await expect(service.cancel('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/backend/src/vendors/vendors.service.spec.ts b/backend/src/vendors/vendors.service.spec.ts new file mode 100644 index 00000000..f859c0f6 --- /dev/null +++ b/backend/src/vendors/vendors.service.spec.ts @@ -0,0 +1,155 @@ +import { VendorsService } from './vendors.service'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { POStatus } from '../purchase-orders/entities/purchase-order.entity'; + +describe('VendorsService', () => { + let service: VendorsService; + let mockVendorRepo: any; + let mockPoRepo: any; + + beforeEach(() => { + mockVendorRepo = { + findAndCount: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + mockPoRepo = { + findOne: jest.fn(), + }; + service = new VendorsService(mockVendorRepo, mockPoRepo); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('returns paginated vendors', async () => { + const vendors = [ + { id: 'v-1', name: 'Acme Corp', code: 'ACME' }, + { id: 'v-2', name: 'Beta Inc', code: 'BETA' }, + ]; + mockVendorRepo.findAndCount.mockResolvedValue([vendors, 2]); + + const result = await service.findAll({ page: 1, limit: 20 }); + expect(result.items).toEqual(vendors); + expect(result.total).toBe(2); + expect(result.totalPages).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('applies search filter', async () => { + mockVendorRepo.findAndCount.mockResolvedValue([[], 0]); + await service.findAll({ page: 1, limit: 20, search: 'Acme' }); + expect(mockVendorRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ name: expect.any(Object) }), + }), + ); + }); + + it('calculates total pages correctly', async () => { + mockVendorRepo.findAndCount.mockResolvedValue([[], 45]); + const result = await service.findAll({ page: 1, limit: 20 }); + expect(result.totalPages).toBe(3); + }); + }); + + describe('findById', () => { + it('returns a vendor by id', async () => { + const vendor = { id: 'v-1', name: 'Acme Corp', code: 'ACME' }; + mockVendorRepo.findOne.mockResolvedValue(vendor); + const result = await service.findById('v-1'); + expect(result).toEqual(vendor); + }); + + it('throws NotFoundException when vendor does not exist', async () => { + mockVendorRepo.findOne.mockResolvedValue(null); + await expect(service.findById('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('create', () => { + it('creates a vendor', async () => { + const dto = { name: 'Acme Corp', code: 'ACME' }; + const created = { id: 'v-1', ...dto }; + mockVendorRepo.create.mockReturnValue(created); + mockVendorRepo.save.mockResolvedValue(created); + + const result = await service.create(dto); + expect(result.name).toBe('Acme Corp'); + expect(mockVendorRepo.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('update', () => { + it('updates an existing vendor', async () => { + const existing = { id: 'v-1', name: 'Acme Corp', code: 'ACME' }; + mockVendorRepo.findOne.mockResolvedValue(existing); + mockVendorRepo.save.mockImplementation((dto) => + Promise.resolve({ ...existing, ...dto }), + ); + + const result = await service.update('v-1', { name: 'Acme Updated' }); + expect(result.name).toBe('Acme Updated'); + }); + + it('throws NotFoundException when updating non-existent vendor', async () => { + mockVendorRepo.findOne.mockResolvedValue(null); + await expect( + service.update('nonexistent', { name: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('deletes a vendor with no open purchase orders', async () => { + const vendor = { id: 'v-1', name: 'Acme Corp' }; + mockVendorRepo.findOne.mockResolvedValue(vendor); + mockPoRepo.findOne.mockResolvedValue(null); + mockVendorRepo.delete.mockResolvedValue(undefined); + + await service.delete('v-1'); + expect(mockVendorRepo.delete).toHaveBeenCalledWith('v-1'); + }); + + it('throws NotFoundException when deleting non-existent vendor', async () => { + mockVendorRepo.findOne.mockResolvedValue(null); + await expect(service.delete('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws BadRequestException when vendor has open purchase orders', async () => { + const vendor = { id: 'v-1', name: 'Acme Corp' }; + const openPo = { + id: 'po-1', + poNumber: 'PO-00001', + vendorId: 'v-1', + status: POStatus.DRAFT, + }; + mockVendorRepo.findOne.mockResolvedValue(vendor); + mockPoRepo.findOne.mockResolvedValue(openPo); + + await expect(service.delete('v-1')).rejects.toThrow(BadRequestException); + await expect(service.delete('v-1')).rejects.toThrow( + 'Cannot delete vendor v-1: it is referenced by open purchase order PO-00001', + ); + }); + + it('allows deleting vendor when all POs are RECEIVED', async () => { + const vendor = { id: 'v-1', name: 'Acme Corp' }; + mockVendorRepo.findOne.mockResolvedValue(vendor); + mockPoRepo.findOne.mockResolvedValue(null); // No non-RECEIVED POs + mockVendorRepo.delete.mockResolvedValue(undefined); + + await service.delete('v-1'); + expect(mockVendorRepo.delete).toHaveBeenCalledWith('v-1'); + }); + }); +});