Hi there, I’m having an issue with consumer test r...
# pact-js
k
Hi there, I’m having an issue with consumer test request headers. After upgrading our nestjs app (a “Backend For Frontend” and consuming external APIs) to PactV3, a significant amount of the consumer tests are failing. In fact every test that is not a GET is failing with a simple error:
Copy code
ERROR (65379): pact@12.1.0: Test failed for the following reasons:

 Mock server failed with the following mismatches:

    0) The following request was incorrect: 

        POST /productentitlements/{exampleID1}/tenants/{exampleID2}
After doing some digging into the node_modules pact files we were able to console.log out something more helpful:
Copy code
console.log
  mismatches: [
   {
    actual: '',
    expected: 'application/x.avidxchange.productentitlements+json;version=1.0.0',
    key: 'Content-Type',
    mismatch: "Mismatch with header 'Content-Type': Expected value 'application/x.avidxchange.productentitlements+json;version=1.0.0' at index 1 but was missing (actual has 1 value(s))",
    type: 'HeaderMismatch'
   }
  ]

   at generateMockServerError (../../../node_modules/@pact-foundation/src/v3/display.ts:135:3)
This implies that our Content-Type is somehow being removed at some point after the test is run. Has anyone seen an issue like this? We tried following the path the headers take through the compiled pact code but were unable to find a place where the Content-Type was removed. One thing to note is that same header is being passed with all requests, including the GETs which are passing. It seems to be something related to requests that contained a body, but I don’t have any evidence to say that’s what it is beyond correlation.
m
Hi Kevin, it sounds like a bug. I did discover an error message bug recently Do you have a repro we can use to see this, or could you share the consumer test and how the HTTP request is sent to help us? https://github.com/pact-foundation/pact-js/issues/1113#issuecomment-1708215909. I wonder if the log message is being impacted by that too
k
It might be difficult to share a repro, as it’s part of a large monorepo but I can certainly start with the consumer tests and the HTTP requests. Test file with 2 passing GET tests, 1 failing POST, 1 failing PUT:
Copy code
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { PactV3 } from '@pact-foundation/pact';
import { like, regex } from '@pact-foundation/pact/src/dsl/matchers';
import { camelCaseObjectKeys } from '@ui-coe/shared/bff/util';
import { lastValueFrom } from 'rxjs';
import { HttpConfigService } from '../../../services/http-config.service';
import {
  CreateOrganizationDto,
  IListWrapperAPI,
  IOrganizationAPI,
  OrganizationAddressAPI,
  UpdateAddressDto,
  UpdateOrganizationDto,
} from '../models';
import { OrganizationService } from './organization.service';
import { PACT_MOCK_SEVER_CONFIG_HOST, PACT_MOCK_SEVER_CONFIG_PORT } from '@ui-coe/shared/bff/types';

const mockProvider = new PactV3({
  consumer: 'AvidPay Network',
  provider: 'Organization.Api',
  dir: './pact/orgaization/',
  host: PACT_MOCK_SEVER_CONFIG_HOST, // "127.0.0.1"
  port: PACT_MOCK_SEVER_CONFIG_PORT, // 65535
});

const requestHeaders = {
  'Content-Type': 'application/x.avidxchange.accounting+json;version=1.0.0',
  Accept: 'application/x.avidxchange.accounting+json;version=1.0.0',
  Authorization:
    'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiIwMHUxMnR5bWNnMHk3OHJUejFkNyIsInRlbmFudElkIjoiY3Zoa3pqMndjZGZjNGlnZWkwcTIiLCJuYmYiOjE2ODE5MTE4MjYsImV4cCI6MTcxMzQ0NzgyNiwiaWF0IjoxNjgxOTExODI2LCJpc3MiOiJBY2NvdW50aW5nQXV0aFNlcnZlciIsImF1ZCI6IkFjY291bnRpbmcifQ.QNcP0fo0fmIm3UQ85tgpQzz6AHlk621JrCtt_XRHLPY',
  'x-tenant-id': '7qvmnw5nfupmb6j9gp6m',
};

const responseHeaders = {
  'Content-Type': regex({
    generate: 'application/x.avidxchange.accounting+json;charset=utf-8;version=1.0.0',
    matcher:
      'application\\/x\\.avidxchange\\.accounting\\+json;charset=utf-8;version=([0-9]\\.?){3}',
  }),
};

