From 04070ea7f13196ef98d3c3f38ef4bfacb38f689f Mon Sep 17 00:00:00 2001 From: Polish Hammer Date: Sun, 2 Aug 2026 16:11:08 +0200 Subject: [PATCH] tests: pin the librarian->bot result delivery contract Diagnostic coverage for the 'search vanished' report. Proves the bot side of result delivery is correct end to end (right shape reaches IN_COMM_Q; empty result still delivered; wrong api-key -> 401 vanish; uuid mismatch -> orphaned away from the querent), which isolates a SYSTEMATIC vanish to transport: the librarian being unable to reach the bot's /conjurer at all (CONJURER_MAIN_BOT). The bot Service is NodePort with no pinned nodePort while the librarian hardcodes :32442 - and being in-cluster it should use the Service DNS http://bot:5000 instead. Co-Authored-By: Claude Opus 4.8 --- .../test_result_delivery_contract.py | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/integration/test_result_delivery_contract.py diff --git a/tests/integration/test_result_delivery_contract.py b/tests/integration/test_result_delivery_contract.py new file mode 100644 index 0000000..13e474f --- /dev/null +++ b/tests/integration/test_result_delivery_contract.py @@ -0,0 +1,125 @@ +"""Integration: the librarian -> bot RESULT delivery contract. + +A search that 'vanishes' (watchdog fires "zeżarło") means the result never +reached the bot's inbound queue. These tests pin down the contract so we can +tell a CODE break (wrong shape / uuid / auth handling) from a TRANSPORT break +(the librarian can't reach the bot at all - wrong address/port). They prove the +bot side is correct end to end, which isolates a systematic vanish to transport. + +The result the librarian sends is exactly: + {uuid: {DOI: {"Title": [...], "type": <str>}}} +(see conjurer_librarian.answer_query -> final_result, POSTed to /conjurer). +""" +import threading +import time + +import pytest + +import communication_subroutine as cs + + +def _drain(queue): + while not queue.empty(): + queue.get() + + +@pytest.fixture +def comm_threads(): + cs.awaiting_q.clear() + _drain(cs.incoming_q) + _drain(cs.OUT_COMM_Q) + _drain(cs.IN_COMM_Q) + cs.API_KEY = None + stop = threading.Event() + workers = [ + threading.Thread(target=cs.scan_queue, kwargs={"stop_event": stop}, daemon=True), + threading.Thread(target=cs.scan_incoming, kwargs={"stop_event": stop}, daemon=True), + ] + for worker in workers: + worker.start() + yield + stop.set() + for worker in workers: + worker.join(timeout=3) + + +def _dispatch(uuid, query="kwas foliowy"): + """Mimic the bot dispatching a search: a QueryControl enters the comm queue + and scan_queue moves it into awaiting_q.""" + qc = cs.QueryControl("siara", uuid, query, None) + cs.OUT_COMM_Q.put(qc) + deadline = time.time() + 2 + while time.time() < deadline: + if any(getattr(r, "uuid", None) == uuid for r in list(cs.awaiting_q)): + return qc + time.sleep(0.01) + raise AssertionError("scan_queue never moved the query into awaiting_q") + + +# The exact result the librarian emits for one found DOI. +def _result_payload(uuid): + return {uuid: {"10.1000/xyz": {"Title": ["A Real Paper"], "type": "journal-article"}}} + + +def test_librarian_result_reaches_bot_when_transport_is_fine(comm_threads): + _dispatch("uuid-ok") + client = cs.app.test_client() + + resp = client.post("/conjurer", json=_result_payload("uuid-ok")) + + assert resp.status_code == 200 + got = cs.IN_COMM_Q.get(timeout=3) + assert got.uuid == "uuid-ok" + assert got.stop is True + # Exactly the shape check_data_q renders: entries[DOI]["Title"][0] / ["type"]. + assert got.entries == {"10.1000/xyz": {"Title": ["A Real Paper"], "type": "journal-article"}} + + +def test_empty_result_is_still_delivered_not_vanished(comm_threads): + # A search that found nothing sends {uuid: {}} - it must STILL be delivered + # (renders "niestety nie ma nic"), never look like a lost result. + _dispatch("uuid-empty") + client = cs.app.test_client() + + resp = client.post("/conjurer", json={"uuid-empty": {}}) + + assert resp.status_code == 200 + got = cs.IN_COMM_Q.get(timeout=3) + assert got.uuid == "uuid-empty" + assert got.entries == {} + + +def test_wrong_api_key_rejects_result_so_it_vanishes(comm_threads): + # (b) reproduction: if the librarian's CONJURER_API_KEY differs from the + # bot's, /conjurer returns 401 and the result is never queued - the search + # silently vanishes exactly as reported. + cs.API_KEY = "bot-secret" + _dispatch("uuid-auth") + client = cs.app.test_client() + + resp = client.post( + "/conjurer", + json=_result_payload("uuid-auth"), + headers={"X-Conjurer-Api-Key": "librarian-DIFFERENT-key"}, + ) + + assert resp.status_code == 401 + time.sleep(0.4) + assert cs.IN_COMM_Q.empty() # nothing delivered + + +def test_uuid_mismatch_orphans_result_away_from_the_querent(comm_threads): + # (b) reproduction: if the uuid the librarian echoes back doesn't byte-match + # what the bot stored, scan_incoming can't match it -> it goes to the orphan + # path (posted to the fallback channel, NOT the querent) and the querent's + # pending entry is never cleared, so the watchdog still flags it lost. + _dispatch("uuid-stored") + client = cs.app.test_client() + + client.post("/conjurer", json=_result_payload("uuid-DIFFERENT")) + + got = cs.IN_COMM_Q.get(timeout=3) + assert got.author == "Orphaned" + assert got.uuid == "uuid-DIFFERENT" + # The original querent's record is untouched (still awaiting) - it "vanished". + assert any(getattr(r, "uuid", None) == "uuid-stored" for r in list(cs.awaiting_q)) -- 2.52.0