From 044a93f9bf492ec3ad8385c2ffbb44508cde365c Mon Sep 17 00:00:00 2001 From: Yousaf Nabi Date: Sat, 8 Jun 2024 23:39:42 +0100 Subject: [PATCH] fix: merge pact duplicates with diff desc/same prov state fix: merge message / sync pacts with no provider state --- python/pact_http_dupe_test.py | 35 ++ python/pact_message_dupe_test.py | 26 ++ ...est-consumer-merge-test-provider-http.json | 106 +++++ ...-consumer-merge-test-provider-message.json | 86 ++++ rust/Cargo.lock | 55 +-- rust/Cargo.toml | 1 + rust/pact_ffi/tests/tests.rs | 401 ++++++++++++++++++ rust/pact_models/src/message_pact.rs | 2 +- rust/pact_models/src/pact.rs | 186 ++++++++ rust/pact_models/src/sync_pact.rs | 8 +- rust/pact_models/src/v4/pact.rs | 217 +++++++++- 11 files changed, 1064 insertions(+), 59 deletions(-) create mode 100644 python/pact_http_dupe_test.py create mode 100644 python/pact_message_dupe_test.py create mode 100644 python/pacts/merge-test-consumer-merge-test-provider-http.json create mode 100644 python/pacts/merge-test-consumer-merge-test-provider-message.json diff --git a/python/pact_http_dupe_test.py b/python/pact_http_dupe_test.py new file mode 100644 index 000000000..afa2b199d --- /dev/null +++ b/python/pact_http_dupe_test.py @@ -0,0 +1,35 @@ +from cffi import FFI +from register_ffi import get_ffi_lib +import json +import requests + +ffi = FFI() +lib = get_ffi_lib(ffi) # loads the entire C namespace +lib.pactffi_logger_init() +lib.pactffi_log_to_stdout(3) + +pact = lib.pactffi_new_pact(b'merge-test-consumer', b'merge-test-provider-http') +lib.pactffi_with_specification(pact, 5) +interaction = lib.pactffi_new_interaction(pact, b'a request for an order with an unknown ID') +lib.pactffi_with_request(interaction, b'GET', b'/api/orders/404') +lib.pactffi_with_header_v2(interaction, 0,b'Accept', 0, b'application/json') +lib.pactffi_response_status(interaction, 404) + +# Start mock server +mock_server_port = lib.pactffi_create_mock_server_for_transport(pact , b'0.0.0.0',0, b'http', b'{}') +print(f"Mock server started: {mock_server_port}") + +try: + response = requests.get(f"http://127.0.0.1:{mock_server_port}/api/orders/404", + headers={'Content-Type': 'application/json'}) + response.raise_for_status() +except requests.HTTPError as http_err: + print(f'Client request - HTTP error occurred: {http_err}') # Python 3.6 +except Exception as err: + print(f'Client request - Other error occurred: {err}') # Python 3.6 + +result = lib.pactffi_mock_server_matched(mock_server_port) +res_write_pact = lib.pactffi_write_pact_file(mock_server_port, './pacts'.encode('ascii'), False) + +## Cleanup +lib.pactffi_cleanup_mock_server(mock_server_port) diff --git a/python/pact_message_dupe_test.py b/python/pact_message_dupe_test.py new file mode 100644 index 000000000..98c29e29d --- /dev/null +++ b/python/pact_message_dupe_test.py @@ -0,0 +1,26 @@ +from cffi import FFI +from register_ffi import get_ffi_lib +import json +import requests + +ffi = FFI() +lib = get_ffi_lib(ffi) # loads the entire C namespace +lib.pactffi_logger_init() +lib.pactffi_log_to_stdout(3) +message_pact = lib.pactffi_new_pact(b'merge-test-consumer', b'merge-test-provider-message') +lib.pactffi_with_specification(message_pact, 5) +message = lib.pactffi_new_message(message_pact, b'an event indicating that an order has been created') +# lib.pactffi_message_expects_to_receive(message,b'Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message') +# lib.pactffi_message_given(message, b'A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required') +contents = { + "id": { + "pact:matcher:type": 'integer', + "value": '1' + } + } +length = len(json.dumps(contents)) +size = length + 1 +lib.pactffi_message_with_contents(message, b'application/json', ffi.new("char[]", json.dumps(contents).encode('ascii')), size) +reified = lib.pactffi_message_reify(message) +res_write_message_pact = lib.pactffi_write_message_pact_file(message_pact, './pacts'.encode('ascii'), False) +print(res_write_message_pact) diff --git a/python/pacts/merge-test-consumer-merge-test-provider-http.json b/python/pacts/merge-test-consumer-merge-test-provider-http.json new file mode 100644 index 000000000..18430e4a2 --- /dev/null +++ b/python/pacts/merge-test-consumer-merge-test-provider-http.json @@ -0,0 +1,106 @@ +{ + "consumer": { + "name": "merge-test-consumer" + }, + "interactions": [ + { + "description": "a request for an order by ID", + "pending": false, + "providerStates": [ + { + "name": "an order with ID {id} exists", + "params": { + "id": 1 + } + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/api/orders/1" + }, + "response": { + "body": { + "content": { + "date": "2023-06-28T12:13:14.0000000+01:00", + "id": 1, + "status": "Pending" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "matchingRules": { + "body": { + "$.date": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + }, + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "Pending|Fulfilling|Shipped" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a request for an order with an unknown ID", + "pending": false, + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/api/orders/404" + }, + "response": { + "status": 404 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.21", + "mockserver": "1.2.8", + "models": "1.2.1" + }, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "merge-test-provider-http" + } +} \ No newline at end of file diff --git a/python/pacts/merge-test-consumer-merge-test-provider-message.json b/python/pacts/merge-test-consumer-merge-test-provider-message.json new file mode 100644 index 000000000..22a96fb9c --- /dev/null +++ b/python/pacts/merge-test-consumer-merge-test-provider-message.json @@ -0,0 +1,86 @@ +{ + "consumer": { + "name": "merge-test-consumer" + }, + "interactions": [ + { + "description": "a request to update the status of an order", + "pending": false, + "providerStates": [ + { + "name": "an order with ID {id} exists", + "params": { + "id": 1 + } + } + ], + "request": { + "body": { + "content": "Fulfilling", + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "Pending|Fulfilling|Shipped" + } + ] + } + } + }, + "method": "PUT", + "path": "/api/orders/1/status" + }, + "response": { + "status": 204 + }, + "type": "Synchronous/HTTP" + }, + { + "contents": { + "content": { + "id": "1" + }, + "contentType": "application/json", + "encoded": false + }, + "description": "an event indicating that an order has been created", + "matchingRules": { + "body": { + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + } + } + }, + "pending": false, + "type": "Asynchronous/Messages" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.21", + "models": "1.2.1" + }, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "merge-test-provider-message" + } +} \ No newline at end of file diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9861bbc23..acde54479 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ "maplit", "md5", "os_info", - "pact_models 1.2.0", + "pact_models", "prost", "prost-types", "regex", @@ -1918,7 +1918,7 @@ dependencies = [ "pact-plugin-driver", "pact_matching 1.2.4", "pact_mock_server", - "pact_models 1.2.0", + "pact_models", "pretty_assertions", "quickcheck", "rand", @@ -1962,7 +1962,7 @@ dependencies = [ "pact-plugin-driver", "pact_matching 1.2.4", "pact_mock_server", - "pact_models 1.2.0", + "pact_models", "pact_verifier", "panic-message", "pretty_assertions", @@ -2015,7 +2015,7 @@ dependencies = [ "nom", "onig", "pact-plugin-driver", - "pact_models 1.2.0", + "pact_models", "rand", "reqwest 0.12.4", "semver", @@ -2058,7 +2058,7 @@ dependencies = [ "ntest", "onig", "pact-plugin-driver", - "pact_models 1.2.0", + "pact_models", "pretty_assertions", "quickcheck", "rand", @@ -2094,7 +2094,7 @@ dependencies = [ "maplit", "pact-plugin-driver", "pact_matching 1.2.3", - "pact_models 1.2.0", + "pact_models", "rustls 0.21.12", "rustls-pemfile 1.0.4", "serde", @@ -2108,45 +2108,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "pact_models" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3bf784d5fc22d0332041fa8f9dd9992e0ce2b22236462847ca1b1377297e10" -dependencies = [ - "anyhow", - "ariadne", - "base64 0.21.7", - "bytes", - "chrono", - "chrono-tz 0.8.6", - "fs2", - "gregorian", - "hashers", - "hex", - "indextree", - "itertools 0.10.5", - "lazy_static", - "lenient_semver", - "logos", - "maplit", - "mime", - "nom", - "onig", - "parse-zoneinfo", - "rand", - "rand_regex", - "regex", - "regex-syntax 0.6.29", - "reqwest 0.11.27", - "semver", - "serde", - "serde_json", - "sxd-document", - "tracing", - "uuid", -] - [[package]] name = "pact_models" version = "1.2.1" @@ -2217,7 +2178,7 @@ dependencies = [ "pact-plugin-driver", "pact_consumer", "pact_matching 1.2.4", - "pact_models 1.2.0", + "pact_models", "pretty_assertions", "quickcheck", "regex", @@ -2247,7 +2208,7 @@ dependencies = [ "junit-report", "log", "maplit", - "pact_models 1.2.0", + "pact_models", "pact_verifier", "regex", "reqwest 0.12.4", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 6b307a417..bce8aeba7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -12,3 +12,4 @@ resolver = "2" [patch.crates-io] onig = { git = "https://github.com/rust-onig/rust-onig", default-features = false } +pact_models = { version = "~1.2.1", path = "./pact_models" } diff --git a/rust/pact_ffi/tests/tests.rs b/rust/pact_ffi/tests/tests.rs index 1f0ce774d..f88cb4bf4 100644 --- a/rust/pact_ffi/tests/tests.rs +++ b/rust/pact_ffi/tests/tests.rs @@ -10,6 +10,8 @@ use expectest::prelude::*; use itertools::Itertools; use libc::c_char; use maplit::*; +use pact_ffi::mock_server::handles::pactffi_new_message_interaction; +use pact_ffi::models::Pact; use pact_models::bodies::OptionalBody; use pact_models::PactSpecification; use pretty_assertions::assert_eq; @@ -1182,6 +1184,405 @@ fn repeated_interaction() { ); } +// Issue #389 +#[test_log::test] +fn merging_duplicate_http_interaction_without_state_with_pact_containing_two_http_interactions_does_not_duplicate() { + + let tmp = tempfile::tempdir().unwrap(); + let tmp_dir = CString::new(tmp.path().to_string_lossy().as_bytes().to_vec()).unwrap(); + // 1. create an existing pact containing + // 1a. http interaction with provider state + // 1b. http interaction without provider state + // 2. save pact to file + // 3. create new pact interaction, duplicating 1b http interaction without provider state + // 4. expect deduplication, and pact contents to be the same as step 2 + let pact_handle = PactHandle::new("MergingPactC", "MergingPactP"); + pactffi_with_specification(pact_handle, PactSpecification::V4); + let desc1 = CString::new("description 1").unwrap(); + let desc2 = CString::new("description 2").unwrap(); + let state_desc_1 = CString::new("state_desc_1").unwrap(); + let path = CString::new("/api/orders/404").unwrap(); + let method = CString::new("GET").unwrap(); + let accept = CString::new("Accept").unwrap(); + let header = CString::new("application/json").unwrap(); + let state_params = CString::new(r#"{"id": "1"}"#).unwrap(); + + // Setup Pact 1 - Interaction 1 - http with provider state + let i_handle1 = pactffi_new_interaction(pact_handle, desc1.as_ptr()); + pactffi_with_request(i_handle1, method.as_ptr(), path.as_ptr()); + pactffi_given_with_params(i_handle1, state_desc_1.as_ptr(), state_params.as_ptr()); + pactffi_with_header_v2(i_handle1, InteractionPart::Request, accept.as_ptr(), 0, header.as_ptr()); + pactffi_response_status(i_handle1, 200); + + + // Write to file + let result_1 = pactffi_pact_handle_write_file(pact_handle, tmp_dir.as_ptr(), false); + + // Setup Pact 1 - Interaction 2 - http without provider state + let i_handle2 = pactffi_new_interaction(pact_handle, desc2.as_ptr()); + pactffi_with_request(i_handle2, method.as_ptr(), path.as_ptr()); + pactffi_with_header_v2(i_handle2, InteractionPart::Request, accept.as_ptr(), 0, header.as_ptr()); + pactffi_response_status(i_handle2, 200); + pactffi_with_header_v2(i_handle2, InteractionPart::Request, accept.as_ptr(), 0, header.as_ptr()); + let result_2 = pactffi_pact_handle_write_file(pact_handle, tmp_dir.as_ptr(), false); + + // Clear pact handle + let existing_pact_file: Option = pact_default_file_name(&pact_handle); + pactffi_free_pact_handle(pact_handle); + + expect!(result_1).to(be_equal_to(0)); + expect!(result_2).to(be_equal_to(0)); + + // Setup Pact 2 - Interaction 1 - http without provider state + // act like we have an existing file and try and merge the same interaction again + let pact_handle = PactHandle::new("MergingPactC", "MergingPactP"); + pactffi_with_specification(pact_handle, PactSpecification::V4); + let i_handle = pactffi_new_interaction(pact_handle, desc2.as_ptr()); + pactffi_with_request(i_handle, method.as_ptr(), path.as_ptr()); + pactffi_with_header_v2(i_handle, InteractionPart::Request, accept.as_ptr(), 0, header.as_ptr()); + pactffi_response_status(i_handle, 200); + pactffi_with_header_v2(i_handle, InteractionPart::Request, accept.as_ptr(), 0, header.as_ptr()); + let result_3 = pactffi_pact_handle_write_file(pact_handle, tmp_dir.as_ptr(), false); + expect!(result_3).to(be_equal_to(0)); + let new_pact_file = pact_default_file_name(&pact_handle); + pactffi_free_pact_handle(pact_handle); + let pact_path = tmp.path().join(new_pact_file.unwrap()); + let f= File::open(pact_path).unwrap(); + + let mut json: Value = serde_json::from_reader(f).unwrap(); + json["metadata"] = Value::Null; + assert_eq!(serde_json::to_string_pretty(&json).unwrap(), + r#"{ + "consumer": { + "name": "MergingPactC" + }, + "interactions": [ + { + "description": "description 1", + "pending": false, + "providerStates": [ + { + "name": "state_desc_1", + "params": { + "id": "1" + } + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/api/orders/404" + }, + "response": { + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "description 2", + "pending": false, + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/api/orders/404" + }, + "response": { + "status": 200 + }, + "type": "Synchronous/HTTP" + } + ], + "metadata": null, + "provider": { + "name": "MergingPactP" + } +}"# + ); +} + +// Issue #389 +#[test_log::test] +fn merging_duplicate_message_interaction_without_state_with_pact_containing_two_mixed_interactions_does_not_duplicate() { + + let tmp = tempfile::tempdir().unwrap(); + let tmp_dir = CString::new(tmp.path().to_string_lossy().as_bytes().to_vec()).unwrap(); + // 1. create an existing pact containing + // 1a. http interaction with provider state + // 1b. message interaction without provider state + // 2. save pact to file + // 3. create new pact interaction, duplicating 1b message interaction without provider state + // 4. expect deduplication, and pact contents to be the same as step 2 + let consumer_name = CString::new("MergingPactC").unwrap(); + let provider_name = CString::new("MergingPactP").unwrap(); + let pact_handle: PactHandle = pactffi_new_pact(consumer_name.as_ptr(), provider_name.as_ptr()); + pactffi_with_specification(pact_handle, PactSpecification::V4); + let desc1 = CString::new("description 1").unwrap(); + // let desc2 = CString::new("description 2").unwrap(); + let state_desc_1 = CString::new("state_desc_1").unwrap(); + let path = CString::new("/api/orders/404").unwrap(); + let method = CString::new("GET").unwrap(); + let accept = CString::new("Accept").unwrap(); + let header = CString::new("application/json").unwrap(); + let state_params = CString::new(r#"{"id": "1"}"#).unwrap(); + + // Setup Pact 1 - Interaction 1 - http with provider state + let i_handle1 = pactffi_new_interaction(pact_handle, desc1.as_ptr()); + pactffi_with_request(i_handle1, method.as_ptr(), path.as_ptr()); + pactffi_given_with_params(i_handle1, state_desc_1.as_ptr(), state_params.as_ptr()); + pactffi_with_header_v2(i_handle1, InteractionPart::Request, accept.as_ptr(), 0, header.as_ptr()); + pactffi_response_status(i_handle1, 200); + + + // Write to file + let result_1 = pactffi_pact_handle_write_file(pact_handle, tmp_dir.as_ptr(), false); + + // Setup Pact 1 - Interaction 2 - message interaction without provider state + let description = CString::new("description 2").unwrap(); + let content_type = CString::new("application/json").unwrap(); + let request_body_with_matchers = CString::new("{\"id\": {\"value\":\"1\",\"pact:matcher:type\":\"integer\"}}").unwrap(); + let interaction_handle = pactffi_new_message_interaction(pact_handle, description.as_ptr()); + let body_bytes = request_body_with_matchers; + pactffi_with_body(interaction_handle.clone(),InteractionPart::Request, content_type.as_ptr(), body_bytes.as_ptr()); + let result_2 = pactffi_pact_handle_write_file(pact_handle, tmp_dir.as_ptr(), false); + + expect!(result_1).to(be_equal_to(0)); + expect!(result_2).to(be_equal_to(0)); + // Clear pact handle + let pact_file: Option = pact_default_file_name(&pact_handle); + pactffi_free_pact_handle(pact_handle); + + // Setup Pact 2 - Interaction 1 - message interaction without provider state + // act like we have an existing file and try and merge the same interaction again + let pact_handle: PactHandle = pactffi_new_pact(consumer_name.as_ptr(), provider_name.as_ptr()); + pactffi_with_specification(pact_handle, PactSpecification::V4); + let description = CString::new("description 2").unwrap(); + let content_type = CString::new("application/json").unwrap(); + let request_body_with_matchers = CString::new("{\"id\": {\"value\":\"1\",\"pact:matcher:type\":\"integer\"}}").unwrap(); + let interaction_handle = pactffi_new_message_interaction(pact_handle, description.as_ptr()); + let body_bytes = request_body_with_matchers; + pactffi_with_body(interaction_handle.clone(),InteractionPart::Request, content_type.as_ptr(), body_bytes.as_ptr()); + let result_3 = pactffi_pact_handle_write_file(pact_handle, tmp_dir.as_ptr(), false); + expect!(result_3).to(be_equal_to(0)); + let pact_file: Option = pact_default_file_name(&pact_handle); + pactffi_free_pact_handle(pact_handle); + + // end setup new pact + + let pact_path = tmp.path().join(pact_file.unwrap()); + let f= File::open(pact_path).unwrap(); + + let mut json: Value = serde_json::from_reader(f).unwrap(); + json["metadata"]["pactRust"] = Value::Null; + assert_eq!(serde_json::to_string_pretty(&json).unwrap(), + r#"{ + "consumer": { + "name": "MergingPactC" + }, + "interactions": [ + { + "description": "description 1", + "pending": false, + "providerStates": [ + { + "name": "state_desc_1", + "params": { + "id": "1" + } + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/api/orders/404" + }, + "response": { + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "contents": { + "content": { + "id": "1" + }, + "contentType": "application/json", + "encoded": false + }, + "description": "description 2", + "matchingRules": { + "body": { + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + } + } + }, + "metadata": { + "contentType": "application/json" + }, + "pending": false, + "type": "Asynchronous/Messages" + } + ], + "metadata": { + "pactRust": null, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "MergingPactP" + } +}"# + ); +} + +// Issue - Should be able to set version of message pact, and write to file containing v4 interactions +// seems to be a problem setting the version, which defaults to v3 +// pactffi_new_message will accept a MessageHandle over the FFI barrier, but rust typing wont allow us +// to pass a PactHandle along with pactffi_with_specification +#[ignore = "require ability to set pact specification version in pactffi_new_message_pact"] +#[test_log::test] +fn allow_creation_v4_spec_message() { + + let tmp = tempfile::tempdir().unwrap(); + let tmp_dir = CString::new(tmp.path().to_string_lossy().as_bytes().to_vec()).unwrap(); + // 1. create an existing pact containing http interaction with provider state + // 3. create new message pact/interaction with v4 specification + let consumer_name = CString::new("MergingPactC").unwrap(); + let provider_name = CString::new("MergingPactP").unwrap(); + let pact_handle: PactHandle = pactffi_new_pact(consumer_name.as_ptr(), provider_name.as_ptr()); + pactffi_with_specification(pact_handle, PactSpecification::V4); + let desc1 = CString::new("description 1").unwrap(); + // let desc2 = CString::new("description 2").unwrap(); + let state_desc_1 = CString::new("state_desc_1").unwrap(); + let path = CString::new("/api/orders/404").unwrap(); + let method = CString::new("GET").unwrap(); + let accept = CString::new("Accept").unwrap(); + let header = CString::new("application/json").unwrap(); + let state_params = CString::new(r#"{"id": "1"}"#).unwrap(); + + // Setup Pact 1 - Interaction 1 - http with provider state + let i_handle1 = pactffi_new_interaction(pact_handle, desc1.as_ptr()); + pactffi_with_request(i_handle1, method.as_ptr(), path.as_ptr()); + pactffi_given_with_params(i_handle1, state_desc_1.as_ptr(), state_params.as_ptr()); + pactffi_with_header_v2(i_handle1, InteractionPart::Request, accept.as_ptr(), 0, header.as_ptr()); + pactffi_response_status(i_handle1, 200); + // Write to file + let result_1 = pactffi_pact_handle_write_file(pact_handle, tmp_dir.as_ptr(), false); + + // Setup Pact 1 - Interaction 2 - message interaction without provider state + let description = CString::new("async message description").unwrap(); + let content_type = CString::new("application/json").unwrap(); + let request_body_with_matchers = CString::new("{\"id\": {\"value\":\"1\",\"pact:matcher:type\":\"integer\"}}").unwrap(); + + let message_pact_handle = pactffi_new_message_pact(consumer_name.as_ptr(), provider_name.as_ptr()); + let message_handle = pactffi_new_message(message_pact_handle, description.as_ptr()); + let body_bytes = request_body_with_matchers.as_bytes(); + pactffi_message_with_contents(message_handle.clone(), content_type.as_ptr(), body_bytes.as_ptr(), body_bytes.len()); + let res: *const c_char = pactffi_message_reify(message_handle.clone()); + let result_2 = pactffi_write_message_pact_file(message_pact_handle.clone(), tmp_dir.as_ptr(), false); + expect!(result_1).to(be_equal_to(0)); + expect!(result_2).to(be_equal_to(0)); + let pact_file: Option = pact_default_file_name(&pact_handle); + // Clear pact handle + pactffi_free_pact_handle(pact_handle); + // end setup new pact + + let pact_path = tmp.path().join(pact_file.unwrap()); + let f= File::open(pact_path).unwrap(); + + let mut json: Value = serde_json::from_reader(f).unwrap(); + json["metadata"]["pactRust"] = Value::Null; + assert_eq!(serde_json::to_string_pretty(&json).unwrap(), + r#"{ + "consumer": { + "name": "MergingPactC" + }, + "interactions": [ + { + "contents": { + "content": { + "id": "1" + }, + "contentType": "application/json", + "encoded": false + }, + "description": "async message description", + "matchingRules": { + "body": { + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + } + } + }, + "metadata": { + "contentType": "application/json" + }, + "pending": false, + "type": "Asynchronous/Messages" + }, + { + "description": "description 1", + "pending": false, + "providerStates": [ + { + "name": "state_desc_1", + "params": { + "id": "1" + } + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/api/orders/404" + }, + "response": { + "status": 200 + }, + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": null, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "MergingPactP" + } +}"# + ); +} + + // Issue #298 #[test_log::test] fn provider_states_ignoring_parameter_types() { diff --git a/rust/pact_models/src/message_pact.rs b/rust/pact_models/src/message_pact.rs index 579547a1b..a7e75bc0b 100644 --- a/rust/pact_models/src/message_pact.rs +++ b/rust/pact_models/src/message_pact.rs @@ -308,7 +308,7 @@ impl ReadWritePact for MessagePact { let messages: Vec> = self.messages.iter() .merge_join_by(pact.interactions().iter(), |a, b| { let cmp = Ord::cmp(&a.description, &b.description()); - if cmp == Ordering::Equal { + if cmp == Ordering::Equal && ! &a.provider_states().is_empty(){ Ord::cmp(&a.provider_states.iter().map(|p| p.name.clone()).collect::>(), &b.provider_states().iter().map(|p| p.name.clone()).collect::>()) } else { diff --git a/rust/pact_models/src/pact.rs b/rust/pact_models/src/pact.rs index 0ad19e1bc..f030933bc 100644 --- a/rust/pact_models/src/pact.rs +++ b/rust/pact_models/src/pact.rs @@ -438,6 +438,8 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::{json, Value}; + use crate::message::Message; + use crate::message_pact::MessagePact; use crate::{Consumer, PactSpecification, Provider}; use crate::bodies::OptionalBody; use crate::content_types::JSON; @@ -1010,6 +1012,190 @@ mod tests { }}"#, PACT_RUST_VERSION.unwrap()))); } + // Issue #389 + #[test] + fn write_pact_test_should_merge_duplicate_http_pacts_without_provider_states() { + let existing_pact = RequestResponsePact { consumer: Consumer { name: "dupe_consumer".to_string() }, + provider: Provider { name: "dupe_provider".to_string() }, + interactions: vec![ + RequestResponseInteraction { + description: "description 1".to_string(), + provider_states: vec![ProviderState { name: "Good state to be in".to_string(), params: hashmap!{} }], + request: Request { headers: Some(hashmap!{ + "Accept".to_string()=>vec!["application/json".to_string()] + }), .. Request::default() }, + .. RequestResponseInteraction::default() + }, + RequestResponseInteraction { + description: "description 2".to_string(), + request: Request { headers: Some(hashmap!{ + "Accept".to_string()=>vec!["application/json".to_string()] + }), .. Request::default() }, + .. RequestResponseInteraction::default() + } + ], + metadata: btreemap!{}, + specification_version: PactSpecification::V3 + }; + let new_pact = RequestResponsePact { consumer: Consumer { name: "dupe_consumer".to_string() }, + provider: Provider { name: "dupe_provider".to_string() }, + interactions: vec![ + RequestResponseInteraction { + description: "description 2".to_string(), + request: Request { headers: Some(hashmap!{ + "Accept".to_string()=>vec!["application/json".to_string()] + }), .. Request::default() }, + .. RequestResponseInteraction::default() + } + ], + metadata: btreemap!{}, + specification_version: PactSpecification::V3 + }; + let mut dir = env::temp_dir(); + let x = rand::random::(); + dir.push(format!("pact_test_{}", x)); + dir.push(existing_pact.default_file_name()); + + let result = write_pact(existing_pact.boxed(), dir.as_path(), PactSpecification::V3, false); + let result2 = write_pact(new_pact.boxed(), dir.as_path(), PactSpecification::V3, false); + + let pact_file: String = read_pact_file(dir.as_path().to_str().unwrap()).unwrap_or("".to_string()); + let mut json: Value = serde_json::from_str(&pact_file).unwrap(); + json["metadata"]["pactRust"] = Value::Null; + fs::remove_dir_all(dir.parent().unwrap()).unwrap_or(()); + + expect!(result).to(be_ok()); + expect!(result2).to(be_ok()); + assert_eq!(serde_json::to_string_pretty(&json).unwrap(),r#"{ + "consumer": { + "name": "dupe_consumer" + }, + "interactions": [ + { + "description": "description 1", + "providerStates": [ + { + "name": "Good state to be in" + } + ], + "request": { + "headers": { + "Accept": "application/json" + }, + "method": "GET", + "path": "/" + }, + "response": { + "status": 200 + } + }, + { + "description": "description 2", + "request": { + "headers": { + "Accept": "application/json" + }, + "method": "GET", + "path": "/" + }, + "response": { + "status": 200 + } + } + ], + "metadata": { + "pactRust": null, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "dupe_provider" + } +}"#); + } + + // Issue #389 + #[test] + fn write_pact_test_should_merge_duplicate_message_pacts_without_provider_states() { + let existing_pact = MessagePact { + consumer: Consumer { name: "dupe_consumer".to_string() }, + provider: Provider { name: "dupe_provider".to_string() }, + messages: vec![ + Message { + description: "description 1".to_string(), + provider_states: vec![ProviderState { name: "Good state to be in".to_string(), params: hashmap!{} }], + .. Message::default() + }, + Message { + description: "description 2".to_string(), + .. Message::default() + } + ], + metadata: btreemap!{}, + specification_version: PactSpecification::V3 + }; + let new_pact = MessagePact { consumer: Consumer { name: "dupe_consumer".to_string() }, + provider: Provider { name: "dupe_provider".to_string() }, + messages: vec![ + Message { + description: "description 2".to_string(), + .. Message::default() + } + ], + metadata: btreemap!{}, + specification_version: PactSpecification::V3 + }; + let mut dir = env::temp_dir(); + let x = rand::random::(); + dir.push(format!("pact_test_{}", x)); + dir.push(existing_pact.default_file_name()); + + let result = write_pact(existing_pact.boxed(), dir.as_path(), PactSpecification::V3, false); + let result2 = write_pact(new_pact.boxed(), dir.as_path(), PactSpecification::V3, false); + + let pact_file: String = read_pact_file(dir.as_path().to_str().unwrap()).unwrap_or("".to_string()); + let mut json: Value = serde_json::from_str(&pact_file).unwrap(); + json["metadata"]["pactRust"] = Value::Null; + fs::remove_dir_all(dir.parent().unwrap()).unwrap_or(()); + + expect!(result).to(be_ok()); + expect!(result2).to(be_ok()); + assert_eq!(serde_json::to_string_pretty(&json).unwrap(),r#"{ + "consumer": { + "name": "dupe_consumer" + }, + "messages": [ + { + "description": "description 1", + "metadata": { + "contentType": "application/json" + }, + "providerStates": [ + { + "name": "Good state to be in" + } + ] + }, + { + "description": "description 2", + "metadata": { + "contentType": "application/json" + } + } + ], + "metadata": { + "pactRust": null, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "dupe_provider" + } +}"#); + } + #[test] fn write_pact_test_should_not_merge_pacts_with_conflicts() { let pact = RequestResponsePact { consumer: Consumer { name: "write_pact_test_consumer".to_string() }, diff --git a/rust/pact_models/src/sync_pact.rs b/rust/pact_models/src/sync_pact.rs index bb6de48ba..a4fd8b437 100644 --- a/rust/pact_models/src/sync_pact.rs +++ b/rust/pact_models/src/sync_pact.rs @@ -301,10 +301,10 @@ impl ReadWritePact for RequestResponsePact { } else { let interactions: Vec> = self.interactions.iter() .merge_join_by(pact.interactions().iter(), |a, b| { - let cmp = Ord::cmp(&a.provider_states.iter().map(|p| p.name.clone()).collect::>(), - &b.provider_states().iter().map(|p| p.name.clone()).collect::>()); - if cmp == Ordering::Equal { - Ord::cmp(&a.description, &b.description()) + let cmp = Ord::cmp(&a.description, &b.description()); + if cmp == Ordering::Equal && ! &a.provider_states().is_empty(){ + Ord::cmp(&a.provider_states.iter().map(|p| p.name.clone()).collect::>(), + &b.provider_states().iter().map(|p| p.name.clone()).collect::>()) } else { cmp } diff --git a/rust/pact_models/src/v4/pact.rs b/rust/pact_models/src/v4/pact.rs index f3f0c3be1..101cfe195 100644 --- a/rust/pact_models/src/v4/pact.rs +++ b/rust/pact_models/src/v4/pact.rs @@ -367,16 +367,21 @@ impl ReadWritePact for V4Pact { (_, _) => { let type_a = a.type_of(); let type_b = b.type_of(); - let cmp = Ord::cmp(&type_a, &type_b); - if cmp == Ordering::Equal { + let cmp = Ord::cmp(&a.description(), &b.description()); + if cmp == Ordering::Equal && !a.provider_states().is_empty() { let cmp = Ord::cmp(&a.provider_states().iter().map(|p| p.name.clone()).collect::>(), - &b.provider_states().iter().map(|p| p.name.clone()).collect::>()); + &b.provider_states().iter().map(|p| p.name.clone()).collect::>()); if cmp == Ordering::Equal { - Ord::cmp(&a.description(), &b.description()) - } else { + Ord::cmp(&type_a, &type_b) + } else + { cmp } - } else { + } + else if cmp == Ordering::Equal && a.provider_states().is_empty() { + Ord::cmp(&type_a, &type_b) + } + else { cmp } } @@ -471,7 +476,7 @@ mod tests { use expectest::prelude::*; use maplit::*; use pretty_assertions::assert_eq; - use serde_json::json; + use serde_json::{json, Value}; use crate::{Consumer, PACT_RUST_VERSION, PactSpecification, Provider}; use crate::bodies::OptionalBody; @@ -959,6 +964,204 @@ mod tests { }}"#, PACT_RUST_VERSION.unwrap()))); } + // Issue #389 + #[test] + fn merging_duplicate_http_interaction_without_state_with_pact_containing_two_http_interactions_does_not_duplicate() { + let existing_pact = V4Pact { + consumer: Consumer { name: "write_pact_test_consumer".into() }, + provider: Provider { name: "write_pact_test_provider".into() }, + interactions: vec![ + Box::new(SynchronousHttp { + description: "description 1".into(), + provider_states: vec![ProviderState { name: "Good state to be in".into(), params: hashmap!{} }], + request: HttpRequest { headers: Some(hashmap!{ + "Accept".to_string()=>vec!["application/json".to_string()] + }), .. HttpRequest::default() }, + .. SynchronousHttp::default() + }), + Box::new(SynchronousHttp { + description: "description 2".into(), + request: HttpRequest { headers: Some(hashmap!{ + "Accept".to_string()=>vec!["application/json".to_string()] + }), .. HttpRequest::default() }, + .. SynchronousHttp::default() + }) + ], + metadata: btreemap!{}, + plugin_data: vec![] + }; + let pact = V4Pact { + consumer: Consumer { name: "write_pact_test_consumer".into() }, + provider: Provider { name: "write_pact_test_provider".into() }, + interactions: vec![ + Box::new(SynchronousHttp { + description: "description 2".into(), + request: HttpRequest { headers: Some(hashmap!{ + "Accept".to_string()=>vec!["application/json".to_string()] + }), .. HttpRequest::default() }, + .. SynchronousHttp::default() + }) + ], + metadata: btreemap!{}, + plugin_data: vec![] + }; + let mut dir = env::temp_dir(); + let x = rand::random::(); + dir.push(format!("pact_test_{}", x)); + dir.push(pact.default_file_name()); + + let existing_pact_result = write_pact(existing_pact.boxed(), dir.as_path(), PactSpecification::V4, false); + let result = write_pact(pact.boxed(), dir.as_path(), PactSpecification::V4, false); + let pact_file = read_pact_file(dir.as_path().to_str().unwrap()).unwrap_or_default(); + let mut json: Value = serde_json::from_str(&pact_file).unwrap(); + json["metadata"]["pactRust"] = Value::Null; + + fs::remove_dir_all(dir.parent().unwrap()).unwrap_or(()); + + expect!(existing_pact_result).to(be_ok()); + expect!(result).to(be_ok()); + assert_eq!(serde_json::to_string_pretty(&json).unwrap(),r#"{ + "consumer": { + "name": "write_pact_test_consumer" + }, + "interactions": [ + { + "description": "description 1", + "pending": false, + "providerStates": [ + { + "name": "Good state to be in" + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/" + }, + "response": { + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "description 2", + "pending": false, + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/" + }, + "response": { + "status": 200 + }, + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": null, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "write_pact_test_provider" + } +}"#); + } + + // Issue #389 + #[test] + fn merging_duplicate_message_interaction_without_state_with_pact_containing_two_mixed_interactions_does_not_duplicate() { + let existing_pact = V4Pact { + consumer: Consumer { name: "write_pact_test_consumer".into() }, + provider: Provider { name: "write_pact_test_provider".into() }, + interactions: vec![ + Box::new(SynchronousHttp { + description: "A1".into(), + provider_states: vec![ProviderState { name: "Good state to be in".into(), params: hashmap!{} }], + request: HttpRequest { headers: Some(hashmap!{ + "Accept".to_string()=>vec!["application/json".to_string()] + }), .. HttpRequest::default() }, + .. SynchronousHttp::default() + }), + Box::new(AsynchronousMessage::default()) + ], + .. V4Pact::default() }; + let pact = V4Pact { + consumer: Consumer { name: "write_pact_test_consumer".into() }, + provider: Provider { name: "write_pact_test_provider".into() }, + interactions: vec![ + Box::new(AsynchronousMessage::default()) + ], + metadata: btreemap!{}, + plugin_data: vec![] + }; + let mut dir = env::temp_dir(); + let x = rand::random::(); + dir.push(format!("pact_test_{}", x)); + dir.push(pact.default_file_name()); + + let existing_pact_result = write_pact(existing_pact.boxed(), dir.as_path(), PactSpecification::V4, false); + let result = write_pact(pact.boxed(), dir.as_path(), PactSpecification::V4, false); + let pact_file = read_pact_file(dir.as_path().to_str().unwrap()).unwrap_or_default(); + let mut json: Value = serde_json::from_str(&pact_file).unwrap(); + json["metadata"]["pactRust"] = Value::Null; + fs::remove_dir_all(dir.parent().unwrap()).unwrap_or(()); + expect!(existing_pact_result).to(be_ok()); + expect!(result).to(be_ok()); + assert_eq!(serde_json::to_string_pretty(&json).unwrap(),r#"{ + "consumer": { + "name": "write_pact_test_consumer" + }, + "interactions": [ + { + "description": "A1", + "pending": false, + "providerStates": [ + { + "name": "Good state to be in" + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/" + }, + "response": { + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "Asynchronous/Message Interaction", + "pending": false, + "type": "Asynchronous/Messages" + } + ], + "metadata": { + "pactRust": null, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "write_pact_test_provider" + } +}"#); + } + #[test] fn pact_merge_does_not_merge_different_consumers() { let pact = V4Pact { consumer: Consumer { name: "test_consumer".to_string() },