Skip to content

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:

  1. Service Logic Untested: Business logic has no unit test coverage
  2. Controller Untested: Request validation and routing not verified
  3. Integration Gaps: Service-to-repository interactions not tested
  4. 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 api passes
  • 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.