diff --git a/src/v4/consumer.spec.ts b/src/v4/consumer.spec.ts new file mode 100644 index 000000000..98953c55f --- /dev/null +++ b/src/v4/consumer.spec.ts @@ -0,0 +1,92 @@ +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { LogLevel } from '@pact-foundation/pact-core'; +import axios from 'axios'; +import { SpecificationVersion, MatchersV3 } from '../v3'; +import { PactV4 } from '.'; + +chai.use(chaiAsPromised); + +const { expect } = chai; + +process.env.ENABLE_FEATURE_V4 = 'true'; + +describe('V4ConsumerPact', () => { + const pact = new PactV4({ + consumer: 'mymessageconsumer', + provider: 'mymessageprovider', + spec: SpecificationVersion.SPECIFICATION_VERSION_V4, + logLevel: (process.env.LOG_LEVEL as LogLevel) || 'trace', + }); + + describe('httpInteraction', () => { + it('passes', async () => { + await pact + .addInteraction() + .uponReceiving('a request only checks the keys and ignores the values') + .withRequest('GET', '/') + .willRespondWith(200, (builder) => { + builder.jsonBody({ + key1: MatchersV3.string("a string we don't care about"), + key2: 1, + }); + }) + .executeTest((mockserver) => + axios + .request({ + baseURL: mockserver.url, + method: 'GET', + url: '/', + }) + .then((res) => { + expect(res.data.key1).to.equal("a string we don't care about"); + expect(res.data.key2).to.equal(1); + }) + ); + }); + }); + + describe('synchronousMessageInteraction', () => { + it('passes', async () => { + await pact + .addSynchronousInteraction('a synchronous message') + .given('a test') + .withRequest((builder) => { + builder.withJSONContent({ + key1: MatchersV3.string('request string'), + key2: 1, + }); + }) + .withResponse((builder) => { + builder.withJSONContent({ + key1: MatchersV3.string('response string'), + key2: 1, + }); + }) + .executeTest(async () => {}); + }); + }); + + describe('asynchronousMessageInteraction', () => { + it('passes', () => { + pact + .addAsynchronousInteraction('an asynchronous message') + .given('a test') + .expectsToReceive('an asynchronous message') + .withContents({ + messageId: '1234', + messageBody: { + value: 'expected', + }, + }) + .executeTest(async (message) => { + expect(message).to.deep.equal({ + messageId: '1234', + messageBody: { + value: 'expected', + }, + }); + }); + }); + }); +}); diff --git a/src/v4/index.ts b/src/v4/index.ts index 85f999e81..bbe7a05d4 100644 --- a/src/v4/index.ts +++ b/src/v4/index.ts @@ -3,8 +3,14 @@ import { UnconfiguredInteraction } from './http'; import { PactV4Options, V4UnconfiguredInteraction } from './http/types'; import { V4ConsumerPact } from './types'; import { version as pactPackageVersion } from '../../package.json'; -import { V4UnconfiguredSynchronousMessage } from './message/types'; -import { UnconfiguredSynchronousMessage } from './message'; +import { + V4UnconfiguredSynchronousMessage, + V4UnconfiguredAsynchronousMessage, +} from './message/types'; +import { + UnconfiguredSynchronousMessage, + UnconfiguredAsynchronousMessage, +} from './message'; import { SpecificationVersion } from '../v3'; export class PactV4 implements V4ConsumerPact { @@ -53,4 +59,17 @@ export class PactV4 implements V4ConsumerPact { } ); } + + addAsynchronousInteraction( + description: string + ): V4UnconfiguredAsynchronousMessage { + return new UnconfiguredAsynchronousMessage( + this.pact, + this.pact.newAsynchronousMessage(description), + this.opts, + () => { + this.setup(); + } + ); + } } diff --git a/src/v4/message/index.ts b/src/v4/message/index.ts index 934de160f..58a40f4b7 100644 --- a/src/v4/message/index.ts +++ b/src/v4/message/index.ts @@ -15,8 +15,12 @@ import { V4SynchronousMessageWithResponseBuilder, V4SynchronousMessageWithTransport, V4UnconfiguredSynchronousMessage, + V4AsynchronousMessage, + V4AsynchronousMessageWithContents, + V4UnconfiguredAsynchronousMessage, } from './types'; import { + AsynchronousMessage as PactCoreAsynchronousMessage, SynchronousMessage as PactCoreSynchronousMessage, ConsumerPact, } from '@pact-foundation/pact-core'; @@ -28,7 +32,10 @@ import { generateMockServerError, } from '../../v3/display'; import logger from '../../common/logger'; -import { isMatcher as isV3Matcher } from '../../v3/matchers'; +import { + isMatcher as isV3Matcher, + matcherValueOrString, +} from '../../v3/matchers'; const defaultPactDir = './pacts'; @@ -375,6 +382,51 @@ const cleanup = ( cleanupFn(); }; +type ReifiedMessage = { + contents: { + content: unknown | null; + contentType: string; + encoded: boolean; + }; + description: string; + pending: boolean; + providerStates: Array<{ name: string; params?: JsonMap }>; +}; + +const executeAsyncMessageTest = async ( + pact: ConsumerPact, + interaction: PactCoreAsynchronousMessage, + opts: PactV4Options, + integrationTest: (content: unknown, contentType: string, encoded: boolean) => Promise, + cleanupFn: () => void +): Promise => { + let val: T | undefined; + let error: Error | undefined; + + try { + const rawInteraction: ReifiedMessage = JSON.parse( + interaction.reifyMessage() + ); + const { content, contentType, encoded } = rawInteraction.contents; + val = await integrationTest(content, contentType, encoded); + } catch (e) { + error = e; + console.error(`Error: ${e}`); + } + + // Scenario: test threw an error, but Pact validation was OK (error in client or test) + if (error) { + cleanup(false, pact, opts, cleanupFn); + + throw error; + } + + // Scenario: Pact validation passed, test didn't throw - return the callback value + cleanup(true, pact, opts, cleanupFn); + + return val; +}; + const executeNonTransportTest = async ( pact: ConsumerPact, opts: PactV4Options, @@ -402,3 +454,93 @@ const executeNonTransportTest = async ( return val; }; + +// ASYNCHRONOUS + +export class UnconfiguredAsynchronousMessage + implements V4UnconfiguredAsynchronousMessage +{ + constructor( + protected pact: ConsumerPact, + protected interaction: PactCoreAsynchronousMessage, + protected opts: PactV4Options, + protected cleanupFn: () => void + ) {} + + given( + state: string, + parameters?: JsonMap + ): V4UnconfiguredAsynchronousMessage { + if (parameters) { + this.interaction.givenWithParams(state, JSON.stringify(parameters)); + } else { + this.interaction.given(state); + } + + return this; + } + + usingPlugin(config: PluginConfig): V4UnconfiguredAsynchronousMessage { + this.pact.addPlugin(config.plugin, config.version); + + return this; + } + + expectsToReceive(description: string): V4AsynchronousMessage { + this.interaction.expectsToReceive(description); + + return new AsynchronousMessage( + this.pact, + this.interaction, + this.opts, + this.cleanupFn + ); + } +} + +export class AsynchronousMessage implements V4AsynchronousMessage { + constructor( + protected pact: ConsumerPact, + protected interaction: PactCoreAsynchronousMessage, + protected opts: PactV4Options, + protected cleanupFn: () => void + ) {} + + withContents(contents: unknown): V4AsynchronousMessageWithContents { + this.interaction.withContents( + matcherValueOrString(contents), + 'application/json' + ); + + return new AsynchronousMessageWithContents( + this.pact, + this.interaction, + this.opts, + this.cleanupFn + ); + } +} + +export class AsynchronousMessageWithContents + implements V4AsynchronousMessageWithContents +{ + constructor( + protected pact: ConsumerPact, + protected interaction: PactCoreAsynchronousMessage, + protected opts: PactV4Options, + protected cleanupFn: () => void + ) {} + + executeTest( + integrationTest: (m: unknown) => Promise + ): Promise { + return executeAsyncMessageTest( + this.pact, + this.interaction, + this.opts, + integrationTest, + this.cleanupFn + ); + } +} + diff --git a/src/v4/message/types.ts b/src/v4/message/types.ts index 53ab97433..20559f526 100644 --- a/src/v4/message/types.ts +++ b/src/v4/message/types.ts @@ -100,3 +100,21 @@ export interface V4SynchronousMessageWithResponse { integrationTest: (m: SynchronousMessage) => Promise ): Promise; } + +// ASYNCHRONOUS + +export interface V4UnconfiguredAsynchronousMessage { + given(state: string, parameters?: JsonMap): V4UnconfiguredAsynchronousMessage; + usingPlugin(config: PluginConfig): V4UnconfiguredAsynchronousMessage; + expectsToReceive(description: string): V4AsynchronousMessage; +} + +export interface V4AsynchronousMessage { + withContents(contents: unknown): V4AsynchronousMessageWithContents; +} + +export interface V4AsynchronousMessageWithContents { + executeTest( + integrationTest: (m: unknown) => Promise + ): Promise; +} diff --git a/src/v4/types.ts b/src/v4/types.ts index 3c7d639e6..6fa9555a3 100644 --- a/src/v4/types.ts +++ b/src/v4/types.ts @@ -1,9 +1,15 @@ import { V4UnconfiguredInteraction } from './http/types'; -import { V4UnconfiguredSynchronousMessage } from './message/types'; +import { + V4UnconfiguredSynchronousMessage, + V4UnconfiguredAsynchronousMessage, +} from './message/types'; export interface V4ConsumerPact { addInteraction(): V4UnconfiguredInteraction; addSynchronousInteraction( description: string ): V4UnconfiguredSynchronousMessage; + addAsynchronousInteraction( + description: string + ): V4UnconfiguredAsynchronousMessage; }