c5643aa28f
CI / compile (pull_request) Successful in 9s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 27s
build / build (push) Successful in 33s
CI / compile (push) Successful in 13s
CI / unit (push) Successful in 33s
CI / integration (push) Successful in 27s
Two hygiene fixes on top of the work-queue OOM bound: Result dumps: cr_results / rr_results / s_results.json were write-only (nothing reads them) yet accumulated EVERY search forever and json.load'd the whole growing file on each write - unbounded RAM and PVC growth, and for a deep search the raw cr_results dump is hundreds of MB. They are now off by default (CONJURER_LIBRARIAN_DEBUG_DUMPS) and, when enabled, are overwritten with just the latest search - never loaded or accumulated. not_in_db.json is untouched: it's a real queue the scraper drains. Search logging: search_bot logged via print(), including a per-line carriage-return progress line that flooded stdout / the log file with millions of entries - fine for a desktop app, unreadable and bloating in a container. All of it is now proper logging at DEBUG (with coarse per-500k-line progress), so a normal run is quiet. The librarian log level is configurable (CONJURER_LIBRARIAN_LOG_LEVEL, default INFO) and a stdout handler is added so stays useful now that the search no longer prints straight to stdout. Set DEBUG for full verbosity. Also: make test_result_delivery_contract hermetic (point the durable spool at a temp dir so it can't pollute or be poisoned by the real result_inbox/ between runs) and gitignore the runtime spool dirs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
4.7 KiB
Python
131 lines
4.7 KiB
Python
"""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": [<title>...], "type": <str>}}}
|
|
(see conjurer_librarian.answer_query -> final_result, POSTed to /conjurer).
|
|
"""
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import communication_subroutine as cs
|
|
from durable_queue import DiskQueue
|
|
|
|
|
|
def _drain(queue):
|
|
while not queue.empty():
|
|
queue.get()
|
|
|
|
|
|
@pytest.fixture
|
|
def comm_threads(tmp_path, monkeypatch):
|
|
# Point the durable spool at a temp dir so /conjurer's dedup/persist can't
|
|
# leak into (or be poisoned by) the real result_inbox/ between runs.
|
|
monkeypatch.setattr(cs, "_inbox", DiskQueue(str(tmp_path / "inbox")))
|
|
monkeypatch.setattr(cs, "_delivered", DiskQueue(str(tmp_path / "delivered")))
|
|
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))
|