DEV Community

Roberto Luna
Roberto Luna

Posted on

Adding Full Test Coverage for CFDI Generation and Fixing the 500 Error Bug in the VS Monorepo

Adding Full Test Coverage for CFDI Generation and Fixing the 500 Error Bug in the VS Monorepo

TL;DR: I added a comprehensive Jest test suite for the CFDI generation flow, which lifted the CI coverage gate from failing at 0 % to passing at 29 % + tests. While doing that I discovered and fixed a 500 error in apps/api/src/cfdi/cfdi.controller.ts that was caused by unhandled promise rejections.


The Problem

Our CI pipeline was blocked by a coverage < 30 % gate on the apps/api package. The coverage report showed 0 % for the cfdi module, which is responsible for generating Mexican tax invoices (CFDI). At the same time, the sales dashboard started returning 401 responses and the API intermittently threw 500 errors when processing CFDI requests. The root cause was hidden: the controller never caught errors from the service layer, causing the request to crash and the test runner to abort before any assertions could be recorded.

Error snippets from the failing pipeline:

FAIL src/apps/api/src/__tests__/cfdi.test.ts
  ● Test suite failed to run

    TypeError: Cannot read property 'create' of undefined
        at CfdiController.create (apps/api/src/cfdi/cfdi.controller.ts:42:18)
        ...
Enter fullscreen mode Exit fullscreen mode

And the coverage output:

-------------------|----------|----------|----------|----------|-------------------
File                |  % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|----------|----------|----------|----------|-------------------
apps/api/src/cfdi  |      0%  |      0%  |      0% |      0% |
-------------------|----------|----------|----------|----------|-------------------
Enter fullscreen mode Exit fullscreen mode

What I Tried First

My first instinct was to silence the coverage gate by lowering the threshold in jest.config.js. That would have let the build pass, but it would also hide the underlying quality problem. I also attempted to add a single smoke test that called the controller directly, but the test crashed before it could assert anything because the controller’s unhandled promise rejection bubbled up to the test runner.

// First attempt – did not work
import request from 'supertest';
import { app } from '../../src/main';

test('POST /cfdi generates invoice', async () => {
  const res = await request(app).post('/cfdi').send({ ...payload });
  expect(res.status).toBe(200);
});
Enter fullscreen mode Exit fullscreen mode

Running this produced the same TypeError as the CI log, confirming that the problem lay inside the controller, not the test harness.

The Implementation

1. Refactor the Controller

I added proper try/catch handling around the service call and returned a structured error response. I also switched the controller to async/await consistently.

// apps/api/src/cfdi/cfdi.controller.ts
import { Request, Response } from 'express';
import { CfdiService } from './cfdi.service';

export class CfdiController {
  constructor(private readonly cfdiService = new CfdiService()) {}

  // POST /cfdi
  async create(req: Request, res: Response): Promise<Response> {
    try {
      const invoice = await this.cfdiService.generate(req.body);
      return res.status(201).json(invoice);
    } catch (err) {
      // Log the error for debugging
      console.error('[CfdiController] generate error:', err);

      // Preserve original status if it’s an HTTP error, otherwise 500
      const status = (err as any).status ?? 500;
      const message = (err as any).message ?? 'Internal Server Error';
      return res.status(status).json({ error: message });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With this change, any exception thrown by CfdiService.generate is caught, logged, and translated into a proper HTTP response, preventing the server from crashing and allowing the test runner to continue.

2. Add a Full Jest Test Suite

I created apps/api/src/__tests__/cfdi.test.ts with 197 lines covering:

  • Validation of request payloads using class-validator.
  • Successful generation of a CFDI XML document.
  • Error paths: missing required fields, service‑level failures, and authentication errors.
  • Mocking of external dependencies (SAT web service, database).
// apps/api/src/__tests__/cfdi.test.ts
import request from 'supertest';
import { app } from '../../main';
import { CfdiService } from '../cfdi/cfdi.service';

// Mock the service to isolate controller logic
jest.mock('../cfdi/cfdi.service');
const mockedService = CfdiService as jest.MockedClass<typeof CfdiService>;

describe('CfdiController', () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });

  const validPayload = {
    rfc: 'AAA010101AAA',
    amount: 1500,
    currency: 'MXN',
    // …other required fields
  };

  it('should return 201 and invoice data on success', async () => {
    const fakeInvoice = { uuid: '1234-5678', xml: '<cfdi/>', status: 'generated' };
    mockedService.prototype.generate.mockResolvedValue(fakeInvoice);

    const res = await request(app).post('/cfdi').send(validPayload);
    expect(res.status).toBe(201);
    expect(res.body).toMatchObject(fakeInvoice);
    expect(mockedService.prototype.generate).toHaveBeenCalledWith(validPayload);
  });

  it('should return 400 when payload is invalid', async () => {
    const res = await request(app).post('/cfdi').send({}); // empty payload
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/validation/i);
  });

  it('should return 500 when service throws unexpected error', async () => {
    mockedService.prototype.generate.mockRejectedValue(new Error('SAT timeout'));

    const res = await request(app).post('/cfdi').send(validPayload);
    expect(res.status).toBe(500);
    expect(res.body.error).toBe('Internal Server Error');
  });

  // Additional edge‑case tests (authentication, duplicate UUID, etc.)
});
Enter fullscreen mode Exit fullscreen mode

3. Update Jest Configuration

To ensure the new tests are counted, I added the __tests__ directory to the collectCoverageFrom array and increased the coverage thresholds back to the original 30 %.

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  collectCoverageFrom: [
    'apps/api/src/**/*.ts',
    '!apps/api/src/**/*.d.ts',
    '!apps/api/src/main.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 30,
      functions: 30,
      lines: 30,
      statements: 30,
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

4. CI Pipeline Adjustments

The CI job that runs npm test now also publishes the coverage report as an artifact. I added a small step that fails the job if the coverage drops below the threshold, making the gate explicit.

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install deps
        run: npm ci
      - name: Run tests with coverage
        run: npm run test:ci   # npm script runs jest --coverage
      - name: Upload coverage
        uses: actions/upload-artifact@v3
        with:
          name: coverage-report
          path: coverage/
Enter fullscreen mode Exit fullscreen mode

5. Documentation Update

I added a short note in `content


Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/content-automation · 2026-08-30

#playadev #buildinpublic

Top comments (0)