AI Prompt: Forma3D.Connect — Phase 5q: Shipment Service Tests¶
Purpose: This prompt instructs an AI to implement comprehensive tests for the shipments module
Estimated Effort: 4-6 hours
Prerequisites: Phase 5p completed (API Versioning)
Output: Full test coverage for shipments service and controller
Status: 🟡 PENDING
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the Phase 5p foundation. Your task is to implement Phase 5q: Shipment Service Tests — specifically addressing TD-015 (Incomplete Shipment Service Tests) from the technical debt register.
Why This Matters:
The shipments module has only repository tests, leaving significant gaps:
- Service Logic Untested: Business logic has no unit test coverage
- Controller Untested: Request validation and routing not verified
- Integration Gaps: Service-to-repository interactions not tested
- Regression Risk: Changes could break shipment functionality silently
Phase 5q delivers:
- Complete service unit tests
- Controller tests with mocked services
- Edge case coverage
- Error scenario testing
📋 Context: Technical Debt Item¶
TD-015: Incomplete Shipment Service Tests¶
| Attribute | Value |
|---|---|
| Type | Test Debt |
| Priority | Medium |
| Location | apps/api/src/shipments/ |
| Interest Rate | Medium |
| Principal (Effort) | 4-6 hours |
Current State¶
Tested:
- shipments.repository.spec.ts ✅
Missing:
- shipments.service.spec.ts ❌
- shipments.controller.spec.ts ❌
🛠️ Implementation Phases¶
Phase 1: Analyze Shipments Module (30 minutes)¶
Priority: Critical | Impact: High | Dependencies: None
1. Review Service Methods¶
Identify all public methods in shipments.service.ts that need testing:
// Expected methods (verify in actual file):
class ShipmentsService {
create(dto: CreateShipmentDto): Promise<Shipment>;
findAll(filters?: ShipmentFilters): Promise<Shipment[]>;
findOne(id: string): Promise<Shipment>;
findByOrderId(orderId: string): Promise<Shipment[]>;
update(id: string, dto: UpdateShipmentDto): Promise<Shipment>;
updateStatus(id: string, status: ShipmentStatus): Promise<Shipment>;
delete(id: string): Promise<void>;
}
2. Review Controller Endpoints¶
Identify all endpoints in shipments.controller.ts:
// Expected endpoints:
@Get() findAll()
@Get(':id') findOne()
@Post() create()
@Patch(':id') update()
@Delete(':id') delete()
Phase 2: Create Service Unit Tests (2 hours)¶
Priority: High | Impact: High | Dependencies: Phase 1
1. Create Service Test File¶
Create apps/api/src/shipments/shipments.service.spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { ShipmentsService } from './shipments.service';
import { ShipmentsRepository } from './shipments.repository';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { NotFoundException } from '@nestjs/common';
import { ShipmentStatus } from '@prisma/client';
describe('ShipmentsService', () => {
let service: ShipmentsService;
let repository: jest.Mocked<ShipmentsRepository>;
let eventEmitter: jest.Mocked<EventEmitter2>;
const mockShipment = {
id: 'shipment-123',
orderId: 'order-456',
carrier: 'DHL',
trackingNumber: 'TRACK123',
status: ShipmentStatus.PENDING,
labelUrl: null,
shippedAt: null,
deliveredAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const mockRepository = {
create: jest.fn(),
findAll: jest.fn(),
findById: jest.fn(),
findByOrderId: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ShipmentsService,
{ provide: ShipmentsRepository, useValue: mockRepository },
{ provide: EventEmitter2, useValue: mockEventEmitter },
],
}).compile();
service = module.get<ShipmentsService>(ShipmentsService);
repository = module.get(ShipmentsRepository);
eventEmitter = module.get(EventEmitter2);
});
describe('create', () => {
const createDto = {
orderId: 'order-456',
carrier: 'DHL',
trackingNumber: 'TRACK123',
};
it('should create a shipment', async () => {
repository.create.mockResolvedValue(mockShipment);
const result = await service.create(createDto);
expect(repository.create).toHaveBeenCalledWith(createDto);
expect(result).toEqual(mockShipment);
});
it('should emit shipment.created event', async () => {
repository.create.mockResolvedValue(mockShipment);
await service.create(createDto);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'shipment.created',
expect.objectContaining({ shipmentId: mockShipment.id }),
);
});
});
describe('findOne', () => {
it('should return a shipment when found', async () => {
repository.findById.mockResolvedValue(mockShipment);
const result = await service.findOne('shipment-123');
expect(repository.findById).toHaveBeenCalledWith('shipment-123');
expect(result).toEqual(mockShipment);
});
it('should throw NotFoundException when shipment not found', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findOne('non-existent')).rejects.toThrow(
NotFoundException,
);
});
});
describe('findAll', () => {
it('should return all shipments', async () => {
repository.findAll.mockResolvedValue([mockShipment]);
const result = await service.findAll();
expect(result).toEqual([mockShipment]);
});
it('should apply filters when provided', async () => {
const filters = { status: ShipmentStatus.SHIPPED };
repository.findAll.mockResolvedValue([]);
await service.findAll(filters);
expect(repository.findAll).toHaveBeenCalledWith(filters);
});
});
describe('findByOrderId', () => {
it('should return shipments for an order', async () => {
repository.findByOrderId.mockResolvedValue([mockShipment]);
const result = await service.findByOrderId('order-456');
expect(repository.findByOrderId).toHaveBeenCalledWith('order-456');
expect(result).toEqual([mockShipment]);
});
});
describe('update', () => {
const updateDto = { carrier: 'FedEx' };
it('should update a shipment', async () => {
const updatedShipment = { ...mockShipment, carrier: 'FedEx' };
repository.findById.mockResolvedValue(mockShipment);
repository.update.mockResolvedValue(updatedShipment);
const result = await service.update('shipment-123', updateDto);
expect(repository.update).toHaveBeenCalledWith('shipment-123', updateDto);
expect(result.carrier).toBe('FedEx');
});
it('should throw NotFoundException for non-existent shipment', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.update('non-existent', updateDto)).rejects.toThrow(
NotFoundException,
);
});
});
describe('updateStatus', () => {
it('should update shipment status', async () => {
const shippedShipment = {
...mockShipment,
status: ShipmentStatus.SHIPPED,
shippedAt: new Date(),
};
repository.findById.mockResolvedValue(mockShipment);
repository.update.mockResolvedValue(shippedShipment);
const result = await service.updateStatus(
'shipment-123',
ShipmentStatus.SHIPPED,
);
expect(result.status).toBe(ShipmentStatus.SHIPPED);
expect(result.shippedAt).toBeDefined();
});
it('should emit status change event', async () => {
repository.findById.mockResolvedValue(mockShipment);
repository.update.mockResolvedValue({
...mockShipment,
status: ShipmentStatus.SHIPPED,
});
await service.updateStatus('shipment-123', ShipmentStatus.SHIPPED);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'shipment.status.changed',
expect.objectContaining({
shipmentId: 'shipment-123',
previousStatus: ShipmentStatus.PENDING,
newStatus: ShipmentStatus.SHIPPED,
}),
);
});
it('should set deliveredAt when status is DELIVERED', async () => {
repository.findById.mockResolvedValue({
...mockShipment,
status: ShipmentStatus.SHIPPED,
});
repository.update.mockResolvedValue({
...mockShipment,
status: ShipmentStatus.DELIVERED,
deliveredAt: new Date(),
});
const result = await service.updateStatus(
'shipment-123',
ShipmentStatus.DELIVERED,
);
expect(result.deliveredAt).toBeDefined();
});
});
describe('delete', () => {
it('should delete a shipment', async () => {
repository.findById.mockResolvedValue(mockShipment);
repository.delete.mockResolvedValue(undefined);
await service.delete('shipment-123');
expect(repository.delete).toHaveBeenCalledWith('shipment-123');
});
it('should throw NotFoundException for non-existent shipment', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.delete('non-existent')).rejects.toThrow(
NotFoundException,
);
});
});
});
Phase 3: Create Controller Unit Tests (1.5 hours)¶
Priority: High | Impact: High | Dependencies: Phase 2
1. Create Controller Test File¶
Create apps/api/src/shipments/shipments.controller.spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { ShipmentsController } from './shipments.controller';
import { ShipmentsService } from './shipments.service';
import { NotFoundException } from '@nestjs/common';
import { ShipmentStatus } from '@prisma/client';
describe('ShipmentsController', () => {
let controller: ShipmentsController;
let service: jest.Mocked<ShipmentsService>;
const mockShipment = {
id: 'shipment-123',
orderId: 'order-456',
carrier: 'DHL',
trackingNumber: 'TRACK123',
status: ShipmentStatus.PENDING,
labelUrl: null,
shippedAt: null,
deliveredAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const mockService = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
findByOrderId: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [ShipmentsController],
providers: [{ provide: ShipmentsService, useValue: mockService }],
}).compile();
controller = module.get<ShipmentsController>(ShipmentsController);
service = module.get(ShipmentsService);
});
describe('POST /shipments', () => {
it('should create a shipment', async () => {
const createDto = {
orderId: 'order-456',
carrier: 'DHL',
trackingNumber: 'TRACK123',
};
service.create.mockResolvedValue(mockShipment);
const result = await controller.create(createDto);
expect(service.create).toHaveBeenCalledWith(createDto);
expect(result).toEqual(mockShipment);
});
});
describe('GET /shipments', () => {
it('should return all shipments', async () => {
service.findAll.mockResolvedValue([mockShipment]);
const result = await controller.findAll({});
expect(result).toEqual([mockShipment]);
});
it('should pass filters to service', async () => {
const filters = { status: ShipmentStatus.SHIPPED };
service.findAll.mockResolvedValue([]);
await controller.findAll(filters);
expect(service.findAll).toHaveBeenCalledWith(filters);
});
});
describe('GET /shipments/:id', () => {
it('should return a shipment by id', async () => {
service.findOne.mockResolvedValue(mockShipment);
const result = await controller.findOne('shipment-123');
expect(service.findOne).toHaveBeenCalledWith('shipment-123');
expect(result).toEqual(mockShipment);
});
it('should propagate NotFoundException', async () => {
service.findOne.mockRejectedValue(new NotFoundException());
await expect(controller.findOne('non-existent')).rejects.toThrow(
NotFoundException,
);
});
});
describe('GET /shipments/order/:orderId', () => {
it('should return shipments for an order', async () => {
service.findByOrderId.mockResolvedValue([mockShipment]);
const result = await controller.findByOrderId('order-456');
expect(service.findByOrderId).toHaveBeenCalledWith('order-456');
expect(result).toEqual([mockShipment]);
});
});
describe('PATCH /shipments/:id', () => {
it('should update a shipment', async () => {
const updateDto = { carrier: 'FedEx' };
const updatedShipment = { ...mockShipment, carrier: 'FedEx' };
service.update.mockResolvedValue(updatedShipment);
const result = await controller.update('shipment-123', updateDto);
expect(service.update).toHaveBeenCalledWith('shipment-123', updateDto);
expect(result.carrier).toBe('FedEx');
});
});
describe('PATCH /shipments/:id/status', () => {
it('should update shipment status', async () => {
const statusDto = { status: ShipmentStatus.SHIPPED };
service.updateStatus.mockResolvedValue({
...mockShipment,
status: ShipmentStatus.SHIPPED,
});
const result = await controller.updateStatus('shipment-123', statusDto);
expect(service.updateStatus).toHaveBeenCalledWith(
'shipment-123',
ShipmentStatus.SHIPPED,
);
expect(result.status).toBe(ShipmentStatus.SHIPPED);
});
});
describe('DELETE /shipments/:id', () => {
it('should delete a shipment', async () => {
service.delete.mockResolvedValue(undefined);
await controller.delete('shipment-123');
expect(service.delete).toHaveBeenCalledWith('shipment-123');
});
});
});
Phase 4: Add Edge Case and Error Tests (1 hour)¶
Priority: Medium | Impact: Medium | Dependencies: Phase 2
1. Add Edge Cases to Service Tests¶
Add to shipments.service.spec.ts:
describe('edge cases', () => {
describe('concurrent status updates', () => {
it('should handle status update to same status gracefully', async () => {
repository.findById.mockResolvedValue(mockShipment);
repository.update.mockResolvedValue(mockShipment);
// Updating PENDING to PENDING should work but not emit event
await service.updateStatus('shipment-123', ShipmentStatus.PENDING);
// Should not emit event for no-op status change
expect(eventEmitter.emit).not.toHaveBeenCalledWith(
'shipment.status.changed',
expect.anything(),
);
});
});
describe('metadata handling', () => {
it('should preserve existing metadata on partial update', async () => {
const shipmentWithMeta = {
...mockShipment,
metadata: { weight: 2.5, dimensions: '10x10x10' },
};
repository.findById.mockResolvedValue(shipmentWithMeta);
repository.update.mockResolvedValue({
...shipmentWithMeta,
carrier: 'FedEx',
});
const result = await service.update('shipment-123', { carrier: 'FedEx' });
expect(result.metadata).toEqual({ weight: 2.5, dimensions: '10x10x10' });
});
});
describe('label URL validation', () => {
it('should accept valid label URLs', async () => {
const dto = {
orderId: 'order-456',
carrier: 'DHL',
trackingNumber: 'TRACK123',
labelUrl: 'https://example.com/labels/123.pdf',
};
repository.create.mockResolvedValue({ ...mockShipment, labelUrl: dto.labelUrl });
const result = await service.create(dto);
expect(result.labelUrl).toBe(dto.labelUrl);
});
});
});
Phase 5: Verify and Document Coverage (30 minutes)¶
Priority: High | Impact: Low | Dependencies: Phase 3
1. Run Tests with Coverage¶
pnpm nx test api --coverage --collectCoverageFrom='**/shipments/**/*.ts'
2. Verify Coverage Thresholds¶
Ensure shipments module meets coverage targets: - Statements: 80%+ - Branches: 75%+ - Functions: 80%+ - Lines: 80%+
📁 Files to Create¶
New Files¶
apps/api/src/shipments/shipments.service.spec.ts
apps/api/src/shipments/shipments.controller.spec.ts
✅ Validation Checklist¶
- Service test file created with all method coverage
- Controller test file created with all endpoint coverage
- Edge cases and error scenarios tested
- Event emissions verified
-
pnpm nx test apipasses - Coverage meets thresholds (80%+)
Final Verification¶
# Run all tests
pnpm nx test api
# Run shipments tests specifically
pnpm nx test api --testPathPattern=shipments
# Run with coverage
pnpm nx test api --coverage --collectCoverageFrom='**/shipments/**/*.ts'
# Verify coverage report
open coverage/lcov-report/index.html
📝 Test Summary Expected¶
| File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|
shipments.service.ts |
90%+ | 85%+ | 100% | 90%+ |
shipments.controller.ts |
100% | 100% | 100% | 100% |
shipments.repository.ts |
85%+ | 80%+ | 90%+ | 85%+ |
END OF PROMPT
This prompt resolves TD-015 from the technical debt register by implementing comprehensive shipment module tests.