Skip to content

Commit

Permalink
feat: Add a beta interface to the FFI / V3 Consumer tests. Try it out…
Browse files Browse the repository at this point in the history
… with `import { makeConsumerPact } from 'pact-core/src/consumer'`
  • Loading branch information
TimothyJones committed Sep 30, 2021
1 parent 4e5969e commit 46d6667
Show file tree
Hide file tree
Showing 11 changed files with 319 additions and 30 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ reports
tmp
.tmp

# Files created during testing
foo-consumer-bar-provider.json

# jest config
!jest.config.js

Expand Down
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ script
# Tests
test
*.spec.ts
foo-consumer-bar-provider.json

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"@types/url-join": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^4.28.0",
"@typescript-eslint/parser": "^4.28.0",
"axios": "^0.21.4",
"basic-auth": "2.0.0",
"body-parser": "1.18.2",
"chai": "4.1.2",
Expand Down
14 changes: 8 additions & 6 deletions src/consumer/checkErrors.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import logger from '../logger';

export const wrapWithCheck =
<Params extends Array<unknown>, F extends (...args: Params) => boolean>(
f: F,
<F extends (...args: never[]) => boolean>(
f: BooleanFunction<F>,
contextMessage: string
) =>
(...args: Params): boolean => {
(...args: Parameters<F>): boolean => {
const result = f(...args);
if (!result) {
logger.pactCrash(
Expand All @@ -15,10 +15,12 @@ export const wrapWithCheck =
return result;
};

type BooleanFunction<T> = T extends (...args: infer A) => boolean
? (...args: A) => boolean
: never;

type BooleanFunctions<T> = {
[key in keyof T]: T[key] extends (...args: infer A) => boolean
? (...args: A) => boolean
: never;
[key in keyof T]: BooleanFunction<T[key]>;
};

export const wrapAllWithCheck = <T extends BooleanFunctions<T>>(
Expand Down
147 changes: 147 additions & 0 deletions src/consumer/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import chai = require('chai');
import chaiAsPromised = require('chai-as-promised');

import { makeConsumerPact } from '.';
import { FfiSpecificationVersion } from '../ffi/types';
import axios from 'axios';
import path = require('path');
import { setLogLevel } from '../logger';
import { ConsumerPact } from './types';

chai.use(chaiAsPromised);
const expect = chai.expect;

const HOST = '127.0.0.1';

describe('Integration like test for the consumer API', () => {
setLogLevel('trace');
let port: number;
let pact: ConsumerPact;

beforeEach(() => {
const like = (value: unknown) => {
return {
'pact:matcher:type': 'type',
value,
};
};

pact = makeConsumerPact(
'foo-consumer',
'bar-provider',
FfiSpecificationVersion.SPECIFICATION_VERSION_V3
);

const interaction = pact.newInteraction('some description');

interaction.uponReceiving('a request to get a dog');
interaction.given('fido exists');
interaction.withRequest('GET', '/dogs/1234');
interaction.withRequestHeader('x-special-header', 0, 'header');
interaction.withQuery('someParam', 0, 'someValue');
interaction.withResponseBody(
JSON.stringify({
name: like('fido'),
age: like(23),
alive: like(true),
}),
'application/json'
);
interaction.withResponseHeader('x-special-header', 0, 'header');
interaction.withStatus(200);

port = pact.createMockServer(HOST);
});

it('Generates a pact with success', () => {
return axios
.request({
baseURL: `http://${HOST}:${port}`,
headers: { Accept: 'application/json', 'x-special-header': 'header' },
params: {
someParam: 'someValue',
},
method: 'GET',
url: '/dogs/1234',
})
.then((res) => {
expect(res.data).to.deep.equal({
name: 'fido',
age: 23,
alive: true,
});
})
.then(() => {
expect(pact.mockServerMatchedSuccessfully(port)).to.be.true;
})
.then(() => {
// You don't have to call this, it's just here to check it works
const mismatches = pact.mockServerMismatches(port);
expect(mismatches).to.have.length(0);
})
.then(() => {
pact.writePactFile(port, path.join(__dirname, '__testoutput__'));
})
.then(() => {
pact.cleanupMockServer(port);
});
});
it('Generates a pact with failure', () => {
return axios
.request({
baseURL: `http://${HOST}:${port}`,
headers: {
Accept: 'application/json',
'x-special-header': 'WrongHeader',
},
params: {
someParam: 'wrongValue',
},
method: 'GET',
url: '/dogs/1234',
})
.then(
() => {
throw new Error(
'This call is not supposed to succeed during testing'
);
},
(err) => {
expect(err.message).to.equal('Request failed with status code 500');
}
)
.then(() => {
const mismatches = pact.mockServerMismatches(port);
expect(mismatches[0]).to.deep.equal({
method: 'GET',
mismatches: [
{
actual: 'wrongValue',
expected: 'someValue',
mismatch: "Expected 'someValue' to be equal to 'wrongValue'",
parameter: 'someParam',
type: 'QueryMismatch',
},
{
actual: 'WrongHeader',
expected: 'header',
key: 'x-special-header',
mismatch:
"Mismatch with header 'x-special-header': Expected 'header' to be equal to 'WrongHeader'",
type: 'HeaderMismatch',
},
],
path: '/dogs/1234',
type: 'request-mismatch',
});
})
.then(() => {
// Yes, this writes the pact file.
// Yes, even though the tests have failed
pact.writePactFile(port, path.join(__dirname, '__testoutput__'));
})
.then(() => {
pact.cleanupMockServer(port);
});
});
});
71 changes: 58 additions & 13 deletions src/consumer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import { getFfiLib } from '../ffi';
import {
CREATE_MOCK_SERVER_ERRORS,
FfiSpecificationVersion,
FfiWritePactResponse,
INTERACTION_PART_REQUEST,
INTERACTION_PART_RESPONSE,
} from '../ffi/types';
import { logCrashAndThrow, logErrorAndThrow } from '../logger';
import { wrapAllWithCheck, wrapWithCheck } from './checkErrors';

import { ConsumerInteraction, ConsumerPact, MatchingResult } from './types';
import {
ConsumerInteraction,
ConsumerPact,
MatchingResult,
Mismatch,
} from './types';

type AnyJson = boolean | number | string | null | JsonArray | JsonMap;
interface JsonMap {
Expand All @@ -24,13 +30,21 @@ export const makeConsumerPact = (
const lib = getFfiLib();

const pactPtr = lib.pactffi_new_pact(consumer, provider);
lib.pactffi_with_specification(pactPtr, version);
if (!lib.pactffi_with_specification(pactPtr, version)) {
throw new Error(
`Unable to set core spec version. The pact FfiSpecificationVersion '${version}' may be invalid (note this is not the same as the pact spec version)`
);
}

return {
createMockServer: (address: string, tls = false) => {
createMockServer: (
address: string,
requestedPort?: number,
tls = false
) => {
const port = lib.pactffi_create_mock_server_for_pact(
pactPtr,
address,
`${address}:${requestedPort ? requestedPort : 0}`,
tls
);
const error: keyof typeof CREATE_MOCK_SERVER_ERRORS | undefined =
Expand All @@ -40,7 +54,7 @@ export const makeConsumerPact = (
if (error) {
if (error === 'ADDRESS_NOT_VALID') {
logErrorAndThrow(
`Unable to start mock server at '${address}'. Is the address valid?`
`Unable to start mock server at '${address}'. Is the address and port valid?`
);
}
if (error === 'TLS_CONFIG') {
Expand All @@ -59,21 +73,52 @@ export const makeConsumerPact = (
}
return port;
},
mockServerMismatches: (port: number): MatchingResult => {
const result = JSON.parse(lib.pactffi_mock_server_mismatches(port));
return {

mockServerMatchedSuccessfully: (port: number) => {
return lib.pactffi_mock_server_matched(port);
},
mockServerMismatches: (port: number): MatchingResult[] => {
const results: MatchingResult[] = JSON.parse(
lib.pactffi_mock_server_mismatches(port)
);
return results.map((result: MatchingResult) => ({
...result,
...(result.mismatches
? { mismatches: result.mismatches.map((m: string) => JSON.parse(m)) }
...('mismatches' in result
? {
mismatches: result.mismatches.map((m: string | Mismatch) =>
typeof m === 'string' ? JSON.parse(m) : m
),
}
: {}),
};
}));
},
cleanupMockServer: (port: number): boolean => {
return wrapWithCheck<[number], (port: number) => boolean>(
(port: number) => lib.pactffi_cleanup_mock_server(port),
return wrapWithCheck<(port: number) => boolean>(
(port: number): boolean => lib.pactffi_cleanup_mock_server(port),
'cleanupMockServer'
)(port);
},
writePactFile: (port: number, dir: string, merge = true) => {
const result = lib.pactffi_write_pact_file(port, dir, !merge);
switch (result) {
case FfiWritePactResponse.SUCCESS:
return;
case FfiWritePactResponse.UNABLE_TO_WRITE_PACT_FILE:
logErrorAndThrow('The pact core was unable to write the pact file');
case FfiWritePactResponse.GENERAL_PANIC:
logCrashAndThrow(
'The pact core panicked while writing the pact file'
);
case FfiWritePactResponse.MOCK_SERVER_NOT_FOUND:
logCrashAndThrow(
'The pact core was asked to write a pact file from a mock server that appears not to exist'
);
default:
logCrashAndThrow(
`The pact core returned an unknown error code (${result}) instead of writing the pact`
);
}
},
newInteraction: (description: string): ConsumerInteraction => {
const interactionPtr = lib.pactffi_new_interaction(pactPtr, description);
return wrapAllWithCheck<ConsumerInteraction>({
Expand Down
24 changes: 22 additions & 2 deletions src/consumer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export type MatchingResult =
| MatchingResultRequestNotFound
| MatchingResultMissingRequest;

// As far as I can tell, MatchingResultSuccess is actually
// never produced by the FFI lib
export type MatchingResultSuccess = {
type: 'request-match';
};
Expand Down Expand Up @@ -114,7 +116,25 @@ export type ConsumerInteraction = {

export type ConsumerPact = {
newInteraction: (description: string) => ConsumerInteraction;
createMockServer: (address: string, tls?: boolean) => number;
mockServerMismatches: (port: number) => MatchingResult;
createMockServer: (address: string, port?: number, tls?: boolean) => number;
mockServerMismatches: (port: number) => MatchingResult[];
cleanupMockServer: (port: number) => boolean;
/**
* This function writes the pact file, regardless of whether or not the test was successful.
* Do not call it without checking that the tests were successful, unless you want to write the wrong pact contents.
*
* @param port the port number the mock server is running on.
* @param dir the directory to write the pact file to
* @param merge whether or not to merge the pact file contents (default true)
*/
writePactFile: (port: number, dir: string, merge?: boolean) => void;
/**
* Check if a mock server has matched all its requests.
*
* @param port the port number the mock server is running on.
* @returns {boolean} true if all requests have been matched. False if there
* is no mock server on the given port, or if any request has not been successfully matched, or
* the method panics.
*/
mockServerMatchedSuccessfully: (port: number) => boolean;
};
Loading

0 comments on commit 46d6667

Please sign in to comment.