Skip to content

Commit d533bb7

Browse files
author
Ronald Holshausen
committed
feat: consumer test with binary file
1 parent b12bb5d commit d533bb7

File tree

5 files changed

+160
-101
lines changed

5 files changed

+160
-101
lines changed

examples/v3/todo/src/todo_consumer.py

+5-9
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,8 @@ def get_projects(self, format='json'):
1919
else:
2020
return ET.fromstring(response.text)
2121

22-
# postImage: (id, image) => {
23-
# const data = fs.readFileSync(image)
24-
# return axios.post(serverUrl + "/projects/" + id + "/images", data, {
25-
# headers: {
26-
# "Content-Type": "application/octet-stream",
27-
# },
28-
# })
29-
# },
30-
# }
22+
def post_image(self, id, file_path):
23+
"""Store an image against a project"""
24+
uri = self.base_uri + '/projects/' + str(id) + '/images'
25+
response = requests.post(uri, data=open(file_path, 'rb'), headers={'Content-Type': 'application/octet-stream'})
26+
response.raise_for_status()

examples/v3/todo/tests/test_todo_consumer.py

+60-81
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ def test_get_projects_as_json(provider):
1414
.upon_receiving('a request for projects')
1515
.with_request(method="GET", path="/projects", query={'from': "today"}, headers={'Accept': "application/json"})
1616
.will_respond_with(
17-
headers={"Content-Type": "application/json"},
18-
body=EachLike({
19-
'id': Integer(1),
20-
'name': Like("Project 1"),
21-
'due': DateTime("yyyy-MM-dd'T'HH:mm:ss.SSSX", "2016-02-11T09:46:56.023Z"),
22-
'tasks': AtLeastOneLike({
23-
'id': Integer(),
24-
'name': Like("Do the laundry"),
25-
'done': Like(True)
26-
}, examples=4)
17+
headers={"Content-Type": "application/json"},
18+
body=EachLike({
19+
'id': Integer(1),
20+
'name': Like("Project 1"),
21+
'due': DateTime("yyyy-MM-dd'T'HH:mm:ss.SSSX", "2016-02-11T09:46:56.023Z"),
22+
'tasks': AtLeastOneLike({
23+
'id': Integer(),
24+
'name': Like("Do the laundry"),
25+
'done': Like(True)
26+
}, examples=4)
2727
})))
2828

2929
with provider as mock_server:
@@ -43,8 +43,8 @@ def test_with_xml_requests(provider):
4343
.upon_receiving('a request for projects in XML')
4444
.with_request(method="GET", path="/projects", query={'from': "today"}, headers={'Accept': "application/xml"})
4545
.will_respond_with(
46-
headers={"Content-Type": "application/xml"},
47-
body='''<?xml version="1.0" encoding="UTF-8"?>
46+
headers={"Content-Type": "application/xml"},
47+
body='''<?xml version="1.0" encoding="UTF-8"?>
4848
<projects foo="bar">
4949
<project id="1" name="Project 1" due="2016-02-11T09:46:56.023Z">
5050
<tasks>
@@ -56,42 +56,42 @@ def test_with_xml_requests(provider):
5656
</project>
5757
<project/>
5858
</projects>'''
59-
# body: new XmlBuilder("1.0", "UTF-8", "ns1:projects").build(el => {
60-
# el.setAttributes({
61-
# id: "1234",
62-
# "xmlns:ns1": "http://some.namespace/and/more/stuff",
63-
# })
64-
# el.eachLike(
65-
# "ns1:project",
66-
# {
67-
# id: integer(1),
68-
# type: "activity",
69-
# name: string("Project 1"),
70-
# // TODO: implement XML generators
71-
# // due: timestamp(
72-
# // "yyyy-MM-dd'T'HH:mm:ss.SZ",
73-
# // "2016-02-11T09:46:56.023Z"
74-
# // ),
75-
# },
76-
# project => {
77-
# project.appendElement("ns1:tasks", {}, task => {
78-
# task.eachLike(
79-
# "ns1:task",
80-
# {
81-
# id: integer(1),
82-
# name: string("Task 1"),
83-
# done: boolean(true),
84-
# },
85-
# null,
86-
# { examples: 5 }
87-
# )
88-
# })
89-
# },
90-
# { examples: 2 }
91-
# )
92-
# }),
93-
94-
))
59+
# body: new XmlBuilder("1.0", "UTF-8", "ns1:projects").build(el => {
60+
# el.setAttributes({
61+
# id: "1234",
62+
# "xmlns:ns1": "http://some.namespace/and/more/stuff",
63+
# })
64+
# el.eachLike(
65+
# "ns1:project",
66+
# {
67+
# id: integer(1),
68+
# type: "activity",
69+
# name: string("Project 1"),
70+
# // TODO: implement XML generators
71+
# // due: timestamp(
72+
# // "yyyy-MM-dd'T'HH:mm:ss.SZ",
73+
# // "2016-02-11T09:46:56.023Z"
74+
# // ),
75+
# },
76+
# project => {
77+
# project.appendElement("ns1:tasks", {}, task => {
78+
# task.eachLike(
79+
# "ns1:task",
80+
# {
81+
# id: integer(1),
82+
# name: string("Task 1"),
83+
# done: boolean(true),
84+
# },
85+
# null,
86+
# { examples: 5 }
87+
# )
88+
# })
89+
# },
90+
# { examples: 2 }
91+
# )
92+
# }),
93+
94+
))
9595

9696
with provider as mock_server:
9797
print("Mock server is running at " + mock_server.get_url())
@@ -106,36 +106,15 @@ def test_with_xml_requests(provider):
106106
assert tasks[0].get('id') == '1'
107107

108108

109-
# describe("with image uploads", () => {
110-
# before(() => {
111-
# provider = new PactV3({
112-
# consumer: "TodoApp",
113-
# provider: "TodoServiceV3",
114-
# dir: path.resolve(process.cwd(), "pacts"),
115-
# })
116-
# provider
117-
# .given("i have a project", { id: "1001", name: "Home Chores" })
118-
# .uponReceiving("a request to store an image against the project")
119-
# .withRequestBinaryFile(
120-
# { method: "POST", path: "/projects/1001/images" },
121-
# "image/jpeg",
122-
# path.resolve(__dirname, "example.jpg")
123-
# )
124-
# .willRespondWith({ status: 201 })
125-
# })
126-
127-
# it("stores the image against the project", async () => {
128-
# let result = await provider.executeTest(mockserver => {
129-
# console.log("In Test Function", mockserver)
130-
# return TodoApp.setUrl(mockserver.url).postImage(
131-
# 1001,
132-
# path.resolve(__dirname, "example.jpg")
133-
# )
134-
# })
135-
# console.log("result from runTest", result.status)
136-
# expect(result.status).to.be.eq(201)
137-
# return result
138-
# })
139-
# })
140-
# })
141-
# })
109+
def test_with_image_upload(provider):
110+
(provider.given('i have a project', {'id': 1001, 'name': 'Home Chores'})
111+
.upon_receiving('a request to store an image against the project')
112+
.with_request_with_binary_file('image/jpeg', 'tests/example.jpg', path="/projects/1001/images")
113+
.will_respond_with(status=201))
114+
115+
with provider as mock_server:
116+
print("Mock server is running at " + mock_server.get_url())
117+
118+
todo = TodoConsumer(mock_server.get_url())
119+
todo.post_image(1001, 'tests/example.jpg')
120+

pact-python-v3/src/lib.rs

+78-2
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
use std::cell::RefCell;
22
use std::collections::HashMap;
3+
use std::fs;
34
use std::str::FromStr;
45
use std::sync::Mutex;
56

7+
use bytes::Bytes;
68
use cpython::*;
79
use env_logger::{Builder, Target};
810
use lazy_static::*;
911
use log::*;
1012
use maplit::*;
11-
use bytes::Bytes;
1213
use pact_matching::models::*;
1314
use pact_matching::models::content_types::ContentType;
1415
use pact_matching::models::generators::Generators;
15-
use pact_matching::models::matchingrules::MatchingRules;
16+
use pact_matching::models::matchingrules::{MatchingRules, MatchingRule, RuleLogic};
1617
use pact_matching::models::provider_states::ProviderState;
1718
use pact_matching::time_utils::generate_string;
1819
use pact_mock_server::mock_server::MockServerConfig;
@@ -149,6 +150,81 @@ py_class!(class PactNative |py| {
149150
})
150151
}
151152

153+
def add_request_binary_file(&self, content_type: &str, file_path: &str, method: &str, path, query, headers) -> PyResult<PyObject> {
154+
self.with_current_interaction(py, &|interaction| {
155+
interaction.request.method = method.to_string();
156+
157+
if let Ok(path) = path.cast_as::<PyString>(py) {
158+
interaction.request.path = path.to_string_lossy(py).to_string();
159+
} else {
160+
// TODO: deal with a matcher
161+
}
162+
163+
if let Ok(query) = query.cast_as::<PyDict>(py) {
164+
let mut query_map = hashmap!{};
165+
for (k, v) in query.items(py) {
166+
let k = k.cast_as::<PyString>(py)?.to_string_lossy(py).to_string();
167+
let v = if let Ok(v) = v.cast_as::<PyString>(py) {
168+
vec![ v.to_string_lossy(py).to_string() ]
169+
} else {
170+
// TODO: deal with a matcher or a list
171+
vec![]
172+
};
173+
query_map.insert(k, v);
174+
}
175+
if !query_map.is_empty() {
176+
interaction.request.query = Some(query_map);
177+
}
178+
} else if query.get_type(py).name(py) != "NoneType" {
179+
return Err(PyErr::new::<exc::TypeError, _>(py, format!("with_request: Query parameters must be supplied as a Dict, got '{}'", query.get_type(py).name(py))));
180+
}
181+
182+
if let Ok(headers) = headers.cast_as::<PyDict>(py) {
183+
let mut header_map = hashmap!{};
184+
for (k, v) in headers.items(py) {
185+
let k = k.cast_as::<PyString>(py)?.to_string_lossy(py).to_string();
186+
let v = if let Ok(v) = v.cast_as::<PyString>(py) {
187+
vec![ v.to_string_lossy(py).to_string() ]
188+
} else {
189+
// TODO: deal with a matcher or a list
190+
vec![]
191+
};
192+
header_map.insert(k, v);
193+
}
194+
if !header_map.is_empty() {
195+
interaction.request.headers = Some(header_map);
196+
}
197+
} else if headers.get_type(py).name(py) != "NoneType" {
198+
return Err(PyErr::new::<exc::TypeError, _>(py, format!("with_request: Headers must be supplied as a Dict, got '{}'", headers.get_type(py).name(py))));
199+
}
200+
201+
let file = fs::read(file_path).map(|data| OptionalBody::Present(Bytes::from(data), None));
202+
match file {
203+
Ok(body) => {
204+
interaction.request.body = body;
205+
interaction.request.matching_rules.add_category("body").add_rule("$",
206+
MatchingRule::ContentType(content_type.to_string()), &RuleLogic::And);
207+
if !interaction.request.has_header(&"Content-Type".to_string()) {
208+
match interaction.request.headers {
209+
Some(ref mut headers) => {
210+
headers.insert("Content-Type".to_string(), vec!["application/octet-stream".to_string()]);
211+
},
212+
None => {
213+
interaction.request.headers = Some(hashmap! { "Content-Type".to_string() => vec!["application/octet-stream".to_string()]});
214+
}
215+
}
216+
};
217+
},
218+
Err(err) => {
219+
error!("Could not load file '{}': {}", file_path, err);
220+
return Err(PyErr::new::<exc::TypeError, _>(py, format!("add_request_binary_file: could not load binary file - {}'", err)));
221+
}
222+
}
223+
224+
Ok(py.None())
225+
})
226+
}
227+
152228
def will_respond_with(&self, status, headers, body) -> PyResult<PyObject> {
153229
self.with_current_interaction(py, &|interaction| {
154230
interaction.response.status = status.extract(py)?;

pact/matchers_v3.py

+12-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pact_python_v3 import generate_datetime_string
22

3+
34
class V3Matcher(object):
45
"""Base class for defining complex contract expectations."""
56

@@ -43,14 +44,15 @@ def generate(self):
4344
json = {
4445
"pact:matcher:type": "type",
4546
'value': [self.template for i in range(self.examples)]
46-
}
47+
}
4748
if self.minimum is not None:
4849
json['min'] = self.minimum
4950
if self.maximum is not None:
5051
json['max'] = self.maximum
5152

5253
return json
5354

55+
5456
class AtLeastOneLike(V3Matcher):
5557
"""
5658
An array that has to have at least one element and each element must match the given template
@@ -77,6 +79,7 @@ def generate(self):
7779
'value': [self.template for i in range(self.examples)]
7880
}
7981

82+
8083
class Like(V3Matcher):
8184
"""
8285
Value must be the same type as the example
@@ -88,9 +91,10 @@ def __init__(self, example):
8891
def generate(self):
8992
return {
9093
"pact:matcher:type": "type",
91-
'value': self.example
94+
'value': self.example
9295
}
9396

97+
9498
class Integer(V3Matcher):
9599
"""
96100
Value must be an integer (must be a number and have no decimal places)
@@ -111,16 +115,17 @@ def __init__(self, *args):
111115
def generate(self):
112116
if self.example is not None:
113117
return {
114-
'pact:matcher:type': 'integer',
115-
'value': self.example
118+
'pact:matcher:type': 'integer',
119+
'value': self.example
116120
}
117121
else:
118122
return {
119-
"pact:generator:type": "RandomInt",
120-
"pact:matcher:type": "integer",
121-
"value": 101
123+
"pact:generator:type": "RandomInt",
124+
"pact:matcher:type": "integer",
125+
"value": 101
122126
}
123127

128+
124129
class DateTime(V3Matcher):
125130
"""
126131
String value that must match the provided datetime format string.

pact/pact_v3.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ def with_request(self, method='GET', path='/', query=None, headers=None, body=No
3333
self.pact.with_request(method, path, query, headers, self.__process_body(body))
3434
return self
3535

36+
def with_request_with_binary_file(self, content_type, file, method='POST', path='/', query=None, headers=None):
37+
self.pact.add_request_binary_file(content_type, file, method, path, query, headers)
38+
return self
39+
3640
def will_respond_with(self, status=200, headers=None, body=None):
3741
self.pact.will_respond_with(status, headers, self.__process_body(body))
3842
return self
@@ -57,8 +61,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
5761
if 'mismatches' in result:
5862
j = 1
5963
for mismatch in result['mismatches']:
60-
error += "\n\t {}) {} {} {}".format(j, mismatch["type"], mismatch["path"],
61-
mismatch["mismatch"])
64+
error += "\n\t {}) {} {}".format(j, mismatch["type"], mismatch["mismatch"])
6265

6366
if result['type'] == "request-not-found":
6467
error += "\n The following request was not expected: {}".format(result["request"])

0 commit comments

Comments
 (0)