Add a dev memo-forwarding fixture and deduplicated intake notifications.
Pose, replay, and conflict stay on an injected transport with local retry evidence. Notification receipts keep only memo id, question, class, and submitter, and a resolve conflict stays readable. Assistant: grok Assistant-Session: 01a10329-31cb-78d3-b7a3-789e1fb83139
This commit is contained in:
parent
42ab047e87
commit
49eeddbfed
10 changed files with 1612 additions and 1 deletions
380
tests/test_memo_forwarding.py
Normal file
380
tests/test_memo_forwarding.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
"""Dev fixture for hub memo forwarding. No live intake, token, or submitter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.informed_decision_contract import (
|
||||
PROVENANCE_NOTE,
|
||||
ContractError,
|
||||
build_envelope,
|
||||
correlate_resolution,
|
||||
resolution_applies,
|
||||
stable_idempotency_key,
|
||||
validate_envelope,
|
||||
)
|
||||
from api.services.memo_forwarding import DevIntakeFixture
|
||||
from api.services.write_idempotency import route_rule_for
|
||||
|
||||
DECISION = "62aa95e4-3ccc-4867-8806-5e3db005b483"
|
||||
MEMO = "infd-20261003-p01"
|
||||
OTHER = "infd-20261003-p02"
|
||||
APPROVAL = "ba5ce2d8-8b6d-40be-af89-2e8c147029a3"
|
||||
BINDING = "sha256:" + "ab" * 32
|
||||
TOKEN = "dev-fixture-not-a-credential"
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1_000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, method, url, body, headers):
|
||||
self.calls.append((method, url, body, dict(headers)))
|
||||
item = self.responses.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
|
||||
def envelope(**overrides):
|
||||
memo = {"id": MEMO, "question": "Accept the forwarded draft?"}
|
||||
packet = [{"item_id": "brief", "media_type": "text/plain", "content": "Bounded brief."}]
|
||||
intent = {
|
||||
"action": "accept",
|
||||
"principal": "pending-human-session",
|
||||
"purpose": "Review the draft.",
|
||||
"target": {"id": MEMO, "type": "decision-memo", "system": "informed-decision"},
|
||||
}
|
||||
fields = dict(
|
||||
decision_id=DECISION,
|
||||
memo=memo,
|
||||
packet=packet,
|
||||
memo_class="work-record-accept",
|
||||
review_group="net-kingdom-admins",
|
||||
approval_intent=intent,
|
||||
drafted_by="claude@the-custodian",
|
||||
resolves="on-answer",
|
||||
)
|
||||
fields.update(overrides)
|
||||
return build_envelope(**fields)
|
||||
|
||||
|
||||
def receipt(memo_id=MEMO):
|
||||
return {
|
||||
"memo_id": memo_id,
|
||||
"approval_id": APPROVAL,
|
||||
"approval_binding_digest": BINDING,
|
||||
"echo": "should-not-be-kept",
|
||||
}
|
||||
|
||||
|
||||
def fixture(tmp_path, transport, clock=None, token=lambda: TOKEN):
|
||||
return DevIntakeFixture(
|
||||
tmp_path / "forward.sqlite",
|
||||
transport=transport,
|
||||
intake_base_url="http://intake.test",
|
||||
hub_base_url="http://hub.test",
|
||||
token_reader=token,
|
||||
clock=clock or Clock(),
|
||||
)
|
||||
|
||||
|
||||
def stored_bytes(path) -> bytes:
|
||||
data = Path(path).read_bytes()
|
||||
for suffix in ("-wal", "-shm"):
|
||||
side = Path(str(path) + suffix)
|
||||
if side.exists():
|
||||
data += side.read_bytes()
|
||||
return data
|
||||
|
||||
|
||||
def test_idempotency_key_is_stable_and_drafted_by_is_not_identity():
|
||||
first = envelope()
|
||||
second = envelope()
|
||||
assert first["idempotency_key"] == second["idempotency_key"]
|
||||
assert first["idempotency_key"] == stable_idempotency_key(DECISION, MEMO)
|
||||
assert "drafted_by" not in first["idempotency_key"]
|
||||
assert "submitter" not in first
|
||||
changed = dict(first)
|
||||
changed["idempotency_key"] = "unstable-key-value"
|
||||
with pytest.raises(ContractError) as error:
|
||||
validate_envelope(changed)
|
||||
assert error.value.code == "idempotency_key_not_stable"
|
||||
|
||||
|
||||
def test_envelope_is_bounded_and_does_not_echo_secrets():
|
||||
huge = envelope()
|
||||
huge["packet"][0]["content"] = "x" * (256 * 1024 + 1)
|
||||
with pytest.raises(ContractError) as too_big:
|
||||
validate_envelope(huge)
|
||||
assert too_big.value.code == "packet_too_large"
|
||||
leaked = envelope()
|
||||
leaked["packet"][0]["content"] = "-----BEGIN PRIVATE KEY-----"
|
||||
with pytest.raises(ContractError) as secret:
|
||||
validate_envelope(leaked)
|
||||
assert secret.value.code == "secret_marker"
|
||||
assert "PRIVATE KEY" not in str(secret.value)
|
||||
extra = envelope()
|
||||
extra["highlights"] = ["not-a-notice"]
|
||||
with pytest.raises(ContractError) as unknown:
|
||||
validate_envelope(extra)
|
||||
assert unknown.value.code == "unknown_field"
|
||||
|
||||
|
||||
def test_pose_replay_conflict_and_asserted_provenance(tmp_path):
|
||||
clock = Clock()
|
||||
transport = Transport([(201, receipt())])
|
||||
fix = fixture(tmp_path, transport, clock)
|
||||
posed = fix.submit(envelope())
|
||||
assert posed.status == "delivered"
|
||||
assert posed.drafted_by == "claude@the-custodian"
|
||||
assert posed.drafted_by_provenance == "asserted"
|
||||
assert posed.provenance_note == PROVENANCE_NOTE
|
||||
assert "asserted provenance" in posed.provenance_note
|
||||
assert set(posed.receipt) == {"memo_id", "approval_id", "approval_binding_digest"}
|
||||
sent = transport.calls[0]
|
||||
assert sent[0:2] == ("POST", "http://intake.test/v1/memos")
|
||||
assert sent[2]["drafted_by"] == "claude@the-custodian"
|
||||
assert "drafted_by_provenance" not in sent[2]
|
||||
assert "submitter" not in sent[2]
|
||||
assert sent[3]["Authorization"] == f"Bearer {TOKEN}"
|
||||
assert TOKEN.encode() not in stored_bytes(fix.path)
|
||||
|
||||
restarted = DevIntakeFixture(
|
||||
fix.path,
|
||||
transport=Transport([(201, receipt())]),
|
||||
intake_base_url="http://intake.test",
|
||||
hub_base_url="http://hub.test",
|
||||
token_reader=lambda: TOKEN,
|
||||
clock=clock,
|
||||
)
|
||||
replay = restarted.submit(envelope())
|
||||
assert replay.status == "replay"
|
||||
assert replay.receipt == posed.receipt
|
||||
assert restarted.transport.calls == []
|
||||
|
||||
conflict = envelope()
|
||||
conflict["packet"][0]["content"] = "Different brief."
|
||||
blocked = restarted.submit(conflict)
|
||||
assert blocked.status == "blocked"
|
||||
assert blocked.detail == "idempotency_conflict"
|
||||
kept = restarted.pose(posed.idempotency_key)
|
||||
assert kept["state"] == "delivered"
|
||||
assert "Different brief." not in kept["envelope_json"]
|
||||
assert restarted.transport.calls == []
|
||||
|
||||
|
||||
def test_delivery_failure_keeps_local_evidence_and_retries(tmp_path):
|
||||
clock = Clock()
|
||||
transport = Transport([
|
||||
ConnectionRefusedError("down"),
|
||||
(503, {"error": "intake_unavailable", "body": "LEAKED-BODY"}),
|
||||
(201, receipt()),
|
||||
])
|
||||
fix = fixture(tmp_path, transport, clock)
|
||||
failed = fix.submit(envelope())
|
||||
assert failed.status == "pending"
|
||||
assert failed.detail == "ConnectionRefusedError"
|
||||
row = fix.pose(failed.idempotency_key)
|
||||
assert row["state"] == "pending"
|
||||
assert "Bounded brief." in row["envelope_json"]
|
||||
assert "LEAKED-BODY" not in row["envelope_json"]
|
||||
assert fix.run_due() is None
|
||||
|
||||
clock.now += 2
|
||||
again = fix.run_due()
|
||||
assert again.status == "pending"
|
||||
assert again.detail == "http_503"
|
||||
assert b"LEAKED-BODY" not in stored_bytes(fix.path)
|
||||
assert fix.run_due() is None
|
||||
|
||||
clock.now += 4
|
||||
delivered = fix.run_due()
|
||||
assert delivered.status == "delivered"
|
||||
assert delivered.receipt["memo_id"] == MEMO
|
||||
assert fix.run_due() is None
|
||||
assert len(transport.calls) == 3
|
||||
|
||||
|
||||
def test_intake_conflict_blocks_without_retry(tmp_path):
|
||||
transport = Transport([(409, {"error": "idempotency_conflict", "packet": "LEAK"})])
|
||||
fix = fixture(tmp_path, transport)
|
||||
blocked = fix.submit(envelope())
|
||||
assert blocked.status == "blocked"
|
||||
assert blocked.detail == "idempotency_conflict"
|
||||
assert b"LEAK" not in stored_bytes(fix.path)
|
||||
assert fix.run_due() is None
|
||||
assert len(transport.calls) == 1
|
||||
|
||||
|
||||
def test_absent_token_is_not_invented(tmp_path):
|
||||
transport = Transport([(201, receipt())])
|
||||
fix = fixture(tmp_path, transport, token=None)
|
||||
pending = fix.submit(envelope())
|
||||
assert pending.status == "pending"
|
||||
assert pending.detail == "projected_token_absent"
|
||||
assert transport.calls == []
|
||||
assert fix.pose(pending.idempotency_key)["state"] == "pending"
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("sk-or-this-must-not-be-stored-0123456789")
|
||||
|
||||
fix.token_reader = boom
|
||||
fix.clock = Clock()
|
||||
fix.clock.now = 1_000 + 2
|
||||
retried = fix.run_due()
|
||||
assert retried.status == "pending"
|
||||
assert retried.detail == "RuntimeError"
|
||||
assert transport.calls == []
|
||||
assert b"sk-or-this-must-not-be-stored" not in stored_bytes(fix.path)
|
||||
|
||||
|
||||
def test_live_hosts_and_runtime_binding_are_refused(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
DevIntakeFixture(
|
||||
tmp_path / "no.sqlite",
|
||||
transport=Transport([]),
|
||||
intake_base_url="http://127.0.0.1:8000",
|
||||
hub_base_url="http://hub.test",
|
||||
)
|
||||
root = Path(__file__).resolve().parents[1] / "api"
|
||||
main = (root / "main.py").read_text()
|
||||
assert "memo_notifications" in main
|
||||
assert "memo_forwarding" not in main
|
||||
assert "DevIntakeFixture" not in main
|
||||
owned = [
|
||||
root / "main.py",
|
||||
root / "services" / "memo_forwarding.py",
|
||||
root / "services" / "informed_decision_contract.py",
|
||||
root / "services" / "memo_notification.py",
|
||||
root / "routers" / "memo_notifications.py",
|
||||
root / "models" / "memo_notification.py",
|
||||
]
|
||||
for path in owned:
|
||||
text = path.read_text()
|
||||
assert "INFD_HUB_WRITEBACK" not in text
|
||||
assert "IntakeDeliveryWorker" not in text
|
||||
assert "/var/run/secrets" not in text
|
||||
assert "token_file" not in text
|
||||
assert route_rule_for("POST", "/informed-decision/notifications") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verb,serves", [
|
||||
("return", {"hub_decision_id": DECISION, "resolves": "on-answer"}),
|
||||
("discuss", {"hub_decision_id": DECISION, "resolves": "on-answer"}),
|
||||
("accept", {"hub_decision_id": DECISION, "resolves": "never"}),
|
||||
("decline", None),
|
||||
])
|
||||
def test_non_terminal_answers_do_not_resolve(tmp_path, verb, serves):
|
||||
assert resolution_applies(verb, serves) is False
|
||||
transport = Transport([(200, {})])
|
||||
fix = fixture(tmp_path, transport)
|
||||
status = fix.observe_resolution(
|
||||
decision_id=DECISION, memo_id=MEMO, verb=verb, serves=serves, payload={"rationale": "no"},
|
||||
)
|
||||
assert status == "not_applicable"
|
||||
assert transport.calls == []
|
||||
|
||||
|
||||
def test_resolution_failure_retries_and_other_memo_blocks(tmp_path):
|
||||
clock = Clock()
|
||||
payload = {
|
||||
"decided_by": "Bernd Worsch via informed-decision",
|
||||
"rationale": f"accept in informed-decision memo {MEMO} v1; ",
|
||||
"write_log": False,
|
||||
}
|
||||
transport = Transport([
|
||||
ConnectionRefusedError("down"),
|
||||
(200, {"status": "resolved"}),
|
||||
(409, {"error": "already resolved"}),
|
||||
(200, {"rationale": "resolved by hand"}),
|
||||
])
|
||||
fix = fixture(tmp_path, transport, clock)
|
||||
failed = fix.observe_resolution(
|
||||
decision_id=DECISION, memo_id=MEMO, verb="accept",
|
||||
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||
)
|
||||
assert failed == "failed"
|
||||
assert fix.resolutions()[0]["payload_json"]
|
||||
assert fix.run_due_resolution() is None
|
||||
clock.now += 2
|
||||
assert fix.run_due_resolution() == "resolved"
|
||||
assert [row["status"] for row in fix.resolutions()] == ["failed", "resolved"]
|
||||
assert fix.run_due_resolution() is None
|
||||
|
||||
conflict = fix.observe_resolution(
|
||||
decision_id=DECISION, memo_id=OTHER, verb="decline",
|
||||
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||
)
|
||||
assert conflict == "conflict"
|
||||
calls = len(transport.calls)
|
||||
assert transport.calls[-2][0] == "POST"
|
||||
assert transport.calls[-1][0] == "GET"
|
||||
assert fix.observe_resolution(
|
||||
decision_id=DECISION, memo_id=OTHER, verb="decline",
|
||||
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||
) == "conflict"
|
||||
assert len(transport.calls) == calls
|
||||
|
||||
|
||||
def test_unreadable_409_stays_retryable(tmp_path):
|
||||
clock = Clock()
|
||||
transport = Transport([
|
||||
(409, {}),
|
||||
(503, {}),
|
||||
(409, {}),
|
||||
(200, {"rationale": f"decline in informed-decision memo {MEMO} v1; "}),
|
||||
])
|
||||
fix = fixture(tmp_path, transport, clock)
|
||||
payload = {"rationale": "kept locally", "decided_by": "reviewer", "write_log": False}
|
||||
assert fix.observe_resolution(
|
||||
decision_id=DECISION, memo_id=MEMO, verb="decline",
|
||||
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||
) == "failed"
|
||||
assert fix.resolutions()[0]["error_type"] == "resolution_unreadable"
|
||||
clock.now += 2
|
||||
assert fix.run_due_resolution() == "already_resolved"
|
||||
|
||||
|
||||
async def test_hub_409_still_returns_the_existing_resolution(client):
|
||||
domain = await client.post("/domains/", json={"slug": "testdomain", "name": "Test Domain"})
|
||||
assert domain.status_code == 201, domain.text
|
||||
topic = await client.post("/topics/", json={
|
||||
"slug": "testtopic", "title": "Test Topic", "domain": "testdomain",
|
||||
})
|
||||
assert topic.status_code == 201, topic.text
|
||||
created = await client.post("/decisions/", json={
|
||||
"title": "Should the draft stand?", "topic_id": topic.json()["id"],
|
||||
})
|
||||
assert created.status_code == 201, created.text
|
||||
decision_id = created.json()["id"]
|
||||
rationale = f"accept in informed-decision memo {MEMO} v1; approval {APPROVAL}"
|
||||
resolved = await client.post(f"/decisions/{decision_id}/resolve", json={
|
||||
"rationale": rationale,
|
||||
"decided_by": "Bernd Worsch via informed-decision",
|
||||
"write_log": False,
|
||||
})
|
||||
assert resolved.status_code == 200, resolved.text
|
||||
conflict = await client.post(f"/decisions/{decision_id}/resolve", json={
|
||||
"rationale": "resolved by hand",
|
||||
"decided_by": "someone else",
|
||||
"write_log": False,
|
||||
})
|
||||
assert conflict.status_code == 409
|
||||
current = await client.get(f"/decisions/{decision_id}")
|
||||
assert current.status_code == 200
|
||||
body = current.json()
|
||||
assert body["rationale"] == rationale
|
||||
assert correlate_resolution(MEMO, post_status=409, existing=body) == "already_resolved"
|
||||
assert correlate_resolution(OTHER, post_status=409, existing=body) == "conflict"
|
||||
152
tests/test_memo_notification.py
Normal file
152
tests/test_memo_notification.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Deduplicated informed-decision notification contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
NOTICE = {
|
||||
"memo_id": "infd-20261003-p01",
|
||||
"question": "Accept the forwarded draft?",
|
||||
"memo_class": "work-record-accept",
|
||||
"submitter": "example-submitter",
|
||||
}
|
||||
KEY = "state-hub.delivery.0001"
|
||||
|
||||
|
||||
async def _messages(client):
|
||||
listed = await client.get("/messages/", params={"to_agent": "bernd"})
|
||||
assert listed.status_code == 200, listed.text
|
||||
return listed.json()
|
||||
|
||||
|
||||
async def test_notification_is_deduplicated_and_limited_to_four_fields(client):
|
||||
first = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=NOTICE,
|
||||
headers={"Idempotency-Key": KEY},
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
body = first.json()
|
||||
assert body["status"] == "accepted"
|
||||
assert body["authoritative_review"] == "signed-in-overview"
|
||||
assert body["delivery_id"] == KEY
|
||||
|
||||
raw = json.dumps({
|
||||
"submitter": NOTICE["submitter"],
|
||||
"memo_class": NOTICE["memo_class"],
|
||||
"question": NOTICE["question"],
|
||||
"memo_id": NOTICE["memo_id"],
|
||||
})
|
||||
second = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
content=raw,
|
||||
headers={"Idempotency-Key": KEY, "Content-Type": "application/json"},
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.headers["x-statehub-idempotency-replay"] == "true"
|
||||
assert second.json()["message_id"] == body["message_id"]
|
||||
|
||||
messages = await _messages(client)
|
||||
assert len(messages) == 1
|
||||
text = messages[0]["body"]
|
||||
assert text.startswith("The signed-in overview is the authoritative review entry point.\n")
|
||||
assert set(json.loads(text.split("\n", 1)[1])) == set(NOTICE)
|
||||
assert "://" not in text
|
||||
assert "packet" not in text
|
||||
assert "highlight" not in text
|
||||
assert messages[0]["from_agent"] == "informed-decision"
|
||||
assert messages[0]["to_agent"] == "bernd"
|
||||
|
||||
|
||||
async def test_conflict_leaves_the_original_receipt_readable(client):
|
||||
created = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=NOTICE,
|
||||
headers={"Idempotency-Key": KEY},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
changed = dict(NOTICE, question="Decline the forwarded draft?")
|
||||
conflict = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=changed,
|
||||
headers={"Idempotency-Key": KEY},
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json() == {"error": "notification_conflict", "delivery_id": KEY}
|
||||
assert "Decline" not in conflict.text
|
||||
current = await client.get(f"/informed-decision/notifications/{KEY}")
|
||||
assert current.status_code == 200
|
||||
assert current.json()["question"] == NOTICE["question"]
|
||||
assert len(await _messages(client)) == 1
|
||||
|
||||
|
||||
async def test_notification_refuses_extra_secret_and_link_without_echo(client):
|
||||
extra = dict(NOTICE, packet="SECRET-PACKET-VALUE")
|
||||
refused = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=extra,
|
||||
headers={"Idempotency-Key": KEY},
|
||||
)
|
||||
assert refused.status_code == 400
|
||||
assert refused.json() == {"error": "unknown_field"}
|
||||
assert "SECRET-PACKET-VALUE" not in refused.text
|
||||
|
||||
secret = dict(NOTICE, question="Is Bearer " + ("A" * 16) + " acceptable?")
|
||||
marked = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=secret,
|
||||
headers={"Idempotency-Key": "state-hub.delivery.0002"},
|
||||
)
|
||||
assert marked.status_code == 400
|
||||
assert marked.json() == {"error": "secret_marker"}
|
||||
assert "AAAA" not in marked.text
|
||||
|
||||
linked = dict(NOTICE, question="Open https://overview.example/review?")
|
||||
link = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=linked,
|
||||
headers={"Idempotency-Key": "state-hub.delivery.0003"},
|
||||
)
|
||||
assert link.status_code == 400
|
||||
assert link.json() == {"error": "notification_has_link"}
|
||||
assert "overview.example" not in link.text
|
||||
assert await _messages(client) == []
|
||||
|
||||
|
||||
async def test_delivery_id_and_size_are_required(client):
|
||||
missing = await client.post("/informed-decision/notifications", json=NOTICE)
|
||||
assert missing.status_code == 400
|
||||
assert missing.json()["error"] == "delivery_id_required"
|
||||
short = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=NOTICE,
|
||||
headers={"Idempotency-Key": "short"},
|
||||
)
|
||||
assert short.status_code == 400
|
||||
assert short.json()["error"] == "delivery_id_invalid"
|
||||
huge = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
content=b"{" + b"x" * 9000,
|
||||
headers={"Idempotency-Key": KEY, "Content-Type": "application/json"},
|
||||
)
|
||||
assert huge.status_code == 413
|
||||
assert huge.json()["error"] == "notification_too_large"
|
||||
assert b"xxx" not in huge.content
|
||||
missing_receipt = await client.get("/informed-decision/notifications/" + KEY)
|
||||
assert missing_receipt.status_code == 404
|
||||
|
||||
|
||||
async def test_distinct_delivery_ids_are_distinct_notices(client):
|
||||
first = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=NOTICE,
|
||||
headers={"Idempotency-Key": "state-hub.delivery.0001"},
|
||||
)
|
||||
second_body = dict(NOTICE, memo_id="infd-20261003-p02", question="Decline the other draft?")
|
||||
second = await client.post(
|
||||
"/informed-decision/notifications",
|
||||
json=second_body,
|
||||
headers={"Idempotency-Key": "state-hub.delivery.0002"},
|
||||
)
|
||||
assert first.status_code == 201 and second.status_code == 201
|
||||
assert first.json()["message_id"] != second.json()["message_id"]
|
||||
assert len(await _messages(client)) == 2
|
||||
Loading…
Add table
Add a link
Reference in a new issue