From 4da4cb3e917c541a5633246d6c1f4c4a22d4e5fd Mon Sep 17 00:00:00 2001 From: mefellows Date: Thu, 9 Mar 2023 22:18:56 +1100 Subject: [PATCH] fix: v4 response builder for non-plugin interface Fixes #1073 --- src/pact.integration.spec.ts | 476 ++++++++++++----------------------- src/v4/http/index.ts | 22 +- src/v4/http/types.ts | 9 +- src/v4/index.ts | 3 +- 4 files changed, 181 insertions(+), 329 deletions(-) diff --git a/src/pact.integration.spec.ts b/src/pact.integration.spec.ts index 5bb3fe459..d336053c8 100644 --- a/src/pact.integration.spec.ts +++ b/src/pact.integration.spec.ts @@ -1,316 +1,160 @@ -// /* tslint:disable:no-unused-expression object-literal-sort-keys no-empty no-console */ -// import * as Promise from "bluebird" -// import * as chai from "chai" -// import * as chaiAsPromised from "chai-as-promised" -// import * as path from "path" -// import * as superagent from "superagent" -// import { HTTPMethod } from "./common/request" -// import { Matchers, Pact } from "./pact" - -// chai.use(chaiAsPromised) -// const expect = chai.expect -// const { eachLike, like, term } = Matchers - -// describe("Integration", () => { -// const protocols = ["http", "https"] - -// protocols.forEach(protocol => { -// describe(`Pact on ${protocol} protocol`, () => { -// const MOCK_PORT = Math.floor(Math.random() * 999) + 9000 -// const PROVIDER_URL = `${protocol}://localhost:${MOCK_PORT}` -// // TODO: this is eagerly run (even if test skipped) -// const provider = new Pact({ -// consumer: "Matching Service", -// provider: "Animal Profile Service", -// port: MOCK_PORT, -// log: path.resolve(process.cwd(), "logs", "mockserver-integration.log"), -// dir: path.resolve(process.cwd(), "pacts"), -// logLevel: "warn", -// ssl: protocol === "https", -// spec: 2, -// }) - -// const EXPECTED_BODY = [ -// { -// id: 1, -// name: "Project 1", -// due: "2016-02-11T09:46:56.023Z", -// tasks: [ -// { -// id: 1, -// name: "Do the laundry", -// done: true, -// }, -// { -// id: 2, -// name: "Do the dishes", -// done: false, -// }, -// { -// id: 3, -// name: "Do the backyard", -// done: false, -// }, -// { -// id: 4, -// name: "Do nothing", -// done: false, -// }, -// ], -// }, -// ] - -// before(() => provider.setup()) - -// // once all tests are run, write pact and remove interactions -// after(() => provider.finalize()) - -// context("with a single request", () => { -// // add interactions, as many as needed -// before(() => -// provider.addInteraction({ -// state: "i have a list of projects", -// uponReceiving: "a request for projects", -// withRequest: { -// method: HTTPMethod.GET, -// path: "/projects", -// headers: { -// Accept: "application/json", -// }, -// }, -// willRespondWith: { -// status: 200, -// headers: { -// "Content-Type": "application/json", -// }, -// body: EXPECTED_BODY, -// }, -// }) -// ) - -// // execute your assertions -// it("returns the correct body", () => -// superagent -// .get(`${PROVIDER_URL}/projects`) -// .set({ -// Accept: "application/json", -// }) -// .then((res: any) => { -// expect(res.text).to.eql(JSON.stringify(EXPECTED_BODY)) -// })) - -// // verify with Pact, and reset expectations -// it("successfully verifies", () => provider.verify()) -// }) - -// context("with a single request with query string parameters", () => { -// // add interactions, as many as needed -// before(() => { -// return provider.addInteraction({ -// state: "i have a list of projects", -// uponReceiving: "a request for projects with a filter", -// withRequest: { -// method: HTTPMethod.GET, -// path: "/projects", -// query: { -// from: "today", -// }, -// headers: { -// Accept: "application/json", -// }, -// }, -// willRespondWith: { -// status: 200, -// headers: { -// "Content-Type": "application/json", -// }, -// body: EXPECTED_BODY, -// }, -// }) -// }) - -// // execute your assertions -// it("returns the correct body", () => -// superagent -// .get(`${PROVIDER_URL}/projects?from=today`) -// .set({ -// Accept: "application/json", -// }) -// .then((res: any) => { -// expect(res.text).to.eql(JSON.stringify(EXPECTED_BODY)) -// })) - -// // verify with Pact, and reset expectations -// it("successfully verifies", () => provider.verify()) -// }) - -// context("with a single request and eachLike, like, term", () => { -// // add interactions, as many as needed -// before(() => { -// return provider.addInteraction({ -// state: "i have a list of projects but I dont know how many", -// uponReceiving: "a request for such projects", -// withRequest: { -// method: HTTPMethod.GET, -// path: "/projects", -// headers: { -// Accept: "application/json", -// }, -// }, -// willRespondWith: { -// status: 200, -// headers: { -// "Content-Type": term({ -// generate: "application/json", -// matcher: "application/json", -// }), -// }, -// body: [ -// { -// id: 1, -// name: "Project 1", -// due: "2016-02-11T09:46:56.023Z", -// tasks: eachLike( -// { -// id: like(1), -// name: like("Do the laundry"), -// done: like(true), -// }, -// { -// min: 4, -// } -// ), -// }, -// ], -// }, -// }) -// }) - -// // execute your assertions -// it("returns the correct body", () => { -// const verificationPromise = superagent -// .get(`${PROVIDER_URL}/projects`) -// .set({ Accept: "application/json" }) -// .then((res: any) => JSON.parse(res.text)[0]) - -// return expect(verificationPromise).to.eventually.have.property( -// "tasks" -// ) -// }) - -// // verify with Pact, and reset expectations -// it("successfully verifies", () => provider.verify()) -// }) - -// context("with two requests", () => { -// before(() => { -// const interaction1 = provider.addInteraction({ -// state: "i have a list of projects", -// uponReceiving: "a request for projects", -// withRequest: { -// method: HTTPMethod.GET, -// path: "/projects", -// headers: { -// Accept: "application/json", -// }, -// }, -// willRespondWith: { -// status: 200, -// headers: { -// "Content-Type": "application/json", -// }, -// body: EXPECTED_BODY, -// }, -// }) - -// const interaction2 = provider.addInteraction({ -// state: "i have a list of projects", -// uponReceiving: "a request for a project that does not exist", -// withRequest: { -// method: HTTPMethod.GET, -// path: "/projects/2", -// headers: { -// Accept: "application/json", -// }, -// }, -// willRespondWith: { -// status: 404, -// headers: { -// "Content-Type": "application/json", -// }, -// }, -// }) - -// return Promise.all([interaction1, interaction2]) -// }) - -// it("allows two requests", () => { -// const verificationPromise = superagent -// .get(`${PROVIDER_URL}/projects`) -// .set({ -// Accept: "application/json", -// }) -// .then((res: any) => res.text) - -// const verificationPromise404 = superagent -// .get(`${PROVIDER_URL}/projects/2`) -// .set({ -// Accept: "application/json", -// }) -// return Promise.all([ -// expect(verificationPromise).to.eventually.equal( -// JSON.stringify(EXPECTED_BODY) -// ), -// expect(verificationPromise404).to.eventually.be.rejected, -// ]) -// }) - -// // verify with Pact, and reset expectations -// it("successfully verifies", () => provider.verify()) -// }) - -// context("with an unexpected interaction", () => { -// // add interactions, as many as needed -// before(() => -// provider -// .addInteraction({ -// state: "i have a list of projects", -// uponReceiving: "a request for projects", -// withRequest: { -// method: HTTPMethod.GET, -// path: "/projects", -// headers: { -// Accept: "application/json", -// }, -// }, -// willRespondWith: { -// status: 200, -// headers: { -// "Content-Type": "application/json", -// }, -// body: EXPECTED_BODY, -// }, -// }) -// .then( -// () => console.log("Adding interaction worked"), -// () => console.warn("Adding interaction failed.") -// ) -// ) - -// it("fails verification", () => { -// const verificationPromise = superagent -// .get(`${PROVIDER_URL}/projects`) -// .set({ Accept: "application/json" }) -// .then(() => -// superagent.delete(`${PROVIDER_URL}/projects/2`).catch(() => {}) -// ) -// .then(() => provider.verify()) - -// return expect(verificationPromise).to.be.rejectedWith( -// "Pact verification failed - expected interactions did not match actual." -// ) -// }) -// }) -// }) -// }) -// }) +/* tslint:disable:no-unused-expression no-empty */ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import axios from 'axios'; +import net = require('net'); +import { PactV4 } from './v4'; + +const { expect } = chai; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +describe('V4 Pact', () => { + let pact: PactV4; + + beforeEach(() => { + pact = new PactV4({ + consumer: 'v4consumer', + provider: 'v4provider', + }); + }); + + describe('HTTP req/res contract', () => { + it('generates a pact', () => + pact + .addInteraction() + .given('some state') + .given('a second state') + .uponReceiving('a standard HTTP req/res') + .withRequest('POST', '/', (builder) => { + builder + .jsonBody({ + foo: 'bar', + }) + .headers({ + 'x-foo': 'x-bar', + }); + }) + .willRespondWith(200, (builder) => { + builder + .jsonBody({ + foo: 'bar', + }) + .headers({ + 'x-foo': 'x-bar', + }); + }) + .executeTest(async (server) => + axios.post( + server.url, + { + foo: 'bar', + }, + { + headers: { + 'x-foo': 'x-bar', + }, + } + ) + )); + }); + + describe('Plugin test', () => { + describe('Using the MATT plugin', () => { + const parseMattMessage = (raw: string): string => + raw.replace(/(MATT)+/g, '').trim(); + + const generateMattMessage = (raw: string): string => `MATT${raw}MATT`; + + describe('HTTP interface', () => { + it('generates a pact', async () => { + const mattRequest = `{"request": {"body": "hello"}}`; + const mattResponse = `{"response":{"body":"world"}}`; + + await pact + .addInteraction() + .given('the Matt protocol exists') + .uponReceiving('an HTTP request to /matt') + .usingPlugin({ + plugin: 'matt', + version: '0.0.7', + }) + .withRequest('POST', '/matt', (builder) => { + builder.pluginContents('application/matt', mattRequest); + }) + .willRespondWith(200, (builder) => { + builder.pluginContents('application/matt', mattResponse); + }) + .executeTest((mockserver) => + axios + .request({ + baseURL: mockserver.url, + headers: { + 'content-type': 'application/matt', + Accept: 'application/matt', + }, + data: generateMattMessage('hello'), + method: 'POST', + url: '/matt', + }) + .then((res) => { + expect(parseMattMessage(res.data)).to.eq('world'); + }) + ); + }); + }); + + describe('Synchronous Message (TCP) ', () => { + describe('with MATT protocol', () => { + const HOST = '127.0.0.1'; + + const sendMattMessageTCP = ( + message: string, + host: string, + port: number + ): Promise => { + const socket = net.connect({ + port, + host, + }); + + const res = socket.write(`${generateMattMessage(message)}\n`); + + if (!res) { + throw Error('unable to connect to host'); + } + + return new Promise((resolve) => { + socket.on('data', (data) => { + resolve(parseMattMessage(data.toString())); + }); + }); + }; + + it('generates a pact', () => { + const mattMessage = `{"request": {"body": "hellotcp"}, "response":{"body":"tcpworld"}}`; + + return pact + .addSynchronousInteraction('a MATT message') + .usingPlugin({ + plugin: 'matt', + version: '0.0.7', + }) + .withPluginContents(mattMessage, 'application/matt') + .startTransport('matt', HOST) + .executeTest(async (tc) => { + const message = await sendMattMessageTCP( + 'hellotcp', + HOST, + tc.port + ); + expect(message).to.eq('tcpworld'); + }); + }); + }); + }); + }); + }); +}); diff --git a/src/v4/http/index.ts b/src/v4/http/index.ts index 6e6c9ba08..858c7e4b3 100644 --- a/src/v4/http/index.ts +++ b/src/v4/http/index.ts @@ -3,7 +3,7 @@ import { ConsumerInteraction, ConsumerPact } from '@pact-foundation/pact-core'; import { JsonMap } from '../../common/jsonTypes'; import { forEachObjIndexed } from 'ramda'; import { Path, TemplateHeaders, TemplateQuery, V3MockServer } from '../../v3'; -import { AnyTemplate, Matcher, matcherValueOrString } from '../../v3/matchers'; +import { Matcher, matcherValueOrString } from '../../v3/matchers'; import { PactV4Options, PluginConfig, @@ -66,7 +66,8 @@ export class UnconfiguredInteraction implements V4UnconfiguredInteraction { return new InteractionWithCompleteRequest( this.pact, this.interaction, - this.opts + this.opts, + request ); } @@ -93,11 +94,11 @@ export class UnconfiguredInteraction implements V4UnconfiguredInteraction { export class InteractionWithCompleteRequest implements V4InteractionWithCompleteRequest { - // tslint:disable:no-empty-function constructor( private pact: ConsumerPact, private interaction: ConsumerInteraction, - private opts: PactV4Options + private opts: PactV4Options, + private request: V4Request ) { throw Error('V4InteractionWithCompleteRequest is unimplemented'); } @@ -116,6 +117,12 @@ export class InteractionwithRequest implements V4InteractionwithRequest { ) {} willRespondWith(status: number, builder?: V4ResponseBuilderFunc) { + this.interaction.withStatus(status); + + if (typeof builder === 'function') { + builder(new ResponseBuilder(this.interaction)); + } + return new InteractionWithResponse(this.pact, this.interaction, this.opts); } } @@ -158,7 +165,7 @@ export class RequestBuilder implements V4RequestBuilder { return this; } - jsonBody(body: AnyTemplate) { + jsonBody(body: unknown) { this.interaction.withRequestBody( matcherValueOrString(body), 'application/json' @@ -189,7 +196,6 @@ export class RequestBuilder implements V4RequestBuilder { export class ResponseBuilder implements V4ResponseBuilder { protected interaction: ConsumerInteraction; - // tslint:disable:no-empty-function constructor(interaction: ConsumerInteraction) { this.interaction = interaction; } @@ -202,7 +208,7 @@ export class ResponseBuilder implements V4ResponseBuilder { return this; } - jsonBody(body: AnyTemplate) { + jsonBody(body: unknown) { this.interaction.withResponseBody( matcherValueOrString(body), 'application/json' @@ -290,6 +296,8 @@ export class InteractionWithPluginRequest status: number, builder?: V4PluginResponseBuilderFunc ): V4InteractionWithPluginResponse { + this.interaction.withStatus(status); + if (typeof builder === 'function') { builder(new ResponseWithPluginBuilder(this.interaction)); } diff --git a/src/v4/http/types.ts b/src/v4/http/types.ts index db2835074..9e5ea7877 100644 --- a/src/v4/http/types.ts +++ b/src/v4/http/types.ts @@ -9,7 +9,6 @@ import { V3Request, V3Response, } from '../../v3'; -import { AnyTemplate } from '../../v3/matchers'; // TODO: do we alias all types to V4 or is this yicky?? // These types are all interface types, so any extensions/modifications @@ -84,7 +83,7 @@ export type V4RequestBuilderFunc = (builder: V4RequestBuilder) => void; export interface V4RequestBuilder { query(query: TemplateQuery): V4RequestBuilder; headers(headers: TemplateHeaders): V4RequestBuilder; - jsonBody(body: AnyTemplate): V4RequestBuilder; + jsonBody(body: unknown): V4RequestBuilder; binaryFile(contentType: string, file: string): V4RequestBuilder; multipartBody( contentType: string, @@ -96,7 +95,7 @@ export interface V4RequestBuilder { export interface V4ResponseBuilder { headers(headers: TemplateHeaders): V4ResponseBuilder; - jsonBody(body: AnyTemplate): V4ResponseBuilder; + jsonBody(body: unknown): V4ResponseBuilder; binaryFile(contentType: string, file: string): V4ResponseBuilder; multipartBody( contentType: string, @@ -146,7 +145,7 @@ export interface V4InteractionWithPluginRequest { export interface V4RequestWithPluginBuilder { query(query: TemplateQuery): V4RequestWithPluginBuilder; headers(headers: TemplateHeaders): V4RequestWithPluginBuilder; - jsonBody(body: AnyTemplate): V4RequestWithPluginBuilder; + jsonBody(body: unknown): V4RequestWithPluginBuilder; binaryFile(contentType: string, file: string): V4RequestWithPluginBuilder; multipartBody( contentType: string, @@ -162,7 +161,7 @@ export interface V4RequestWithPluginBuilder { export interface V4ResponseWithPluginBuilder { headers(headers: TemplateHeaders): V4ResponseBuilder; - jsonBody(body: AnyTemplate): V4ResponseBuilder; + jsonBody(body: unknown): V4ResponseBuilder; binaryFile(contentType: string, file: string): V4ResponseBuilder; multipartBody( contentType: string, diff --git a/src/v4/index.ts b/src/v4/index.ts index 7762e97c1..707b2b4a3 100644 --- a/src/v4/index.ts +++ b/src/v4/index.ts @@ -5,6 +5,7 @@ import { V4ConsumerPact } from './types'; import { version as pactPackageVersion } from '../../package.json'; import { V4UnconfiguredSynchronousMessage } from './message/types'; import { UnconfiguredSynchronousMessage } from './message'; +import { SpecificationVersion } from '../v3'; export class PactV4 implements V4ConsumerPact { private pact: ConsumerPact; @@ -19,7 +20,7 @@ export class PactV4 implements V4ConsumerPact { this.pact = makeConsumerPact( opts.consumer, opts.provider, - opts.spec, + opts.spec ?? SpecificationVersion.SPECIFICATION_VERSION_V4, opts.logLevel );