hookhappens 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.
- hookhappens/__init__.py +3 -0
- hookhappens/client.py +441 -0
- hookhappens/py.typed +0 -0
- hookhappens/pytest_plugin.py +195 -0
- hookhappens-0.1.0.dist-info/METADATA +59 -0
- hookhappens-0.1.0.dist-info/RECORD +8 -0
- hookhappens-0.1.0.dist-info/WHEEL +4 -0
- hookhappens-0.1.0.dist-info/entry_points.txt +2 -0
hookhappens/__init__.py
ADDED
hookhappens/client.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Drive HookHappens from tests: a namespace of your own, scenarios and rules, and assertions on
|
|
2
|
+
the calls your system made.
|
|
3
|
+
|
|
4
|
+
from hookhappens.client import HookHappens, gt
|
|
5
|
+
|
|
6
|
+
with HookHappens("https://hookhappens.com") as hh, hh.new_namespace("checkout") as ns:
|
|
7
|
+
ns.scenario("bank-down") # change the answers for this test
|
|
8
|
+
with ns.expect_call("authorize-payment", where={"body.amount": gt(0)}) as got:
|
|
9
|
+
shop.checkout(payments_url=ns.url, amount=50) # your system, pointed at ns.url
|
|
10
|
+
assert got.call.status == 503
|
|
11
|
+
|
|
12
|
+
When the expected call doesn't come, the failure says which calls came closest and what was
|
|
13
|
+
different about each (``CallNotReceived``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
21
|
+
from contextlib import contextmanager, suppress
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from types import TracebackType
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx2
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Call", "CallNotReceived", "Expectation", "HookHappens", "HookHappensError", "Namespace",
|
|
30
|
+
"Op", "UnexpectedCall", "contains", "exists", "gt", "gte", "lt", "lte", "matches",
|
|
31
|
+
"missing", "none_of", "not_equal", "one_of",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
Where = Mapping[str, Any] | Sequence[Mapping[str, Any]] | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── conditions: {"body.amount": gt(100)} reads as "body.amount > 100" ──
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Op:
|
|
41
|
+
"""A comparison for a ``where`` value other than "equals"."""
|
|
42
|
+
|
|
43
|
+
op: str
|
|
44
|
+
value: Any
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def gt(v: Any) -> Op:
|
|
48
|
+
return Op("gt", v)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def gte(v: Any) -> Op:
|
|
52
|
+
return Op("gte", v)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def lt(v: Any) -> Op:
|
|
56
|
+
return Op("lt", v)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def lte(v: Any) -> Op:
|
|
60
|
+
return Op("lte", v)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def not_equal(v: Any) -> Op:
|
|
64
|
+
return Op("not_equals", v)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def one_of(*values: Any) -> Op:
|
|
68
|
+
return Op("in", list(values))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def none_of(*values: Any) -> Op:
|
|
72
|
+
return Op("not_in", list(values))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def contains(v: Any) -> Op:
|
|
76
|
+
return Op("contains", v)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def matches(pattern: str) -> Op:
|
|
80
|
+
return Op("matches", pattern)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def exists() -> Op:
|
|
84
|
+
return Op("exists", True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def missing() -> Op:
|
|
88
|
+
return Op("exists", False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def conditions(where: Where) -> list[dict[str, Any]]:
|
|
92
|
+
"""``{"body.amount": 50, "body.currency": one_of("EUR", "USD")}`` or the catalog's own
|
|
93
|
+
form ``[{"field": "body.amount", "gt": 100}]`` → the catalog's form."""
|
|
94
|
+
if where is None:
|
|
95
|
+
return []
|
|
96
|
+
if isinstance(where, Mapping):
|
|
97
|
+
return [{"field": f, v.op: v.value} if isinstance(v, Op) else {"field": f, "equals": v}
|
|
98
|
+
for f, v in where.items()]
|
|
99
|
+
return [dict(c) for c in where]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_SYMBOLS = {"equals": "=", "not_equals": "!=", "gt": ">", "gte": ">=", "lt": "<", "lte": "<=",
|
|
103
|
+
"in": "in", "not_in": "not in", "contains": "contains", "matches": "matches"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _show(v: Any) -> str:
|
|
107
|
+
return json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else repr(v)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def describe_condition(c: Mapping[str, Any]) -> str:
|
|
111
|
+
op = next((k for k in c if k != "field"), "equals")
|
|
112
|
+
if op == "exists":
|
|
113
|
+
return f"{c['field']} {'exists' if c[op] else 'is missing'}"
|
|
114
|
+
return f"{c['field']} {_SYMBOLS.get(op, op)} {_show(c[op])}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── calls ──
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Call:
|
|
121
|
+
"""One call your system made to HookHappens, and what it got back."""
|
|
122
|
+
|
|
123
|
+
id: int
|
|
124
|
+
received_at: str
|
|
125
|
+
method: str
|
|
126
|
+
path: str
|
|
127
|
+
endpoint: str | None
|
|
128
|
+
response_name: str | None
|
|
129
|
+
status: int
|
|
130
|
+
duration_ms: int
|
|
131
|
+
request: dict[str, Any]
|
|
132
|
+
sent: dict[str, Any]
|
|
133
|
+
issues: list[dict[str, Any]]
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_api(cls, d: Mapping[str, Any]) -> Call:
|
|
137
|
+
return cls(d["id"], d["receivedAt"], d["method"], d["path"], d.get("endpoint"),
|
|
138
|
+
d.get("response"), d["status"], d.get("durationMs", 0),
|
|
139
|
+
d.get("request") or {}, d.get("sent") or {}, d.get("issues") or [])
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def body(self) -> Any:
|
|
143
|
+
"""The request body your system sent."""
|
|
144
|
+
return self.request.get("body")
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def headers(self) -> dict[str, str]:
|
|
148
|
+
return dict(self.request.get("headers") or {})
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def query(self) -> dict[str, Any]:
|
|
152
|
+
return dict(self.request.get("query") or {})
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def params(self) -> dict[str, str]:
|
|
156
|
+
return dict(self.request.get("params") or {})
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def response_body(self) -> Any:
|
|
160
|
+
"""What HookHappens answered (after any rules)."""
|
|
161
|
+
return self.sent.get("body")
|
|
162
|
+
|
|
163
|
+
def __str__(self) -> str:
|
|
164
|
+
where = f" ({self.endpoint})" if self.endpoint else ""
|
|
165
|
+
return f"#{self.id} {self.method} {self.path} -> {self.status}{where}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class HookHappensError(Exception):
|
|
169
|
+
"""The server refused a request, or could not be reached."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class CallNotReceived(AssertionError):
|
|
173
|
+
"""The call a test waited for didn't come. The message lists the closest calls."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, message: str, result: Mapping[str, Any]) -> None:
|
|
176
|
+
super().__init__(message)
|
|
177
|
+
self.result = dict(result)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class UnexpectedCall(AssertionError):
|
|
181
|
+
"""A call came that the test said must not."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, message: str, calls: list[Call]) -> None:
|
|
184
|
+
super().__init__(message)
|
|
185
|
+
self.calls = calls
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _what(endpoint: str | None, method: str | None, path: str | None,
|
|
189
|
+
conds: list[dict[str, Any]]) -> str:
|
|
190
|
+
target = (f"call to {endpoint}" if endpoint
|
|
191
|
+
else f"{method or ''} {path}".strip() + " call" if path or method else "call")
|
|
192
|
+
return target + (" where " + " and ".join(map(describe_condition, conds)) if conds else "")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _check_line(c: Mapping[str, Any]) -> str:
|
|
196
|
+
mark = "+" if c["ok"] else "x"
|
|
197
|
+
if "op" not in c: # endpoint / method / path
|
|
198
|
+
return (f"{mark} {c['check']} = {c['expected']}" if c["ok"]
|
|
199
|
+
else f"{mark} {c['check']}: expected {c['expected']}, got {c['actual']}")
|
|
200
|
+
text = describe_condition({"field": c["check"], c["op"]: c["expected"]})
|
|
201
|
+
if c["ok"]:
|
|
202
|
+
return f"{mark} {text}"
|
|
203
|
+
return f"{mark} {text}: got {_show(c['actual']) if c.get('found') else 'nothing'}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def explain_miss(what: str, timeout: float, namespace: str, result: Mapping[str, Any]) -> str:
|
|
207
|
+
"""The failure message: what was expected, and the calls that came closest."""
|
|
208
|
+
seen = result.get("seen", 0)
|
|
209
|
+
lines = [f"No {what} arrived within {timeout:g} s "
|
|
210
|
+
f"(namespace {namespace}; {seen} call{'s' if seen != 1 else ''} seen)."]
|
|
211
|
+
misses = result.get("nearMisses") or []
|
|
212
|
+
if not misses:
|
|
213
|
+
lines.append("No calls at all came in: is your system pointed at "
|
|
214
|
+
f"/h/{namespace}/ ?" if not seen else "")
|
|
215
|
+
else:
|
|
216
|
+
lines.append("Closest:")
|
|
217
|
+
for m in misses:
|
|
218
|
+
c = m["call"]
|
|
219
|
+
checks = m["checks"]
|
|
220
|
+
passed = sum(x["ok"] for x in checks)
|
|
221
|
+
where = f" ({c['endpoint']})" if c.get("endpoint") else ""
|
|
222
|
+
lines.append(f" #{c['id']} {c['method']} {c['path']} -> {c['status']}{where}"
|
|
223
|
+
f" {passed} of {len(checks)} checks")
|
|
224
|
+
lines += [f" {_check_line(x)}" for x in checks]
|
|
225
|
+
return "\n".join(line for line in lines if line)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ── the client ──
|
|
229
|
+
|
|
230
|
+
class HookHappens:
|
|
231
|
+
"""HookHappens, for tests. Use ``new_namespace`` for a namespace of your own per test.
|
|
232
|
+
|
|
233
|
+
``api_key`` is your workspace's API key (default: the ``HOOKHAPPENS_API_KEY`` environment
|
|
234
|
+
variable); every call sends it as ``Authorization: Bearer …``."""
|
|
235
|
+
|
|
236
|
+
def __init__(self, url: str = "https://hookhappens.com", *, api_key: str | None = None,
|
|
237
|
+
timeout: float = 30.0, http: httpx2.Client | None = None) -> None:
|
|
238
|
+
self.url = url.rstrip("/")
|
|
239
|
+
key = api_key or os.environ.get("HOOKHAPPENS_API_KEY")
|
|
240
|
+
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
241
|
+
self._http = http or httpx2.Client(base_url=self.url, timeout=timeout, headers=headers)
|
|
242
|
+
|
|
243
|
+
def close(self) -> None:
|
|
244
|
+
self._http.close()
|
|
245
|
+
|
|
246
|
+
def __enter__(self) -> HookHappens:
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
def __exit__(self, *exc: object) -> None:
|
|
250
|
+
self.close()
|
|
251
|
+
|
|
252
|
+
def request(self, method: str, path: str, body: Any = None,
|
|
253
|
+
params: Mapping[str, Any] | None = None) -> Any:
|
|
254
|
+
try:
|
|
255
|
+
r = self._http.request(method, path, json=body, params=params)
|
|
256
|
+
except httpx2.HTTPError as exc:
|
|
257
|
+
raise HookHappensError(
|
|
258
|
+
f"HookHappens is not reachable at {self.url} ({exc}). Check the address "
|
|
259
|
+
"(HOOKHAPPENS_URL) and the network.") from exc
|
|
260
|
+
if r.status_code >= 400:
|
|
261
|
+
try:
|
|
262
|
+
data = r.json()
|
|
263
|
+
problems = "".join(f"\n {p['location']}: {p['message']}"
|
|
264
|
+
for p in data.get("problems", []))
|
|
265
|
+
message = f"{data.get('message', r.text)}{problems}"
|
|
266
|
+
except ValueError:
|
|
267
|
+
message = r.text
|
|
268
|
+
raise HookHappensError(f"{method} {path} -> {r.status_code}: {message}")
|
|
269
|
+
return r.json() if r.content else None
|
|
270
|
+
|
|
271
|
+
def health(self) -> dict[str, Any]:
|
|
272
|
+
out: dict[str, Any] = self.request("GET", "/health")
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
def namespace(self, namespace: str) -> Namespace:
|
|
276
|
+
"""An existing namespace (e.g. the project's own). Left as it is on close."""
|
|
277
|
+
return Namespace(self, namespace, owned=False)
|
|
278
|
+
|
|
279
|
+
def new_namespace(self, project: str, *, id: str | None = None,
|
|
280
|
+
ttl: int | None = 3600) -> Namespace:
|
|
281
|
+
"""A fresh namespace of ``project``: its own URL, rules and call log. Deleted on close
|
|
282
|
+
(or ``ttl`` seconds after the last keep-alive, if the test never gets there)."""
|
|
283
|
+
data = self.request("POST", f"/api/v1/projects/{project}/namespaces",
|
|
284
|
+
{"id": id, "ttl_seconds": ttl})
|
|
285
|
+
return Namespace(self, data["id"], project=project, owned=True)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class Expectation:
|
|
290
|
+
"""Filled when the ``with ns.expect_call(...)`` block ends: the call that came."""
|
|
291
|
+
|
|
292
|
+
call: Call = field(init=False)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class Namespace:
|
|
296
|
+
"""One namespace: where your system sends its calls (``url``), and what the test controls."""
|
|
297
|
+
|
|
298
|
+
def __init__(self, hh: HookHappens, namespace: str, *, project: str | None = None,
|
|
299
|
+
owned: bool = False) -> None:
|
|
300
|
+
self.hh = hh
|
|
301
|
+
self.id = namespace
|
|
302
|
+
self.project = project
|
|
303
|
+
self.owned = owned
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def url(self) -> str:
|
|
307
|
+
"""The base URL to give your system: the namespace's address, …/h/checkout-3fa2c1"""
|
|
308
|
+
return f"{self.hh.url}/h/{self.id}"
|
|
309
|
+
|
|
310
|
+
def __repr__(self) -> str:
|
|
311
|
+
return f"Namespace({self.id!r})"
|
|
312
|
+
|
|
313
|
+
def __enter__(self) -> Namespace:
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
|
|
317
|
+
tb: TracebackType | None) -> None:
|
|
318
|
+
self.close()
|
|
319
|
+
|
|
320
|
+
def close(self) -> None:
|
|
321
|
+
"""Delete it, if this client made it."""
|
|
322
|
+
if self.owned:
|
|
323
|
+
with suppress(HookHappensError): # already gone (expired) is fine
|
|
324
|
+
self.hh.request("DELETE", f"/api/v1/namespaces/{self.id}")
|
|
325
|
+
self.owned = False
|
|
326
|
+
|
|
327
|
+
def keep_alive(self, ttl: int = 3600) -> None:
|
|
328
|
+
self.hh.request("POST", f"/api/v1/namespaces/{self.id}/keep-alive", {"ttl_seconds": ttl})
|
|
329
|
+
|
|
330
|
+
# ── what it answers ──
|
|
331
|
+
|
|
332
|
+
def scenario(self, scenario_id: str, **settings: Any) -> None:
|
|
333
|
+
"""This scenario of the catalog, and nothing else: other scenarios and the rules armed
|
|
334
|
+
here are switched off. Keyword arguments set its settings, e.g.
|
|
335
|
+
``ns.scenario("skip-steps", stepTypes=["table"])``."""
|
|
336
|
+
if settings:
|
|
337
|
+
self.scenario_settings(scenario_id, **settings)
|
|
338
|
+
self.hh.request("PUT", f"/api/v1/namespaces/{self.id}/active-scenario", {"id": scenario_id})
|
|
339
|
+
|
|
340
|
+
def scenario_on(self, scenario_id: str, **settings: Any) -> None:
|
|
341
|
+
"""Switch a scenario on as well (any number can be on), with these settings."""
|
|
342
|
+
self._switch(scenario_id, {"active": True, **({"settings": settings} if settings else {})})
|
|
343
|
+
|
|
344
|
+
def scenario_off(self, scenario_id: str) -> None:
|
|
345
|
+
self._switch(scenario_id, {"active": False})
|
|
346
|
+
|
|
347
|
+
def scenario_settings(self, scenario_id: str, **settings: Any) -> None:
|
|
348
|
+
"""Set a scenario's settings here (``None`` puts one back to its default)."""
|
|
349
|
+
self._switch(scenario_id, {"settings": settings})
|
|
350
|
+
|
|
351
|
+
def _switch(self, scenario_id: str, body: dict[str, Any]) -> None:
|
|
352
|
+
self.hh.request("PUT", f"/api/v1/namespaces/{self.id}/scenarios/{scenario_id}", body)
|
|
353
|
+
|
|
354
|
+
def clear_rules(self) -> None:
|
|
355
|
+
"""Back to the catalog's own answers (no scenario, no rules)."""
|
|
356
|
+
self.hh.request("DELETE", f"/api/v1/namespaces/{self.id}/active-scenario")
|
|
357
|
+
|
|
358
|
+
def rule(self, endpoint: str | None = None, *, then: Sequence[Mapping[str, Any]],
|
|
359
|
+
when: Where = None, skip: int = 0, times: int | None = None,
|
|
360
|
+
description: str | None = None) -> str:
|
|
361
|
+
"""Add one rule (see the scenario rules docs for ``then`` actions). Returns its id."""
|
|
362
|
+
body: dict[str, Any] = {"endpoint": endpoint, "then": [dict(a) for a in then],
|
|
363
|
+
"when": conditions(when), "skip": skip, "times": times,
|
|
364
|
+
"description": description}
|
|
365
|
+
out = self.hh.request("POST", f"/api/v1/namespaces/{self.id}/rules",
|
|
366
|
+
{k: v for k, v in body.items() if v not in (None, [])})
|
|
367
|
+
return str(out["id"])
|
|
368
|
+
|
|
369
|
+
# ── what it received ──
|
|
370
|
+
|
|
371
|
+
def calls(self, endpoint: str | None = None, *, limit: int = 200) -> list[Call]:
|
|
372
|
+
"""The calls in the log, oldest first."""
|
|
373
|
+
data = self.hh.request("GET", f"/api/v1/namespaces/{self.id}/calls",
|
|
374
|
+
params={"limit": limit})
|
|
375
|
+
calls = [Call.from_api(c) for c in reversed(data["calls"])]
|
|
376
|
+
return [c for c in calls if endpoint is None or c.endpoint == endpoint]
|
|
377
|
+
|
|
378
|
+
def mark(self) -> int:
|
|
379
|
+
"""The newest call's id now (0 if none): pass it as ``after`` to wait only for later
|
|
380
|
+
calls."""
|
|
381
|
+
data = self.hh.request("GET", f"/api/v1/namespaces/{self.id}/calls", params={"limit": 1})
|
|
382
|
+
return int(data["calls"][0]["id"]) if data["calls"] else 0
|
|
383
|
+
|
|
384
|
+
def _wait(self, endpoint: str | None, where: Where, method: str | None, path: str | None,
|
|
385
|
+
after: int | None, timeout: float, count: int) -> tuple[dict[str, Any], str]:
|
|
386
|
+
conds = conditions(where)
|
|
387
|
+
result: dict[str, Any] = self.hh.request(
|
|
388
|
+
"POST", f"/api/v1/namespaces/{self.id}/calls/wait",
|
|
389
|
+
{"endpoint": endpoint, "where": conds, "method": method, "path": path,
|
|
390
|
+
"after": after, "count": count, "timeout_ms": int(timeout * 1000)})
|
|
391
|
+
return result, _what(endpoint, method, path, conds)
|
|
392
|
+
|
|
393
|
+
def wait_for(self, endpoint: str | None = None, *, where: Where = None,
|
|
394
|
+
method: str | None = None, path: str | None = None, after: int | None = None,
|
|
395
|
+
timeout: float = 5.0) -> Call:
|
|
396
|
+
"""The first call that fits (already in the log, or arriving within ``timeout``).
|
|
397
|
+
Raises ``CallNotReceived``, listing the closest calls, when none does."""
|
|
398
|
+
__tracebackhide__ = True # pytest shows the test's line, not these helpers
|
|
399
|
+
return self.wait_for_all(1, endpoint, where=where, method=method, path=path,
|
|
400
|
+
after=after, timeout=timeout)[0]
|
|
401
|
+
|
|
402
|
+
def wait_for_all(self, count: int, endpoint: str | None = None, *, where: Where = None,
|
|
403
|
+
method: str | None = None, path: str | None = None,
|
|
404
|
+
after: int | None = None, timeout: float = 5.0) -> list[Call]:
|
|
405
|
+
"""``count`` calls that fit, oldest first."""
|
|
406
|
+
__tracebackhide__ = True
|
|
407
|
+
result, what = self._wait(endpoint, where, method, path, after, timeout, count)
|
|
408
|
+
if not result["matched"]:
|
|
409
|
+
found = len(result["calls"])
|
|
410
|
+
label = what if count == 1 else f"{count} x {what} (only {found} came)"
|
|
411
|
+
raise CallNotReceived(explain_miss(label, timeout, self.id, result), result)
|
|
412
|
+
return [Call.from_api(c) for c in result["calls"]]
|
|
413
|
+
|
|
414
|
+
@contextmanager
|
|
415
|
+
def expect_call(self, endpoint: str | None = None, *, where: Where = None,
|
|
416
|
+
method: str | None = None, path: str | None = None,
|
|
417
|
+
timeout: float = 5.0) -> Iterator[Expectation]:
|
|
418
|
+
"""The block must make your system send this call (only calls made inside count)."""
|
|
419
|
+
__tracebackhide__ = True
|
|
420
|
+
after = self.mark()
|
|
421
|
+
got = Expectation()
|
|
422
|
+
yield got
|
|
423
|
+
got.call = self.wait_for(endpoint, where=where, method=method, path=path, after=after,
|
|
424
|
+
timeout=timeout)
|
|
425
|
+
|
|
426
|
+
def expect_no_call(self, endpoint: str | None = None, *, where: Where = None,
|
|
427
|
+
method: str | None = None, path: str | None = None,
|
|
428
|
+
after: int | None = None, within: float = 1.0) -> None:
|
|
429
|
+
"""No such call (in the log after ``after``, or arriving within ``within`` seconds)."""
|
|
430
|
+
__tracebackhide__ = True
|
|
431
|
+
result, what = self._wait(endpoint, where, method, path, after, within, 1)
|
|
432
|
+
if result["matched"]:
|
|
433
|
+
calls = [Call.from_api(c) for c in result["calls"]]
|
|
434
|
+
raise UnexpectedCall(f"Expected no {what}, but got {calls[0]} (namespace {self.id}).",
|
|
435
|
+
calls)
|
|
436
|
+
|
|
437
|
+
def evidence(self) -> dict[str, Any]:
|
|
438
|
+
"""Everything about this namespace's calls, for a test report."""
|
|
439
|
+
data = self.hh.request("GET", f"/api/v1/namespaces/{self.id}/calls", params={"limit": 500})
|
|
440
|
+
return {"namespace": self.id, "project": self.project, "url": self.url,
|
|
441
|
+
"calls": list(reversed(data["calls"]))}
|
hookhappens/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""pytest plugin: each test gets a fresh HookHappens namespace, and a failing test shows the
|
|
2
|
+
calls its system made.
|
|
3
|
+
|
|
4
|
+
# pytest.ini (or --hookhappens-url / --hookhappens-project, or HOOKHAPPENS_URL)
|
|
5
|
+
[pytest]
|
|
6
|
+
hookhappens_url = https://hookhappens.com
|
|
7
|
+
hookhappens_project = checkout
|
|
8
|
+
|
|
9
|
+
def test_paid(hh): # hh: a namespace of its own, deleted afterwards
|
|
10
|
+
assert shop.checkout(hh.url, amount=50) == "paid"
|
|
11
|
+
hh.wait_for("authorize-payment", where={"body.amount": 50})
|
|
12
|
+
|
|
13
|
+
@pytest.mark.hookhappens(scenario="bank-down")
|
|
14
|
+
def test_bank_down(hh): ...
|
|
15
|
+
|
|
16
|
+
With ``--hookhappens-evidence DIR``, every test's calls are written to DIR as JSON, with an
|
|
17
|
+
``index.html`` to browse them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import html
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Generator, Iterator
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import pytest
|
|
31
|
+
|
|
32
|
+
from hookhappens.client import HookHappens, HookHappensError, Namespace
|
|
33
|
+
|
|
34
|
+
NAMESPACE = pytest.StashKey[Namespace]()
|
|
35
|
+
MARKER = "hookhappens"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
39
|
+
group = parser.getgroup("hookhappens", "HookHappens")
|
|
40
|
+
group.addoption("--hookhappens-url", default=None, metavar="URL",
|
|
41
|
+
help="the HookHappens server (default: $HOOKHAPPENS_URL, the "
|
|
42
|
+
"hookhappens_url ini value, or https://hookhappens.com)")
|
|
43
|
+
group.addoption("--hookhappens-project", default=None, metavar="ID",
|
|
44
|
+
help="the project tests get namespaces of (or the hookhappens_project "
|
|
45
|
+
"ini value, or @pytest.mark.hookhappens(project=...))")
|
|
46
|
+
group.addoption("--hookhappens-evidence", default=None, metavar="DIR",
|
|
47
|
+
help="write each test's calls to DIR (JSON per test + index.html)")
|
|
48
|
+
parser.addini("hookhappens_url", "the HookHappens server", default="")
|
|
49
|
+
parser.addini("hookhappens_project", "the project tests get namespaces of", default="")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
53
|
+
config.addinivalue_line(
|
|
54
|
+
"markers", f"{MARKER}(project=None, scenario=None, ttl=3600): the HookHappens "
|
|
55
|
+
"namespace the `hh` fixture makes for this test")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _setting(config: pytest.Config, name: str, env: str | None = None) -> str | None:
|
|
59
|
+
value = config.getoption(f"--hookhappens-{name}")
|
|
60
|
+
if value:
|
|
61
|
+
return str(value)
|
|
62
|
+
if env and os.environ.get(env):
|
|
63
|
+
return os.environ[env]
|
|
64
|
+
ini = config.getini(f"hookhappens_{name}")
|
|
65
|
+
return str(ini) if ini else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture(scope="session")
|
|
69
|
+
def hookhappens(pytestconfig: pytest.Config) -> Iterator[HookHappens]:
|
|
70
|
+
"""The HookHappens server (a client for it), checked to be up."""
|
|
71
|
+
url = _setting(pytestconfig, "url", "HOOKHAPPENS_URL") or "https://hookhappens.com"
|
|
72
|
+
client = HookHappens(url)
|
|
73
|
+
try:
|
|
74
|
+
client.health()
|
|
75
|
+
except HookHappensError as exc:
|
|
76
|
+
client.close()
|
|
77
|
+
pytest.fail(str(exc), pytrace=False)
|
|
78
|
+
yield client
|
|
79
|
+
client.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
def hh(request: pytest.FixtureRequest, hookhappens: HookHappens) -> Iterator[Namespace]:
|
|
84
|
+
"""A fresh namespace of the project for this test: its own URL (``hh.url``), rules and
|
|
85
|
+
call log. Deleted after the test."""
|
|
86
|
+
marker = request.node.get_closest_marker(MARKER)
|
|
87
|
+
options: dict[str, Any] = dict(marker.kwargs) if marker else {}
|
|
88
|
+
project = options.get("project") or _setting(request.config, "project")
|
|
89
|
+
if not project:
|
|
90
|
+
pytest.fail("Which HookHappens project? Set hookhappens_project in pytest.ini, pass "
|
|
91
|
+
"--hookhappens-project, or mark the test "
|
|
92
|
+
"@pytest.mark.hookhappens(project=...)", pytrace=False)
|
|
93
|
+
ns = hookhappens.new_namespace(project, ttl=options.get("ttl", 3600))
|
|
94
|
+
request.node.stash[NAMESPACE] = ns
|
|
95
|
+
try:
|
|
96
|
+
if options.get("scenario"):
|
|
97
|
+
ns.scenario(options["scenario"])
|
|
98
|
+
yield ns
|
|
99
|
+
finally:
|
|
100
|
+
ns.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.hookimpl(wrapper=True)
|
|
104
|
+
def pytest_runtest_makereport(
|
|
105
|
+
item: pytest.Item, call: pytest.CallInfo[None],
|
|
106
|
+
) -> Generator[None, pytest.TestReport, pytest.TestReport]:
|
|
107
|
+
report = yield
|
|
108
|
+
ns = item.stash.get(NAMESPACE, None)
|
|
109
|
+
if ns is None or report.when != "call":
|
|
110
|
+
return report
|
|
111
|
+
evidence_dir = item.config.getoption("--hookhappens-evidence")
|
|
112
|
+
if not (report.failed or evidence_dir):
|
|
113
|
+
return report
|
|
114
|
+
try:
|
|
115
|
+
evidence = ns.evidence() # before the fixture deletes the namespace
|
|
116
|
+
except HookHappensError as exc:
|
|
117
|
+
report.sections.append(("HookHappens calls", f"(could not read them: {exc})"))
|
|
118
|
+
return report
|
|
119
|
+
if report.failed:
|
|
120
|
+
report.sections.append((f"HookHappens calls (namespace {ns.id})", _calls_text(evidence)))
|
|
121
|
+
if evidence_dir:
|
|
122
|
+
_write_evidence(Path(evidence_dir), item.nodeid, report.outcome, evidence)
|
|
123
|
+
return report
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _calls_text(evidence: dict[str, Any]) -> str:
|
|
127
|
+
calls = evidence["calls"]
|
|
128
|
+
if not calls:
|
|
129
|
+
return f"No calls came in. Was your system pointed at {evidence['url']} ?"
|
|
130
|
+
lines = []
|
|
131
|
+
for c in calls:
|
|
132
|
+
where = f" ({c['endpoint']})" if c.get("endpoint") else ""
|
|
133
|
+
lines.append(f"#{c['id']} {c['method']} {c['path']} -> {c['status']}{where}")
|
|
134
|
+
body = (c.get("request") or {}).get("body")
|
|
135
|
+
if body is not None:
|
|
136
|
+
lines.append(f" sent: {json.dumps(body, ensure_ascii=False)[:300]}")
|
|
137
|
+
answer = (c.get("sent") or {}).get("body")
|
|
138
|
+
if answer is not None:
|
|
139
|
+
lines.append(f" answered: {json.dumps(answer, ensure_ascii=False)[:300]}")
|
|
140
|
+
rules = (c.get("sent") or {}).get("rules")
|
|
141
|
+
if rules:
|
|
142
|
+
lines.append(f" rules: {', '.join(rules)}")
|
|
143
|
+
for issue in c.get("issues") or []:
|
|
144
|
+
lines.append(f" ! {issue['location']}: {issue['message']}")
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _file_name(nodeid: str) -> str:
|
|
149
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", nodeid).strip("_")[:150] + ".json"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _write_evidence(folder: Path, nodeid: str, outcome: str, evidence: dict[str, Any]) -> None:
|
|
153
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
(folder / _file_name(nodeid)).write_text(
|
|
155
|
+
json.dumps({"test": nodeid, "outcome": outcome, **evidence}, indent=2,
|
|
156
|
+
ensure_ascii=False), encoding="utf-8")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def pytest_sessionfinish(session: pytest.Session) -> None:
|
|
160
|
+
folder = session.config.getoption("--hookhappens-evidence")
|
|
161
|
+
if not folder or hasattr(session.config, "workerinput"): # once, not per xdist worker
|
|
162
|
+
return
|
|
163
|
+
path = Path(folder)
|
|
164
|
+
if path.is_dir():
|
|
165
|
+
(path / "index.html").write_text(evidence_index(path), encoding="utf-8")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_PAGE_STYLE = (
|
|
169
|
+
"body{font:14px system-ui;margin:24px}details{margin:6px 0;padding:6px 10px;"
|
|
170
|
+
"border-radius:8px;background:#f4f2fb}details.failed{background:#fde8e8}"
|
|
171
|
+
"pre{background:#fff;padding:6px;border-radius:6px;max-height:240px;overflow:auto}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _call_item(c: dict[str, Any]) -> str:
|
|
175
|
+
e = html.escape
|
|
176
|
+
sent = json.dumps((c.get("request") or {}).get("body"), indent=2, ensure_ascii=False)
|
|
177
|
+
return (f"<li><code>{e(c['method'])} {e(c['path'])}</code> → {c['status']} "
|
|
178
|
+
f"{e(c.get('endpoint') or '')}<pre>{e(sent)}</pre></li>")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _test_block(data: dict[str, Any], file_name: str) -> str:
|
|
182
|
+
e = html.escape
|
|
183
|
+
calls = "".join(_call_item(c) for c in data["calls"]) or "<li>no calls</li>"
|
|
184
|
+
return (f"<details class='{e(data['outcome'])}'><summary><b>{e(data['outcome'])}</b> "
|
|
185
|
+
f"{e(data['test'])} <small>({len(data['calls'])} calls, namespace "
|
|
186
|
+
f"{e(data['namespace'])}) · <a href='{e(file_name)}'>JSON</a></small></summary>"
|
|
187
|
+
f"<ul>{calls}</ul></details>")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def evidence_index(folder: Path) -> str:
|
|
191
|
+
"""One page listing every test's calls (from the JSON files the run wrote)."""
|
|
192
|
+
blocks = [_test_block(json.loads(f.read_text(encoding="utf-8")), f.name)
|
|
193
|
+
for f in sorted(folder.glob("*.json"))]
|
|
194
|
+
return ("<!doctype html><meta charset='utf-8'><title>HookHappens evidence</title>"
|
|
195
|
+
f"<style>{_PAGE_STYLE}</style><h1>HookHappens evidence</h1>" + "".join(blocks))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hookhappens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Python client and pytest plugin for HookHappens, the webhook and API mock for testing at hookhappens.com.
|
|
5
|
+
Project-URL: Homepage, https://hookhappens.com
|
|
6
|
+
Project-URL: Documentation, https://hookhappens.com/#/how-to/testing
|
|
7
|
+
License-Expression: LicenseRef-Proprietary
|
|
8
|
+
Keywords: api,mock,pytest,testing,webhook
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# HookHappens, the Python client
|
|
21
|
+
|
|
22
|
+
**HookHappens** ("Mock around & find out") stands in for the endpoints your system calls, or
|
|
23
|
+
sits in front of the real ones. A test changes exactly the part of the answer it needs, and you
|
|
24
|
+
see what your system did with it. The service lives at **https://hookhappens.com**; this package
|
|
25
|
+
is the Python client and the pytest plugin for it.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install hookhappens
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from hookhappens import HookHappens
|
|
33
|
+
|
|
34
|
+
with HookHappens("https://hookhappens.com", api_key="hh_…") as hh:
|
|
35
|
+
with hh.new_namespace("snack-bar") as run: # a throwaway address for this test run
|
|
36
|
+
run.rule("order-snack", then=[{"status": 503}]) # the kitchen is on fire
|
|
37
|
+
my_app.order("cookie", 3, kitchen=run.url) # point your system at it
|
|
38
|
+
call = run.wait_for("order-snack") # the call your system made, with its answer
|
|
39
|
+
assert call.status == 503
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
With pytest, the `hh` fixture does the same with no setup beyond two environment variables:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export HOOKHAPPENS_URL=https://hookhappens.com
|
|
46
|
+
export HOOKHAPPENS_API_KEY=hh_…
|
|
47
|
+
pytest
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
def test_the_kitchen_on_fire(hh):
|
|
52
|
+
hh.scenario_on("kitchen-on-fire")
|
|
53
|
+
my_app.order("cookie", 3, kitchen=hh.url)
|
|
54
|
+
assert hh.wait_for(endpoint="order-snack").status == 503
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The whole story, with a runnable example app and its test suite, is the **Testing your system**
|
|
58
|
+
guide: https://hookhappens.com/#/how-to/testing. The service is hosted only; nothing to install
|
|
59
|
+
but this client. © 2026 Carolina Shulman Grigoriev. All rights reserved.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hookhappens/__init__.py,sha256=xwopvWUUEBBFKyPVHr_eM8GD6NNbE1vs_oPTc1oTk_8,120
|
|
2
|
+
hookhappens/client.py,sha256=uBuy5NwnhiUAIk2F82NuSXG_1DmGnGRrhMMy0BCor0U,17820
|
|
3
|
+
hookhappens/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
hookhappens/pytest_plugin.py,sha256=ysTut2m-GDv-sRiHg9yMD1hmw44fXs48jfUwfaIstDQ,8147
|
|
5
|
+
hookhappens-0.1.0.dist-info/METADATA,sha256=_2e2iXEUvdHd2R8PUZIAVSFYrWmU54Lmke5ugOo3Bhc,2406
|
|
6
|
+
hookhappens-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
hookhappens-0.1.0.dist-info/entry_points.txt,sha256=eaQgfbts17b_gc_avsutFevd836SuCpUFYugKdafGCI,51
|
|
8
|
+
hookhappens-0.1.0.dist-info/RECORD,,
|