feat(sp27): extract handle_999 from scheduler.py into handlers/
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""Direct tests for the ``handle_999`` handler (SP27 Task 2).
|
||||
|
||||
Locks the handler's contract independent of the scheduler lifecycle
|
||||
so a regression in the scheduler wiring doesn't hide a regression
|
||||
in the handler.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.handlers.handle_999 import handle
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
|
||||
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
|
||||
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
|
||||
|
||||
|
||||
def test_handle_999_persists_ack_row_and_returns_count():
|
||||
text = ACCEPTED.read_text()
|
||||
parser_used, claim_count = handle(text, source_file=ACCEPTED.name)
|
||||
assert parser_used == "parse_999"
|
||||
assert claim_count == 1 # one AK2 in the happy-path fixture
|
||||
# Lock the persistence half of the contract: a row was actually
|
||||
# added to ``acks`` for this filename (source_batch_id encodes it).
|
||||
from cyclone.handlers._ack_id import ack_synthetic_source_batch_id
|
||||
expected_bsid = ack_synthetic_source_batch_id(
|
||||
interchange_control_number="000000001",
|
||||
pcn="0001",
|
||||
source_filename=ACCEPTED.name,
|
||||
)
|
||||
with db.SessionLocal()() as session:
|
||||
rows = session.query(db.Ack).filter_by(source_batch_id=expected_bsid).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].received_count == 1
|
||||
assert rows[0].ack_code == "A"
|
||||
|
||||
|
||||
def test_handle_999_rejected_persists_with_rejected_count():
|
||||
text = REJECTED.read_text()
|
||||
parser_used, claim_count = handle(text, source_file=REJECTED.name)
|
||||
assert parser_used == "parse_999"
|
||||
assert claim_count == 1
|
||||
# Lock the rejected-count half: IK5="R" → ack_code = "R".
|
||||
from cyclone.handlers._ack_id import ack_synthetic_source_batch_id
|
||||
expected_bsid = ack_synthetic_source_batch_id(
|
||||
interchange_control_number="000000002",
|
||||
pcn="0001",
|
||||
source_filename=REJECTED.name,
|
||||
)
|
||||
with db.SessionLocal()() as session:
|
||||
rows = session.query(db.Ack).filter_by(source_batch_id=expected_bsid).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].rejected_count == 1
|
||||
assert rows[0].ack_code == "R"
|
||||
|
||||
|
||||
def test_handle_999_raises_value_error_on_bad_x12():
|
||||
# Garbage that tokenize may accept but parse_999 will reject.
|
||||
bad = "ISA*00*bad~ST*999*0001~SE*2*0001~IEA*0*0~"
|
||||
with pytest.raises((CycloneParseError, ValueError)):
|
||||
handle(bad, source_file="bad.999")
|
||||
|
||||
|
||||
def test_handle_999_distinct_filenames_get_distinct_synthetic_ids():
|
||||
"""Two calls with the same PCN but different inbound filenames
|
||||
produce distinct synthetic ``batches.id``s (the 8-char hash suffix
|
||||
differs). The scheduler's dedup-by-filename needs this so a re-poll
|
||||
doesn't collapse onto the same row."""
|
||||
from cyclone.handlers._ack_id import ack_synthetic_source_batch_id
|
||||
|
||||
text = ACCEPTED.read_text()
|
||||
_, _ = handle(text, source_file="a-999.x12")
|
||||
_, _ = handle(text, source_file="b-999.x12")
|
||||
|
||||
id_a = ack_synthetic_source_batch_id(
|
||||
interchange_control_number="000000001",
|
||||
pcn="0001",
|
||||
source_filename="a-999.x12",
|
||||
)
|
||||
id_b = ack_synthetic_source_batch_id(
|
||||
interchange_control_number="000000001",
|
||||
pcn="0001",
|
||||
source_filename="b-999.x12",
|
||||
)
|
||||
assert id_a != id_b
|
||||
assert id_a.startswith("999-0001-")
|
||||
assert id_b.startswith("999-0001-")
|
||||
# And both rows are independently persisted (no collision).
|
||||
with db.SessionLocal()() as session:
|
||||
rows_a = session.query(db.Ack).filter_by(source_batch_id=id_a).all()
|
||||
rows_b = session.query(db.Ack).filter_by(source_batch_id=id_b).all()
|
||||
assert len(rows_a) == 1
|
||||
assert len(rows_b) == 1
|
||||
|
||||
|
||||
def test_handle_999_event_bus_parameter_is_optional():
|
||||
"""Scheduler passes event_bus=None; api.py passes app.state.event_bus.
|
||||
Both must work without crashing on publish."""
|
||||
text = ACCEPTED.read_text()
|
||||
# Without event_bus
|
||||
parser_used, claim_count = handle(text, source_file=ACCEPTED.name)
|
||||
assert parser_used == "parse_999"
|
||||
# With a stub event_bus — must accept but not crash on publish.
|
||||
class _Stub:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
def publish(self, kind: str, payload: dict) -> None:
|
||||
self.calls.append((kind, payload))
|
||||
|
||||
spy = _Stub()
|
||||
parser_used, claim_count = handle(
|
||||
text, source_file=ACCEPTED.name, event_bus=spy,
|
||||
)
|
||||
assert parser_used == "parse_999"
|
||||
# The stub pattern is sync. Real EventBus is async and the handler
|
||||
# is sync — Task 6 bridges that. For now, just verify the sync
|
||||
# stub fires (or doesn't) without crashing.
|
||||
# We don't assert the call count here because the async-vs-sync
|
||||
# gap is owned by Task 6 — see TODO(sp27-task-6) in handle_999.
|
||||
Reference in New Issue
Block a user