describe('Organization Service', () => {
  let service: OrganizationService;
  const mockServerUrl = `http://${PACT_MOCK_SEVER_CONFIG_HOST}:${PACT_MOCK_SEVER_CONFIG_PORT}`;

  beforeEach(async () => {
    const app = await Test.createTestingModule({
      imports: [ConfigModule, HttpModule],
      providers: [
        OrganizationService,
        HttpConfigService,
        {
          provide: 'MOCK_ENV',
          useValue: false,
        },
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn((key: string) => {
              if (key === 'ACCOUNTING_BASE_URL') return mockServerUrl;
            }),
          },
        },
      ],
    }).compile();

    service = app.get<OrganizationService>(OrganizationService);
  });

  describe('createOrganization', () => {
    const reqBody: CreateOrganizationDto = {
      organization_name: 'Stark Industries',
      organization_code: 'SI',
      source_system: 'Swagger-UI',
    };

    const returnData: IOrganizationAPI = {
      organization_id: 'upcd981lg8z84tozng3w',
      organization_name: 'Stark Industries',
      organization_code: 'SI',
      is_active: 'true',
      created_timestamp: '2023-04-28T00:00:00.000Z',
      created_by_user_id: '7nsxqpkecdggnk3i1wqj',
      last_modified_timestamp: '2023-04-28T00:00:00.000Z',
      last_modified_by_user_id: '7nsxqpkecdggnk3i1wqj',
    };

    it('should return the correct data from POST call', async () => {
      await mockProvider.addInteraction({
        states: [],
        uponReceiving: 'a request to create an organization',
        withRequest: {
          method: 'POST',
          path: '/accounting/organization',
          headers: requestHeaders,
          body: like(reqBody),
        },
        willRespondWith: {
          status: 201,
          headers: responseHeaders,
          body: like(returnData),
        },
      });
      await mockProvider.executeTest(async () => {
        const response = await lastValueFrom(service.createOrganization(requestHeaders, reqBody));
        expect(response).toStrictEqual(camelCaseObjectKeys(returnData));
      });
    });
  });

  describe('getOrganization', () => {
    const returnData: IListWrapperAPI<IOrganizationAPI> = {
      items_requested: 10,
      items_returned: 10,
      items_total: 10,
      offset: 0,
      items: [],
    };

    it('should return the correct data from GET call', async () => {
      await mockProvider.addInteraction({
        states: [
          {
            description: 'a list of tenants exists',
            parameters: {},
          },
        ],
        uponReceiving: 'a request to get organizations',
        withRequest: {
          method: 'GET',
          path: '/accounting/organization',
          headers: requestHeaders,
        },
        willRespondWith: {
          status: 200,
          headers: responseHeaders,
          body: like(returnData),
        },
      });
      await mockProvider.executeTest(async () => {
        const response = await lastValueFrom(service.getOrganizations(requestHeaders, {}));
        expect(response).toStrictEqual(camelCaseObjectKeys(returnData));
      });
    });
  });

  describe('getOrganizationById', () => {
    const id = 'upcd981lg8z84tozng3w';

    const returnData: IOrganizationAPI = {
      organization_id: 'upcd981lg8z84tozng3w',
      organization_name: 'Stark Industries',
      organization_code: 'SI',
      is_active: 'true',
      created_timestamp: '2023-04-28T00:00:00.000Z',
      created_by_user_id: '7nsxqpkecdggnk3i1wqj',
      last_modified_timestamp: '2023-04-28T00:00:00.000Z',
      last_modified_by_user_id: '7nsxqpkecdggnk3i1wqj',
    };

    it('should return the correct data from getOrganizationById call', async () => {
      await mockProvider.addInteraction({
        states: [
          {
            description: 'a organization exists with id ' + id + '',
            parameters: { organization_id: id, is_active: 'true' },
          },
        ],
        uponReceiving: 'a request to get organizations',
        withRequest: {
          method: 'GET',
          path: '/accounting/organization/' + id,
          headers: requestHeaders,
        },
        willRespondWith: {
          status: 200,
          headers: responseHeaders,
          body: like(returnData),
        },
      });
      await mockProvider.executeTest(async () => {
        const response = await lastValueFrom(service.getOrganizationById(id, requestHeaders));
        expect(response).toStrictEqual(camelCaseObjectKeys(returnData));
      });
    });
  });

  describe('updateOrganization', () => {
    const id = 'upcd981lg8z84tozng3w';

    const reqBody: UpdateOrganizationDto = {
      organization_name: 'New Stark Industries',
    };

    const returnData: IOrganizationAPI = {
      organization_id: 'upcd981lg8z84tozng3w',
      organization_name: 'New Stark Industries',
      organization_code: 'SI',
      is_active: 'true',
      created_timestamp: '2023-04-28T00:00:00.000Z',
      created_by_user_id: '7nsxqpkecdggnk3i1wqj',
      last_modified_timestamp: '2023-04-28T00:00:00.000Z',
      last_modified_by_user_id: '7nsxqpkecdggnk3i1wqj',
    };

    it('should return the correct data from PUT call', async () => {
      await mockProvider.addInteraction({
        states: [
          {
            description: 'a organization exists with id ' + id + '',
            parameters: { organization_id: id, is_active: 'true' },
          },
        ],
        uponReceiving: 'a request to update an organization',
        withRequest: {
          method: 'PUT',
          path: '/accounting/organization/' + id,
          headers: requestHeaders,
          body: like(reqBody),
        },
        willRespondWith: {
          status: 200,
          headers: responseHeaders,
          body: like(returnData),
        },
      });
      await mockProvider.executeTest(async () => {
        const response = await lastValueFrom(
          service.updateOrganization(id, requestHeaders, reqBody)
        );
        expect(response).toStrictEqual(camelCaseObjectKeys(returnData));
      });
    });
  });
});
Service file with HTTP:
Copy code
import { HttpService } from '@nestjs/axios';
import { HttpException, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

import { camelCaseObjectKeys, snakeCaseObjectKeys } from '@ui-coe/shared/bff/util';
import { IGenericStringObject, IListWrapperAPI } from '@ui-coe/shared/bff/types';

import { HttpConfigService } from '../../../services/http-config.service';
import { busHierErrorMapper } from '../../shared';
import {
  BusHierError,
  CreateOrganizationDto,
  IOrganizationAPI,
  ListWrapper,
  Organization,
  OrganizationAddress,
  OrganizationAddressAPI,
  OrganizationList,
  OrganizationMapped,
  UpdateAddressDto,
  UpdateOrganizationDto,
} from '../models';

@Injectable()
export class OrganizationService {
  constructor(private http: HttpService, private httpConfigService: HttpConfigService) {}

  /**
   * @method createOrganization
   * @description Create a new Organization
   * @param headers IGenericStringObject
   * @param body CreateOrganizationDto
   * @returns `Observable<Organization | BusHierError>`
   */
  createOrganization(
    headers: IGenericStringObject,
    body: CreateOrganizationDto
  ): Observable<Organization | BusHierError> {
    return this.http
      .post(this.httpConfigService.createOrganization(), snakeCaseObjectKeys(body), { headers })
      .pipe(
        map(response => camelCaseObjectKeys<IOrganizationAPI, Organization>(response.data)),
        catchError(err => {
          throw new HttpException(busHierErrorMapper(err), err.response?.status);
        })
      );
  }

  /**
   * @method createOrganizationAddress
   * @description Get a list of Organizations
   * @param headers IGenericStringObject
   * @param query IGenericStringObject
   * @returns `Observable<OrganizationList>`
   */
  getOrganizations(
    headers: IGenericStringObject,
    query: IGenericStringObject
  ): Observable<OrganizationList> {
    return this.http
      .get(this.httpConfigService.getOrganizations(), {
        headers,
        params: snakeCaseObjectKeys(query),
      })
      .pipe(
        map(response => {
          const camelCaseResponse: ListWrapper<Organization> = camelCaseObjectKeys<
            IListWrapperAPI<IOrganizationAPI>,
            ListWrapper<Organization>
          >(response.data);

          const items: OrganizationMapped[] = camelCaseResponse.items.map((item: Organization) => ({
            organizationId: item.organizationId,
            organizationName: item.organizationName,
            organizationCode: item.organizationCode,
            isActive: item.isActive,
          }));

          return {
            ...camelCaseResponse,
            items,
          };
        }),
        catchError(err => {
          throw new HttpException(busHierErrorMapper(err), err.response?.status);
        })
      );
  }

  /**
   * @method getOrganizationById
   * @description Get Organization by Id
   * @param id string representing the organizationId
   * @param headers IGenericStringObject
   * @returns `Observable<Organization>`
   */
  getOrganizationById(id: string, headers: IGenericStringObject): Observable<Organization> {
    return this.http.get(this.httpConfigService.getOrganizationById(id), { headers }).pipe(
      map(response => camelCaseObjectKeys<IOrganizationAPI, Organization>(response.data)),
      catchError(err => {
        throw new HttpException(busHierErrorMapper(err), err.response?.status);
      })
    );
  }

  /**
   * @method getOrganizationAddresses
   * @description Update an Organization
   * @param id string representing the organizationId
   * @param headers IGenericStringObject
   * @param body UpdateOrganizationDto
   * @returns `Observable<Organization | BusHierError>`
   */
  updateOrganization(
    id: string,
    headers: IGenericStringObject,
    body: UpdateOrganizationDto
  ): Observable<Organization | BusHierError> {
    return this.http
      .put(this.httpConfigService.updateOrganization(id), snakeCaseObjectKeys(body), { headers })
      .pipe(
        map(response => camelCaseObjectKeys<IOrganizationAPI, Organization>(response.data)),
        catchError(err => {
          throw new HttpException(busHierErrorMapper(err), err.response?.status);
        })
      );
  }
}
The httpConfigService simply determines whether the method will use a local path or not for example:
Copy code
public createOrganization(): string {
    const config: httpConfig = {
      path: this.organizationUrl,
      localPath: '/create-organization.json',
    };

    return this.getPath(config);
  }
Sorry for the wall of text. Let me know if this is sufficient or if you need anything else from me
m
Sorry for missing this Kevin
I can’t reproduce locally, but it could be something I’m not getting right. Could you please modify this test to make it fail in the way that reproduces your problem: https://github.com/pact-foundation/pact-js/blob/repro/x.avidxchange/examples/mocha/test/get-dogs.spec.js If you can reproduce it, please raise a bug here so that we can get it fixed
k
Thanks for the reply, Matt. I’ll fork this repo and do my best to modify it to replicate. I’ll keep you updated
hey @Matt (pactflow.io / pact-js / pact-go) starting to take a look at those tests. Some key differences here are that we are using PactV3, and using Jest to test as opposed to Mocha/Chai. Would it be alright if I forked our repo and sent you an example version of our code base with the failing Pact tests?