pyssertive 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyssertive/__init__.py +0 -0
- pyssertive/db.py +51 -0
- pyssertive/http/__init__.py +8 -0
- pyssertive/http/assertions.py +317 -0
- pyssertive/http/client.py +124 -0
- pyssertive/http/debug.py +78 -0
- pyssertive/http/django.py +86 -0
- pyssertive/http/request.py +130 -0
- pyssertive-0.1.0.dist-info/METADATA +151 -0
- pyssertive-0.1.0.dist-info/RECORD +13 -0
- pyssertive-0.1.0.dist-info/WHEEL +5 -0
- pyssertive-0.1.0.dist-info/licenses/LICENSE +202 -0
- pyssertive-0.1.0.dist-info/top_level.txt +1 -0
pyssertive/__init__.py
ADDED
|
File without changes
|
pyssertive/db.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Database assertion utilities for Django models."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Collection, Generator, Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import connection
|
|
8
|
+
from django.db.models import Model, QuerySet
|
|
9
|
+
from django.test import TransactionTestCase
|
|
10
|
+
from django.test.utils import CaptureQueriesContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def assert_queryset_equal(
|
|
14
|
+
actual: QuerySet | list[Any] | Iterator[Any],
|
|
15
|
+
expected: Collection,
|
|
16
|
+
*,
|
|
17
|
+
transform: Callable[[Any], Any] = repr,
|
|
18
|
+
ordered: bool = True,
|
|
19
|
+
) -> None:
|
|
20
|
+
TransactionTestCase().assertQuerySetEqual(actual, expected, transform=transform, ordered=ordered)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def assert_num_queries(expected: int) -> Generator[None, Any, None]:
|
|
25
|
+
with CaptureQueriesContext(connection) as ctx:
|
|
26
|
+
yield
|
|
27
|
+
actual = len(ctx)
|
|
28
|
+
assert actual == expected, f"Expected {expected} queries, but got {actual}.\nQueries:\n" + "\n".join(
|
|
29
|
+
q["sql"] for q in ctx.captured_queries
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def assert_model_exists(model: type[Model], **filters) -> None:
|
|
34
|
+
assert model.objects.filter(**filters).exists(), f"{model.__name__} does not exist with filters: {filters}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def assert_model_not_exists(model: type[Model], **filters) -> None:
|
|
38
|
+
assert not model.objects.filter(**filters).exists(), f"{model.__name__} unexpectedly exists with filters: {filters}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def assert_model_count(model: type[Model], expected: int, **filters) -> None:
|
|
42
|
+
actual = model.objects.filter(**filters).count()
|
|
43
|
+
assert actual == expected, f"Expected {expected} records for {model.__name__}, got {actual}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def assert_model_soft_deleted(model: type[Model], **filters) -> None:
|
|
47
|
+
"""Assumes model uses soft-deletion with a `deleted_at` datetime field."""
|
|
48
|
+
obj = model.objects.filter(**filters).first()
|
|
49
|
+
assert obj is not None, f"{model.__name__} does not exist with filters: {filters}"
|
|
50
|
+
assert hasattr(obj, "deleted_at"), f"{model.__name__} has no 'deleted_at' field"
|
|
51
|
+
assert obj.deleted_at is not None, f"{model.__name__} is not soft deleted"
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Self
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from django.http import HttpResponse
|
|
10
|
+
from django.test import SimpleTestCase
|
|
11
|
+
from django.utils.html import strip_tags
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HttpStatusAssertionsMixin:
|
|
15
|
+
_response: HttpResponse
|
|
16
|
+
|
|
17
|
+
def assert_ok(self) -> Self:
|
|
18
|
+
assert 200 <= self._response.status_code < 300, f"Expected 2xx, got {self._response.status_code}"
|
|
19
|
+
return self # type: ignore[return-value]
|
|
20
|
+
|
|
21
|
+
def assert_created(self) -> Self:
|
|
22
|
+
assert self._response.status_code == 201, f"Expected 201 Created, got {self._response.status_code}"
|
|
23
|
+
return self # type: ignore[return-value]
|
|
24
|
+
|
|
25
|
+
def assert_accepted(self) -> Self:
|
|
26
|
+
assert self._response.status_code == 202, f"Expected 202 Accepted, got {self._response.status_code}"
|
|
27
|
+
return self # type: ignore[return-value]
|
|
28
|
+
|
|
29
|
+
def assert_no_content(self) -> Self:
|
|
30
|
+
assert self._response.status_code == 204, f"Expected 204 No Content, got {self._response.status_code}"
|
|
31
|
+
return self # type: ignore[return-value]
|
|
32
|
+
|
|
33
|
+
def assert_redirect(self, to: str | None = None) -> Self:
|
|
34
|
+
assert 300 <= self._response.status_code < 400, f"Expected redirect (3xx), got {self._response.status_code}"
|
|
35
|
+
if to is not None:
|
|
36
|
+
location = self._response.headers.get("Location") or self._response.get("Location")
|
|
37
|
+
assert location, "Redirect location header is missing"
|
|
38
|
+
expected_path = urlparse(to).path
|
|
39
|
+
actual_path = urlparse(location).path
|
|
40
|
+
assert actual_path.endswith(expected_path), f"Expected redirect to '{to}', got '{location}'"
|
|
41
|
+
return self # type: ignore[return-value]
|
|
42
|
+
|
|
43
|
+
def assert_moved_permanently(self) -> Self:
|
|
44
|
+
assert self._response.status_code == 301, f"Expected 301 Moved Permanently, got {self._response.status_code}"
|
|
45
|
+
return self # type: ignore[return-value]
|
|
46
|
+
|
|
47
|
+
def assert_found(self) -> Self:
|
|
48
|
+
assert self._response.status_code == 302, f"Expected 302 Found (redirect), got {self._response.status_code}"
|
|
49
|
+
return self # type: ignore[return-value]
|
|
50
|
+
|
|
51
|
+
def assert_see_other(self) -> Self:
|
|
52
|
+
assert self._response.status_code == 303, f"Expected 303 See Other, got {self._response.status_code}"
|
|
53
|
+
return self # type: ignore[return-value]
|
|
54
|
+
|
|
55
|
+
def assert_bad_request(self) -> Self:
|
|
56
|
+
assert self._response.status_code == 400, f"Expected 400 Bad Request, got {self._response.status_code}"
|
|
57
|
+
return self # type: ignore[return-value]
|
|
58
|
+
|
|
59
|
+
def assert_unauthorized(self) -> Self:
|
|
60
|
+
assert self._response.status_code == 401, f"Expected 401 Unauthorized, got {self._response.status_code}"
|
|
61
|
+
return self # type: ignore[return-value]
|
|
62
|
+
|
|
63
|
+
def assert_payment_required(self) -> Self:
|
|
64
|
+
assert self._response.status_code == 402, f"Expected 402 Payment Required, got {self._response.status_code}"
|
|
65
|
+
return self # type: ignore[return-value]
|
|
66
|
+
|
|
67
|
+
def assert_forbidden(self) -> Self:
|
|
68
|
+
assert self._response.status_code == 403, f"Expected 403 Forbidden, got {self._response.status_code}"
|
|
69
|
+
return self # type: ignore[return-value]
|
|
70
|
+
|
|
71
|
+
def assert_not_found(self) -> Self:
|
|
72
|
+
assert self._response.status_code == 404, f"Expected 404 Not Found, got {self._response.status_code}"
|
|
73
|
+
return self # type: ignore[return-value]
|
|
74
|
+
|
|
75
|
+
def assert_method_not_allowed(self) -> Self:
|
|
76
|
+
assert self._response.status_code == 405, f"Expected 405 Method Not Allowed, got {self._response.status_code}"
|
|
77
|
+
return self # type: ignore[return-value]
|
|
78
|
+
|
|
79
|
+
def assert_request_timeout(self) -> Self:
|
|
80
|
+
assert self._response.status_code == 408, f"Expected 408 Request Timeout, got {self._response.status_code}"
|
|
81
|
+
return self # type: ignore[return-value]
|
|
82
|
+
|
|
83
|
+
def assert_conflict(self) -> Self:
|
|
84
|
+
assert self._response.status_code == 409, f"Expected 409 Conflict, got {self._response.status_code}"
|
|
85
|
+
return self # type: ignore[return-value]
|
|
86
|
+
|
|
87
|
+
def assert_gone(self) -> Self:
|
|
88
|
+
assert self._response.status_code == 410, f"Expected 410 Gone, got {self._response.status_code}"
|
|
89
|
+
return self # type: ignore[return-value]
|
|
90
|
+
|
|
91
|
+
def assert_unprocessable(self) -> Self:
|
|
92
|
+
assert self._response.status_code == 422, f"Expected 422 Unprocessable Entity, got {self._response.status_code}"
|
|
93
|
+
return self # type: ignore[return-value]
|
|
94
|
+
|
|
95
|
+
def assert_too_many_requests(self) -> Self:
|
|
96
|
+
assert self._response.status_code == 429, f"Expected 429 Too Many Requests, got {self._response.status_code}"
|
|
97
|
+
return self # type: ignore[return-value]
|
|
98
|
+
|
|
99
|
+
def assert_internal_server_error(self) -> Self:
|
|
100
|
+
assert self._response.status_code == 500, (
|
|
101
|
+
f"Expected 500 Internal Server Error, got {self._response.status_code}"
|
|
102
|
+
)
|
|
103
|
+
return self # type: ignore[return-value]
|
|
104
|
+
|
|
105
|
+
def assert_service_unavailable(self) -> Self:
|
|
106
|
+
assert self._response.status_code == 503, f"Expected 503 Service Unavailable, got {self._response.status_code}"
|
|
107
|
+
return self # type: ignore[return-value]
|
|
108
|
+
|
|
109
|
+
def assert_server_error(self) -> Self:
|
|
110
|
+
assert 500 <= self._response.status_code < 600, f"Expected 5xx Server Error, got {self._response.status_code}"
|
|
111
|
+
return self # type: ignore[return-value]
|
|
112
|
+
|
|
113
|
+
def assert_client_error(self) -> Self:
|
|
114
|
+
assert 400 <= self._response.status_code < 500, f"Expected 4xx Client Error, got {self._response.status_code}"
|
|
115
|
+
return self # type: ignore[return-value]
|
|
116
|
+
|
|
117
|
+
def assert_status(self, status_code: int) -> Self:
|
|
118
|
+
assert self._response.status_code == status_code, (
|
|
119
|
+
f"Expected status {status_code}, got {self._response.status_code}"
|
|
120
|
+
)
|
|
121
|
+
return self # type: ignore[return-value]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class HeaderAssertionsMixin:
|
|
125
|
+
_response: HttpResponse
|
|
126
|
+
|
|
127
|
+
def assert_header(self, name: str, value: str) -> Self:
|
|
128
|
+
actual = self._response.headers.get(name)
|
|
129
|
+
assert actual == value, f"Expected header '{name}' to be '{value}', got '{actual}'"
|
|
130
|
+
return self # type: ignore[return-value]
|
|
131
|
+
|
|
132
|
+
def assert_header_contains(self, name: str, fragment: str) -> Self:
|
|
133
|
+
actual = self._response.headers.get(name)
|
|
134
|
+
assert actual is not None, f"Expected header '{name}' to exist"
|
|
135
|
+
assert fragment in actual, f"Expected header '{name}' to contain '{fragment}', got '{actual}'"
|
|
136
|
+
return self # type: ignore[return-value]
|
|
137
|
+
|
|
138
|
+
def assert_header_missing(self, name: str) -> Self:
|
|
139
|
+
assert name not in self._response.headers, (
|
|
140
|
+
f"Expected header '{name}' to be missing, but found: '{self._response.headers.get(name)}'"
|
|
141
|
+
)
|
|
142
|
+
return self # type: ignore[return-value]
|
|
143
|
+
|
|
144
|
+
def assert_content_type(self, expected: str) -> Self:
|
|
145
|
+
actual = self._response.headers.get("Content-Type")
|
|
146
|
+
assert actual == expected, f"Expected Content-Type '{expected}', got '{actual}'"
|
|
147
|
+
return self # type: ignore[return-value]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class HTMLContentAssertionsMixin:
|
|
151
|
+
_response: HttpResponse
|
|
152
|
+
|
|
153
|
+
def assert_see(self, text: str) -> Self:
|
|
154
|
+
body = html.unescape(self._response.content.decode("utf-8", errors="replace"))
|
|
155
|
+
body = re.sub(r"\s+", " ", body).strip()
|
|
156
|
+
assert text in body, f"Expected to see '{text}', got: {body}"
|
|
157
|
+
return self # type: ignore[return-value]
|
|
158
|
+
|
|
159
|
+
def assert_dont_see(self, text: str) -> Self:
|
|
160
|
+
body = html.unescape(self._response.content.decode("utf-8", errors="replace"))
|
|
161
|
+
body = re.sub(r"\s+", " ", body).strip()
|
|
162
|
+
assert text not in body, f"Did not expect to see '{text}', got: {body}"
|
|
163
|
+
return self # type: ignore[return-value]
|
|
164
|
+
|
|
165
|
+
def assert_see_text(self, text: str) -> Self:
|
|
166
|
+
plain = html.unescape(strip_tags(self._response.content.decode("utf-8", errors="replace")))
|
|
167
|
+
plain = re.sub(r"\s+", " ", plain).strip()
|
|
168
|
+
assert text in plain, f"Expected to see plain text '{text}', got: {plain}"
|
|
169
|
+
return self # type: ignore[return-value]
|
|
170
|
+
|
|
171
|
+
def assert_dont_see_text(self, text: str) -> Self:
|
|
172
|
+
plain = html.unescape(strip_tags(self._response.content.decode("utf-8", errors="replace")))
|
|
173
|
+
plain = re.sub(r"\s+", " ", plain).strip()
|
|
174
|
+
assert text not in plain, f"Did not expect plain text '{text}', got: {plain}"
|
|
175
|
+
return self # type: ignore[return-value]
|
|
176
|
+
|
|
177
|
+
def assert_see_in_order(self, texts: list[str]) -> Self:
|
|
178
|
+
body = html.unescape(self._response.content.decode("utf-8", errors="replace"))
|
|
179
|
+
body = re.sub(r"\s+", " ", body).strip()
|
|
180
|
+
last_index = -1
|
|
181
|
+
last_text = ""
|
|
182
|
+
for key, text in enumerate(texts):
|
|
183
|
+
index = body.find(text, last_index + 1)
|
|
184
|
+
message = "" if last_text == "" else f"after '{last_text}'"
|
|
185
|
+
assert index != -1, f"'{text}' ({key}) not found {message}"
|
|
186
|
+
last_index = index
|
|
187
|
+
last_text = texts[key]
|
|
188
|
+
return self # type: ignore[return-value]
|
|
189
|
+
|
|
190
|
+
def assert_html_contains(self, html_fragment: str) -> Self:
|
|
191
|
+
SimpleTestCase().assertInHTML(html_fragment, self._response.content.decode())
|
|
192
|
+
return self # type: ignore[return-value]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class JsonContentAssertionsMixin:
|
|
196
|
+
_response: HttpResponse
|
|
197
|
+
|
|
198
|
+
def _get_json(self) -> Any:
|
|
199
|
+
try:
|
|
200
|
+
return json.loads(self._response.content)
|
|
201
|
+
except json.JSONDecodeError:
|
|
202
|
+
raise AssertionError("Response content is not valid JSON") from None
|
|
203
|
+
|
|
204
|
+
def _resolve_path(self, data: Any, path: str) -> Any:
|
|
205
|
+
for part in path.split("."):
|
|
206
|
+
if isinstance(data, dict):
|
|
207
|
+
data = data.get(part)
|
|
208
|
+
elif isinstance(data, list) and part.isdigit():
|
|
209
|
+
data = data[int(part)]
|
|
210
|
+
else:
|
|
211
|
+
raise AssertionError(f"Path '{path}' not found in response JSON")
|
|
212
|
+
return data
|
|
213
|
+
|
|
214
|
+
def assert_json(self) -> Self:
|
|
215
|
+
self._get_json()
|
|
216
|
+
return self # type: ignore[return-value]
|
|
217
|
+
|
|
218
|
+
def assert_json_path(self, path: str, expected: Any) -> Self:
|
|
219
|
+
data = self._get_json()
|
|
220
|
+
actual = self._resolve_path(data, path)
|
|
221
|
+
assert actual == expected, f"Expected '{expected}' at path '{path}', got '{actual}'"
|
|
222
|
+
return self # type: ignore[return-value]
|
|
223
|
+
|
|
224
|
+
def assert_json_fragment(self, fragment: dict) -> Self:
|
|
225
|
+
data = self._get_json()
|
|
226
|
+
flat = json.dumps(data)
|
|
227
|
+
for key, value in fragment.items():
|
|
228
|
+
pair = f'"{key}": {json.dumps(value)}'
|
|
229
|
+
assert pair in flat, f"Fragment {key}: {value} not found in response JSON"
|
|
230
|
+
return self # type: ignore[return-value]
|
|
231
|
+
|
|
232
|
+
def assert_json_missing_fragment(self, fragment: dict) -> Self:
|
|
233
|
+
data = self._get_json()
|
|
234
|
+
flat = json.dumps(data)
|
|
235
|
+
for key, value in fragment.items():
|
|
236
|
+
pair = f'"{key}": {json.dumps(value)}'
|
|
237
|
+
assert pair not in flat, f"Unexpected fragment {key}: {value} found in response JSON"
|
|
238
|
+
return self # type: ignore[return-value]
|
|
239
|
+
|
|
240
|
+
def assert_json_count(self, expected: int, path: str | None = None) -> Self:
|
|
241
|
+
data = self._get_json()
|
|
242
|
+
if path:
|
|
243
|
+
data = self._resolve_path(data, path)
|
|
244
|
+
assert isinstance(data, list), f"Expected a list at path '{path}', got {type(data)}"
|
|
245
|
+
assert len(data) == expected, f"Expected {expected} items at '{path}', got {len(data)}"
|
|
246
|
+
return self # type: ignore[return-value]
|
|
247
|
+
|
|
248
|
+
def assert_exact_json(self, expected: Any) -> Self:
|
|
249
|
+
data = self._get_json()
|
|
250
|
+
assert data == expected, f"Expected exact JSON: {expected}, got: {data}"
|
|
251
|
+
return self # type: ignore[return-value]
|
|
252
|
+
|
|
253
|
+
def assert_json_structure(self, structure: dict) -> Self:
|
|
254
|
+
data = self._get_json()
|
|
255
|
+
assert isinstance(data, dict), f"Expected JSON object, got {type(data).__name__}"
|
|
256
|
+
for key, expected_type in structure.items():
|
|
257
|
+
assert key in data, f"Key '{key}' missing from JSON response"
|
|
258
|
+
if expected_type is not None:
|
|
259
|
+
actual_type = type(data[key])
|
|
260
|
+
assert isinstance(data[key], expected_type), (
|
|
261
|
+
f"Key '{key}' expected type {expected_type.__name__}, got {actual_type.__name__}"
|
|
262
|
+
)
|
|
263
|
+
return self # type: ignore[return-value]
|
|
264
|
+
|
|
265
|
+
def assert_json_missing_path(self, path: str) -> Self:
|
|
266
|
+
data = self._get_json()
|
|
267
|
+
parts = path.split(".")
|
|
268
|
+
current = data
|
|
269
|
+
for part in parts:
|
|
270
|
+
if isinstance(current, dict):
|
|
271
|
+
if part not in current:
|
|
272
|
+
return self # type: ignore[return-value]
|
|
273
|
+
current = current[part]
|
|
274
|
+
elif isinstance(current, list) and part.isdigit():
|
|
275
|
+
idx = int(part)
|
|
276
|
+
if idx >= len(current):
|
|
277
|
+
return self # type: ignore[return-value]
|
|
278
|
+
current = current[idx]
|
|
279
|
+
else:
|
|
280
|
+
return self # type: ignore[return-value]
|
|
281
|
+
raise AssertionError(f"Path '{path}' should not exist but has value: {current}")
|
|
282
|
+
|
|
283
|
+
def assert_json_is_array(self) -> Self:
|
|
284
|
+
data = self._get_json()
|
|
285
|
+
assert isinstance(data, list), f"Expected JSON array, got {type(data).__name__}"
|
|
286
|
+
return self # type: ignore[return-value]
|
|
287
|
+
|
|
288
|
+
def assert_json_is_object(self) -> Self:
|
|
289
|
+
data = self._get_json()
|
|
290
|
+
assert isinstance(data, dict), f"Expected JSON object, got {type(data).__name__}"
|
|
291
|
+
return self # type: ignore[return-value]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class CookieAssertionsMixin:
|
|
295
|
+
_response: HttpResponse
|
|
296
|
+
|
|
297
|
+
def assert_cookie(self, name: str, value: str | None = None) -> Self:
|
|
298
|
+
cookies = self._response.cookies
|
|
299
|
+
assert name in cookies, f"Cookie '{name}' not found. Available cookies: {list(cookies.keys())}"
|
|
300
|
+
if value is not None:
|
|
301
|
+
actual = cookies[name].value
|
|
302
|
+
assert actual == value, f"Cookie '{name}' expected '{value}', got '{actual}'"
|
|
303
|
+
return self # type: ignore[return-value]
|
|
304
|
+
|
|
305
|
+
def assert_cookie_missing(self, name: str) -> Self:
|
|
306
|
+
cookies = self._response.cookies
|
|
307
|
+
assert name not in cookies, f"Cookie '{name}' should not exist but has value '{cookies[name].value}'"
|
|
308
|
+
return self # type: ignore[return-value]
|
|
309
|
+
|
|
310
|
+
def assert_cookie_expired(self, name: str) -> Self:
|
|
311
|
+
cookies = self._response.cookies
|
|
312
|
+
assert name in cookies, f"Cookie '{name}' not found"
|
|
313
|
+
cookie = cookies[name]
|
|
314
|
+
max_age = cookie.get("max-age")
|
|
315
|
+
is_expired = max_age == 0 or max_age == "0" or cookie.value == ""
|
|
316
|
+
assert is_expired, f"Cookie '{name}' is not expired (max-age={max_age}, value='{cookie.value}')"
|
|
317
|
+
return self # type: ignore[return-value]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.http import HttpResponse
|
|
6
|
+
from django.test import Client
|
|
7
|
+
|
|
8
|
+
from pyssertive.http.assertions import (
|
|
9
|
+
CookieAssertionsMixin,
|
|
10
|
+
HeaderAssertionsMixin,
|
|
11
|
+
HTMLContentAssertionsMixin,
|
|
12
|
+
HttpStatusAssertionsMixin,
|
|
13
|
+
JsonContentAssertionsMixin,
|
|
14
|
+
)
|
|
15
|
+
from pyssertive.http.debug import DebugResponseMixin
|
|
16
|
+
from pyssertive.http.django import (
|
|
17
|
+
FormValidationAssertionsMixin,
|
|
18
|
+
SessionAssertionsMixin,
|
|
19
|
+
TemplateContextAssertionsMixin,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FluentResponse(
|
|
24
|
+
DebugResponseMixin,
|
|
25
|
+
SessionAssertionsMixin,
|
|
26
|
+
CookieAssertionsMixin,
|
|
27
|
+
TemplateContextAssertionsMixin,
|
|
28
|
+
FormValidationAssertionsMixin,
|
|
29
|
+
HTMLContentAssertionsMixin,
|
|
30
|
+
JsonContentAssertionsMixin,
|
|
31
|
+
HeaderAssertionsMixin,
|
|
32
|
+
HttpStatusAssertionsMixin,
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Fluent assertion wrapper for Django HTTP responses.
|
|
36
|
+
|
|
37
|
+
Example::
|
|
38
|
+
|
|
39
|
+
response = client.get('/api/users/')
|
|
40
|
+
FluentResponse(response).assert_ok().assert_json().assert_json_path('count', 10)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, response: HttpResponse) -> None:
|
|
44
|
+
self._response: HttpResponse = response
|
|
45
|
+
|
|
46
|
+
def __getattr__(self, name: str) -> Any:
|
|
47
|
+
return getattr(self._response, name)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def wrapped(self) -> HttpResponse:
|
|
51
|
+
return self._response
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def status_code(self) -> int:
|
|
55
|
+
return self._response.status_code
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def content(self) -> bytes:
|
|
59
|
+
return self._response.content
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def headers(self) -> Any:
|
|
63
|
+
return self._response.headers
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def cookies(self) -> Any:
|
|
67
|
+
return self._response.cookies
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def charset(self) -> str | None:
|
|
71
|
+
return self._response.charset
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def reason_phrase(self) -> str:
|
|
75
|
+
return self._response.reason_phrase
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FluentHttpAssertClient:
|
|
79
|
+
"""
|
|
80
|
+
Fluent wrapper for Django's test client.
|
|
81
|
+
|
|
82
|
+
Example::
|
|
83
|
+
|
|
84
|
+
client = FluentHttpAssertClient(Client())
|
|
85
|
+
client.get('/api/').assert_ok().assert_json()
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, base_client: Client) -> None:
|
|
89
|
+
self._client: Client = base_client
|
|
90
|
+
|
|
91
|
+
def get(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
92
|
+
response = self._client.get(*args, **kwargs)
|
|
93
|
+
return FluentResponse(response)
|
|
94
|
+
|
|
95
|
+
def post(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
96
|
+
response = self._client.post(*args, **kwargs)
|
|
97
|
+
return FluentResponse(response)
|
|
98
|
+
|
|
99
|
+
def put(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
100
|
+
response = self._client.put(*args, **kwargs)
|
|
101
|
+
return FluentResponse(response)
|
|
102
|
+
|
|
103
|
+
def patch(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
104
|
+
response = self._client.patch(*args, **kwargs)
|
|
105
|
+
return FluentResponse(response)
|
|
106
|
+
|
|
107
|
+
def delete(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
108
|
+
response = self._client.delete(*args, **kwargs)
|
|
109
|
+
return FluentResponse(response)
|
|
110
|
+
|
|
111
|
+
def head(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
112
|
+
response = self._client.head(*args, **kwargs)
|
|
113
|
+
return FluentResponse(response)
|
|
114
|
+
|
|
115
|
+
def options(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
116
|
+
response = self._client.options(*args, **kwargs)
|
|
117
|
+
return FluentResponse(response)
|
|
118
|
+
|
|
119
|
+
def trace(self, *args: Any, **kwargs: Any) -> FluentResponse:
|
|
120
|
+
response = self._client.trace(*args, **kwargs)
|
|
121
|
+
return FluentResponse(response)
|
|
122
|
+
|
|
123
|
+
def __getattr__(self, name: str) -> Any:
|
|
124
|
+
return getattr(self._client, name)
|
pyssertive/http/debug.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pprint
|
|
5
|
+
from json import JSONDecodeError
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from django.http import HttpResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DebugResponseMixin:
|
|
12
|
+
_response: HttpResponse
|
|
13
|
+
|
|
14
|
+
def dump(self, content_format: str | None = None) -> Self:
|
|
15
|
+
content_type = content_format or self._response.headers.get("Content-Type", "")
|
|
16
|
+
|
|
17
|
+
print("\n[Response Dump - format:", content_type, "]")
|
|
18
|
+
print("[Status]", self._response.status_code)
|
|
19
|
+
print("[Headers]", dict(self._response.headers))
|
|
20
|
+
|
|
21
|
+
match content_type:
|
|
22
|
+
case "application/json":
|
|
23
|
+
try:
|
|
24
|
+
pprint.pprint(json.loads(self._response.content))
|
|
25
|
+
except JSONDecodeError:
|
|
26
|
+
print("[Invalid JSON]", self._response.content.decode(errors="replace"))
|
|
27
|
+
case "text/plain":
|
|
28
|
+
print(self._response.content.decode(errors="replace"))
|
|
29
|
+
case _:
|
|
30
|
+
print(repr(self._response.content))
|
|
31
|
+
|
|
32
|
+
return self # type: ignore[return-value]
|
|
33
|
+
|
|
34
|
+
def dump_headers(self) -> Self:
|
|
35
|
+
print("\n[Response Headers]")
|
|
36
|
+
for key, value in self._response.headers.items():
|
|
37
|
+
print(f" {key}: {value}")
|
|
38
|
+
return self # type: ignore[return-value]
|
|
39
|
+
|
|
40
|
+
def dump_json(self) -> Self:
|
|
41
|
+
print("\n[Response JSON]")
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(self._response.content)
|
|
44
|
+
print(json.dumps(data, indent=2, default=str))
|
|
45
|
+
except JSONDecodeError:
|
|
46
|
+
raise AssertionError("Response content is not valid JSON") from None
|
|
47
|
+
return self # type: ignore[return-value]
|
|
48
|
+
|
|
49
|
+
def dump_session(self) -> Self:
|
|
50
|
+
print("\n[Session Data]")
|
|
51
|
+
if not hasattr(self._response, "wsgi_request"):
|
|
52
|
+
print(" (no request context available)")
|
|
53
|
+
return self # type: ignore[return-value]
|
|
54
|
+
session = dict(self._response.wsgi_request.session)
|
|
55
|
+
if session:
|
|
56
|
+
for key, value in session.items():
|
|
57
|
+
print(f" {key}: {value!r}")
|
|
58
|
+
else:
|
|
59
|
+
print(" (empty)")
|
|
60
|
+
return self # type: ignore[return-value]
|
|
61
|
+
|
|
62
|
+
def dump_cookies(self) -> Self:
|
|
63
|
+
print("\n[Response Cookies]")
|
|
64
|
+
cookies = self._response.cookies
|
|
65
|
+
if cookies:
|
|
66
|
+
for name, cookie in cookies.items():
|
|
67
|
+
print(f" {name}: {cookie.value}")
|
|
68
|
+
if cookie.get("max-age"):
|
|
69
|
+
print(f" max-age: {cookie['max-age']}")
|
|
70
|
+
if cookie.get("path"): # pragma: no branch
|
|
71
|
+
print(f" path: {cookie['path']}")
|
|
72
|
+
else:
|
|
73
|
+
print(" (none)")
|
|
74
|
+
return self # type: ignore[return-value]
|
|
75
|
+
|
|
76
|
+
def dd(self) -> None:
|
|
77
|
+
self.dump()
|
|
78
|
+
raise RuntimeError("dd() called - stopping execution")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Self
|
|
4
|
+
|
|
5
|
+
from django.forms import Form
|
|
6
|
+
from django.forms.formsets import BaseFormSet
|
|
7
|
+
from django.http import HttpResponse
|
|
8
|
+
from django.template.response import TemplateResponse
|
|
9
|
+
from django.test import SimpleTestCase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TemplateContextAssertionsMixin:
|
|
13
|
+
_response: HttpResponse
|
|
14
|
+
|
|
15
|
+
def assert_template_used(self, template_name: str) -> Self:
|
|
16
|
+
SimpleTestCase().assertTemplateUsed(self._response, template_name)
|
|
17
|
+
return self # type: ignore[return-value]
|
|
18
|
+
|
|
19
|
+
def assert_template_not_used(self, template_name: str) -> Self:
|
|
20
|
+
SimpleTestCase().assertTemplateNotUsed(self._response, template_name)
|
|
21
|
+
return self # type: ignore[return-value]
|
|
22
|
+
|
|
23
|
+
def assert_context_has(self, key: str) -> Self:
|
|
24
|
+
assert hasattr(self._response, "context") and self._response.context is not None, "Response has no context"
|
|
25
|
+
assert key in self._response.context, f"Expected context to contain '{key}'"
|
|
26
|
+
return self # type: ignore[return-value]
|
|
27
|
+
|
|
28
|
+
def assert_context_equals(self, key: str, expected: object) -> Self:
|
|
29
|
+
assert hasattr(self._response, "context") and self._response.context is not None, "Response has no context"
|
|
30
|
+
assert key in self._response.context, f"Expected context to contain '{key}'"
|
|
31
|
+
actual = self._response.context[key]
|
|
32
|
+
assert actual == expected, f"Expected context['{key}'] == {expected}, got {actual}"
|
|
33
|
+
return self # type: ignore[return-value]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FormValidationAssertionsMixin:
|
|
37
|
+
_response: HttpResponse
|
|
38
|
+
|
|
39
|
+
def assert_form_error(self, form: str, field: str, error: str | list[str]) -> Self:
|
|
40
|
+
assert isinstance(self._response, TemplateResponse), "Response must be a TemplateResponse"
|
|
41
|
+
assert hasattr(self._response, "context"), "Response has no context"
|
|
42
|
+
assert form in self._response.context, f"'{form}' not found in response context"
|
|
43
|
+
|
|
44
|
+
form_obj = self._response.context[form]
|
|
45
|
+
assert isinstance(form_obj, Form), f"Context variable '{form}' is not a Form instance"
|
|
46
|
+
|
|
47
|
+
SimpleTestCase().assertFormError(form_obj, field, error)
|
|
48
|
+
return self # type: ignore[return-value]
|
|
49
|
+
|
|
50
|
+
def assert_formset_error(self, formset: str, form_index: int, field: str, error: str | list[str]) -> Self:
|
|
51
|
+
assert isinstance(self._response, TemplateResponse), "Response must be a TemplateResponse"
|
|
52
|
+
assert hasattr(self._response, "context"), "Response has no context"
|
|
53
|
+
assert formset in self._response.context, f"'{formset}' not found in response context"
|
|
54
|
+
|
|
55
|
+
formset_obj = self._response.context[formset]
|
|
56
|
+
assert isinstance(formset_obj, BaseFormSet), f"Context variable '{formset}' is not a FormSet instance"
|
|
57
|
+
|
|
58
|
+
SimpleTestCase().assertFormSetError(formset_obj, form_index, field, error)
|
|
59
|
+
return self # type: ignore[return-value]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SessionAssertionsMixin:
|
|
63
|
+
_response: HttpResponse
|
|
64
|
+
|
|
65
|
+
def _get_session(self) -> dict:
|
|
66
|
+
if not hasattr(self._response, "wsgi_request"):
|
|
67
|
+
raise AssertionError("Response has no request context (use Client, not RequestFactory)")
|
|
68
|
+
return dict(self._response.wsgi_request.session)
|
|
69
|
+
|
|
70
|
+
def assert_session_has(self, key: str, value: Any = None) -> Self:
|
|
71
|
+
session = self._get_session()
|
|
72
|
+
assert key in session, f"Session key '{key}' not found. Available keys: {list(session.keys())}"
|
|
73
|
+
if value is not None:
|
|
74
|
+
assert session[key] == value, f"Session key '{key}' expected '{value}', got '{session[key]}'"
|
|
75
|
+
return self # type: ignore[return-value]
|
|
76
|
+
|
|
77
|
+
def assert_session_missing(self, key: str) -> Self:
|
|
78
|
+
session = self._get_session()
|
|
79
|
+
assert key not in session, f"Session key '{key}' should not exist but has value '{session[key]}'"
|
|
80
|
+
return self # type: ignore[return-value]
|
|
81
|
+
|
|
82
|
+
def assert_session_has_all(self, keys: list[str]) -> Self:
|
|
83
|
+
session = self._get_session()
|
|
84
|
+
missing = [k for k in keys if k not in session]
|
|
85
|
+
assert not missing, f"Session missing keys: {missing}. Available keys: {list(session.keys())}"
|
|
86
|
+
return self # type: ignore[return-value]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
6
|
+
from django.http import HttpRequest
|
|
7
|
+
from django.test import RequestFactory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RequestBuilder:
|
|
11
|
+
"""
|
|
12
|
+
Fluent builder for creating HttpRequest objects using Django's RequestFactory.
|
|
13
|
+
|
|
14
|
+
Useful for unit testing views directly without going through the full
|
|
15
|
+
HTTP request/response cycle (bypasses middleware and URL routing).
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
from pyssertive.http.request import RequestBuilder
|
|
20
|
+
|
|
21
|
+
request = (
|
|
22
|
+
RequestBuilder()
|
|
23
|
+
.with_method("POST")
|
|
24
|
+
.with_path("/api/users/")
|
|
25
|
+
.with_body({"name": "John"})
|
|
26
|
+
.with_user(user)
|
|
27
|
+
.with_cookie("session", "abc123")
|
|
28
|
+
.with_meta("HTTP_X_FORWARDED_FOR", "192.168.1.1")
|
|
29
|
+
.build()
|
|
30
|
+
)
|
|
31
|
+
response = my_view(request)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
rf: RequestFactory | None = None,
|
|
37
|
+
method: str = "GET",
|
|
38
|
+
path: str = "/",
|
|
39
|
+
data: dict[str, Any] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.rf = rf or RequestFactory()
|
|
42
|
+
self.method = method.upper()
|
|
43
|
+
self.path = path
|
|
44
|
+
self.data = data or {}
|
|
45
|
+
self.user: AbstractBaseUser | None = None
|
|
46
|
+
self.cookies: dict[str, str] = {}
|
|
47
|
+
self.meta: dict[str, Any] = {}
|
|
48
|
+
self.headers: dict[str, str] = {}
|
|
49
|
+
self.custom_properties: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
def with_method(self, method: str) -> RequestBuilder:
|
|
52
|
+
self.method = method.upper()
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def with_path(self, path: str) -> RequestBuilder:
|
|
56
|
+
self.path = path
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def with_data(self, data: dict[str, Any]) -> RequestBuilder:
|
|
60
|
+
self.data = data
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def with_body(self, data: dict[str, Any]) -> RequestBuilder:
|
|
64
|
+
if self.method not in ["POST", "PUT", "PATCH"]:
|
|
65
|
+
raise ValueError(f"Cannot set body on {self.method} request")
|
|
66
|
+
self.data = data
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def with_query_string(self, params: dict[str, Any]) -> RequestBuilder:
|
|
70
|
+
self.data = params
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def with_user(self, user: AbstractBaseUser) -> RequestBuilder:
|
|
74
|
+
self.user = user
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def with_cookie(self, key: str, value: Any) -> RequestBuilder:
|
|
78
|
+
self.cookies[key] = str(value)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def with_cookies(self, cookies: dict[str, Any]) -> RequestBuilder:
|
|
82
|
+
for key, value in cookies.items():
|
|
83
|
+
self.cookies[key] = str(value)
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def with_meta(self, key: str, value: Any) -> RequestBuilder:
|
|
87
|
+
self.meta[key] = str(value)
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def with_header(self, key: str, value: str) -> RequestBuilder:
|
|
91
|
+
self.headers[key] = value
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def with_headers(self, headers: dict[str, str]) -> RequestBuilder:
|
|
95
|
+
self.headers.update(headers)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def with_property(self, name: str, value: Any) -> RequestBuilder:
|
|
99
|
+
self.custom_properties[name] = value
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def build(self) -> HttpRequest:
|
|
103
|
+
method_map = {
|
|
104
|
+
"GET": self.rf.get,
|
|
105
|
+
"POST": self.rf.post,
|
|
106
|
+
"PUT": self.rf.put,
|
|
107
|
+
"PATCH": self.rf.patch,
|
|
108
|
+
"DELETE": self.rf.delete,
|
|
109
|
+
"HEAD": self.rf.head,
|
|
110
|
+
"OPTIONS": self.rf.options,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if self.method not in method_map:
|
|
114
|
+
raise ValueError(f"Unsupported HTTP method: {self.method}")
|
|
115
|
+
|
|
116
|
+
request = method_map[self.method](self.path, self.data, headers=self.headers)
|
|
117
|
+
|
|
118
|
+
if self.user:
|
|
119
|
+
request.user = self.user
|
|
120
|
+
|
|
121
|
+
for key, value in self.cookies.items():
|
|
122
|
+
request.COOKIES[key] = value
|
|
123
|
+
|
|
124
|
+
for key, value in self.meta.items():
|
|
125
|
+
request.META[key] = value
|
|
126
|
+
|
|
127
|
+
for key, value in self.custom_properties.items():
|
|
128
|
+
setattr(request, key, value)
|
|
129
|
+
|
|
130
|
+
return request
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyssertive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fluent, chainable assertions for Django tests. Inspired by Laravel's elegant testing API.
|
|
5
|
+
Author-email: Unay Santisteban <usantisteban@outlook.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/usantisteban/pyssertive
|
|
8
|
+
Project-URL: Repository, https://github.com/usantisteban/pyssertive.git
|
|
9
|
+
Project-URL: Issues, https://github.com/usantisteban/pyssertive/issues
|
|
10
|
+
Keywords: django,testing,assertions,fluent,pytest
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Framework :: Django
|
|
20
|
+
Classifier: Framework :: Django :: 4.2
|
|
21
|
+
Classifier: Framework :: Django :: 5.0
|
|
22
|
+
Classifier: Framework :: Django :: 5.1
|
|
23
|
+
Classifier: Framework :: Pytest
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django>=4.2
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# pyssertive
|
|
33
|
+
|
|
34
|
+
[](https://github.com/othercodes/pyssertive/actions/workflows/test.yml)
|
|
35
|
+
|
|
36
|
+
Fluent, chainable assertions for Django tests. Inspired by Laravel's elegant testing API.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- Fluent, chainable API for readable test assertions
|
|
41
|
+
- HTTP status code assertions (2xx, 3xx, 4xx, 5xx)
|
|
42
|
+
- JSON response validation with path navigation
|
|
43
|
+
- HTML content assertions
|
|
44
|
+
- Template and context assertions
|
|
45
|
+
- Form and formset error assertions
|
|
46
|
+
- Session and cookie assertions
|
|
47
|
+
- Header assertions
|
|
48
|
+
- Debug helpers for test development
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
|
|
52
|
+
- Python 3.11+
|
|
53
|
+
- Django 4.2+
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pyssertive
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
### Basic Example
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import pytest
|
|
67
|
+
from pyssertive.http import FluentHttpAssertClient
|
|
68
|
+
|
|
69
|
+
@pytest.fixture
|
|
70
|
+
def client():
|
|
71
|
+
from django.test import Client
|
|
72
|
+
return FluentHttpAssertClient(Client())
|
|
73
|
+
|
|
74
|
+
@pytest.mark.django_db
|
|
75
|
+
def test_user_api(client):
|
|
76
|
+
response = client.get("/api/users/")
|
|
77
|
+
|
|
78
|
+
response.assert_ok()\
|
|
79
|
+
.assert_json()\
|
|
80
|
+
.assert_json_path("count", 10)\
|
|
81
|
+
.assert_header("Content-Type", "application/json")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### HTTP Status Assertions
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
response.assert_ok() # 2xx
|
|
88
|
+
response.assert_created() # 201
|
|
89
|
+
response.assert_not_found() # 404
|
|
90
|
+
response.assert_forbidden() # 403
|
|
91
|
+
response.assert_redirect("/login/")
|
|
92
|
+
response.assert_status(418) # Any status code
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### JSON Assertions
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
response.assert_json()\
|
|
99
|
+
.assert_json_path("user.name", "John")\
|
|
100
|
+
.assert_json_fragment({"status": "active"})\
|
|
101
|
+
.assert_json_count(5, path="items")\
|
|
102
|
+
.assert_json_structure({"id": int, "name": str})\
|
|
103
|
+
.assert_json_is_array()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Session and Cookie Assertions
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
response.assert_session_has("user_id", 123)\
|
|
110
|
+
.assert_session_missing("temp_token")\
|
|
111
|
+
.assert_cookie("session_id")\
|
|
112
|
+
.assert_cookie_missing("tracking")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Template Assertions
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
response.assert_template_used("users/list.html")\
|
|
119
|
+
.assert_context_has("users")\
|
|
120
|
+
.assert_context_equals("page", 1)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Debug Helpers
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
response.dump() # Print full response
|
|
127
|
+
response.dump_json() # Pretty print JSON
|
|
128
|
+
response.dump_headers() # Print headers
|
|
129
|
+
response.dump_session() # Print session data
|
|
130
|
+
response.dd() # Dump and die (raises exception)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Database Assertions
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from pyssertive.db import (
|
|
137
|
+
assert_model_exists,
|
|
138
|
+
assert_model_count,
|
|
139
|
+
assert_num_queries,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
assert_model_exists(User, username="john")
|
|
143
|
+
assert_model_count(User, 5)
|
|
144
|
+
|
|
145
|
+
with assert_num_queries(2):
|
|
146
|
+
list(User.objects.all())
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
Apache License 2.0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pyssertive/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pyssertive/db.py,sha256=SOA10V_KtkVAik_K3QMlCMPUzwssVd4tibJP_7_D6Jg,2056
|
|
3
|
+
pyssertive/http/__init__.py,sha256=u4qK0THIBSb_D-aEeMN6pltz4r9oZmFqhADMJgkAoXw,214
|
|
4
|
+
pyssertive/http/assertions.py,sha256=6sKRfEZYzHPwsD9eVxeTm9dGENv9C5MJqc0RTU0Mjmc,14615
|
|
5
|
+
pyssertive/http/client.py,sha256=1zxNIVtP0SPDG_PhwBXgURG6OVumC4u9H3S9KSYr5uo,3529
|
|
6
|
+
pyssertive/http/debug.py,sha256=_BYv2XAp55yVRPew86ty-Koq881ABFIf9gfwX00zlR4,2799
|
|
7
|
+
pyssertive/http/django.py,sha256=btiyhpvrcEVk_0curGjCEhfGZ2_Zg9F9B0yups9DVrg,4130
|
|
8
|
+
pyssertive/http/request.py,sha256=XXlPvNqwB8LDYb5UgsZW2k-esrTflbEvaD3VAtnMJL4,3942
|
|
9
|
+
pyssertive-0.1.0.dist-info/licenses/LICENSE,sha256=CUvt2ilcBwKAfRAnoA4RNPTLcdUj3En21LPK9YkFMGg,11358
|
|
10
|
+
pyssertive-0.1.0.dist-info/METADATA,sha256=ZrWuebALpMR4If-gJCnRu-irDR7CO3RnYLFEiVN2Odo,3995
|
|
11
|
+
pyssertive-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
pyssertive-0.1.0.dist-info/top_level.txt,sha256=bvu8OTqOlhnCqyRciyMafB5DegDKjIv6l0RPdQQtgxE,11
|
|
13
|
+
pyssertive-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "{}"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright {yyyy} {name of copyright owner}
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyssertive
|