from scenarios.arbitration import ARB_FEE_PER_SIDE, ArbitrationOutcome from scenarios.case_generator import generate_task from scenarios.issuer_model import IssuerDecision, IssuerReview from core.models import ChargebackOpsAction from server.chargeback_ops_environment import ChargebackOpsEnvironment class _ScriptedIssuer: """Deterministic Issuer stub that returns planned decisions in sequence. Lets round-2 env tests exercise the full dispute lifecycle without depending on the exact thresholds in IssuerAgent — the scoring math is already pinned in tests/test_issuer.py. """ def __init__(self, decisions: list[IssuerDecision]): self._decisions = list(decisions) self.calls: list[int] = [] def decide_review(self, case, progress, round_number): self.calls.append(round_number) idx = min(len(self.calls) - 1, len(self._decisions) - 1) decision = self._decisions[idx] return IssuerReview( decision=decision, evidence_strength_score=0.5, rationale=f"stub decision {decision.value} at r{round_number}", ) def _drive_case_into_round_2(env: ChargebackOpsEnvironment) -> None: """Select CB-E1, attach required evidence, submit — with Issuer stub that requests more evidence on round 1. Leaves env in round-2 state. """ env.step(ChargebackOpsAction(action_type="select_case", case_id="CB-E1")) env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-E1", system_name="orders" ) ) env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-E1", system_name="shipping" ) ) env.step( ChargebackOpsAction( action_type="add_evidence", case_id="CB-E1", evidence_ids=["E1-ORDER-CONF", "E1-DELIVERY-SCAN"], ) ) env.step( ChargebackOpsAction( action_type="set_strategy", case_id="CB-E1", strategy="contest" ) ) env.step( ChargebackOpsAction(action_type="submit_representment", case_id="CB-E1") ) def test_reset_returns_task_observation(): env = ChargebackOpsEnvironment() obs = env.reset(task_id="goods_not_received_easy") assert obs.task_id == "goods_not_received_easy" assert obs.steps_remaining == 10 assert len(obs.queue) == 1 assert obs.queue[0].transaction_id.startswith("txn_") assert obs.queue[0].merchant_mcc.isdigit() assert obs.queue[0].masked_card.startswith("****") def test_reset_accepts_curriculum_difficulty(): env = ChargebackOpsEnvironment() obs = env.reset(difficulty="hard", seed=7) assert obs.task_id == "generated_hard_s7" assert obs.difficulty == "hard" assert len(obs.queue) >= 2 def test_easy_case_can_be_won(): env = ChargebackOpsEnvironment() env.reset(task_id="goods_not_received_easy") env.step(ChargebackOpsAction(action_type="select_case", case_id="CB-E1")) env.step(ChargebackOpsAction(action_type="inspect_case", case_id="CB-E1")) env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-E1", system_name="orders", ) ) env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-E1", system_name="shipping", ) ) env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-E1", system_name="support", ) ) env.step( ChargebackOpsAction( action_type="add_evidence", case_id="CB-E1", evidence_ids=[ "E1-ORDER-CONF", "E1-DELIVERY-SCAN", "E1-SIGNATURE", "E1-SUPPORT-ACK", ], ) ) env.step( ChargebackOpsAction( action_type="set_strategy", case_id="CB-E1", strategy="contest", ) ) obs = env.step( ChargebackOpsAction( action_type="submit_representment", case_id="CB-E1", ) ) assert obs.done is True assert obs.grader_report is not None assert obs.grader_report.normalized_score > 0.8 def test_generated_task_reproducibility(): """Same seed must produce identical cases.""" t1 = generate_task(99, difficulty="medium") t2 = generate_task(99, difficulty="medium") assert t1.task_id == t2.task_id assert len(t1.cases) == len(t2.cases) for c1, c2 in zip(t1.cases, t2.cases): assert c1.case_id == c2.case_id assert c1.amount == c2.amount assert c1.optimal_strategy == c2.optimal_strategy def test_generated_task_runs_in_environment(): """A generated task should reset and accept at least one step.""" env = ChargebackOpsEnvironment() obs = env.reset(task_id="generated_easy_s7") assert obs.task_id == "generated_easy_s7" assert len(obs.queue) >= 1 case_id = obs.queue[0].case_id obs = env.step(ChargebackOpsAction(action_type="select_case", case_id=case_id)) assert obs.selected_case_id == case_id assert obs.visible_case is not None assert obs.visible_case.transaction_timestamp.endswith("Z") assert "episode_metrics" in obs.info def test_marathon_task_has_wave_arrivals_and_wait_action(): env = ChargebackOpsEnvironment() obs = env.reset(task_id="monthly_dispute_backlog_marathon") assert obs.task_id == "monthly_dispute_backlog_marathon" assert obs.steps_remaining == 60 assert len(obs.queue) == 4 assert obs.info["episode_metrics"]["future_case_count"] == 8.0 # Resolve the urgent refund cases that are initially visible enough to # leave the environment waiting on future arrivals or async work later. assert "wait_for_updates" not in obs.available_actions def test_marathon_delayed_evidence_and_issuer_review(): env = ChargebackOpsEnvironment() obs = env.reset(task_id="monthly_dispute_backlog_marathon") obs = env.step(ChargebackOpsAction(action_type="select_case", case_id="CB-L02")) obs = env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-L02", system_name="orders" ) ) obs = env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-L02", system_name="shipping" ) ) assert "delayed shipping evidence" in obs.last_action_result.lower() assert obs.info["pending_evidence_systems"] == ["shipping"] obs = env.step(ChargebackOpsAction(action_type="wait_for_updates")) obs = env.step(ChargebackOpsAction(action_type="wait_for_updates")) assert "Delayed shipping evidence arrived" in obs.last_action_result assert obs.visible_case is not None retrieved_ids = {item.evidence_id for item in obs.visible_case.retrieved_evidence} assert any(eid.endswith("E1-DELIVERY-SCAN") for eid in retrieved_ids) delivery_ids = sorted( eid for eid in retrieved_ids if eid.endswith("E1-ORDER-CONF") or eid.endswith("E1-DELIVERY-SCAN") ) obs = env.step( ChargebackOpsAction( action_type="add_evidence", case_id="CB-L02", evidence_ids=delivery_ids, ) ) obs = env.step( ChargebackOpsAction( action_type="set_strategy", case_id="CB-L02", strategy="contest" ) ) obs = env.step( ChargebackOpsAction(action_type="submit_representment", case_id="CB-L02") ) assert obs.visible_case is not None assert obs.visible_case.status == "pending_issuer_review" assert obs.info["episode_metrics"]["pending_issuer_reviews"] == 1.0 obs = env.step(ChargebackOpsAction(action_type="wait_for_updates")) obs = env.step(ChargebackOpsAction(action_type="wait_for_updates")) obs = env.step(ChargebackOpsAction(action_type="wait_for_updates")) assert "Issuer" in obs.last_action_result assert obs.info["episode_metrics"]["pending_issuer_reviews"] == 0.0 def test_generated_task_covers_all_reason_codes(): """Generator should produce all 6 reason code families across seeds.""" seen_codes: set[str] = set() for seed in range(50): for diff in ("easy", "medium", "hard"): t = generate_task(seed, difficulty=diff) for c in t.cases: seen_codes.add(c.reason_code) expected = { "goods_not_received", "fraud_cnp", "credit_not_processed", "duplicate_processing", "product_not_as_described", "service_not_provided", } assert expected.issubset(seen_codes), f"Missing: {expected - seen_codes}" # --------------------------------------------------------------------------- # multi-round dispute lifecycle # --------------------------------------------------------------------------- def test_pre_arb_available_actions_exclude_submit_representment(): env = ChargebackOpsEnvironment() env.reset(task_id="goods_not_received_easy") env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE]) _drive_case_into_round_2(env) actions = env._build_available_actions() assert "submit_representment" not in actions assert "respond_to_pre_arb" in actions assert "escalate_to_arbitration" in actions assert "accept_arbitration_loss" in actions def test_full_three_round_cycle_ending_in_arbitration(): env = ChargebackOpsEnvironment() env.reset(task_id="goods_not_received_easy") env._issuer_agent = _ScriptedIssuer( [ IssuerDecision.REQUEST_MORE_EVIDENCE, IssuerDecision.ESCALATE_TO_ARBITRATION, ] ) _drive_case_into_round_2(env) env.step( ChargebackOpsAction( action_type="query_system", case_id="CB-E1", system_name="support" ) ) obs = env.step( ChargebackOpsAction( action_type="respond_to_pre_arb", case_id="CB-E1", compelling_evidence_ids=["E1-SIGNATURE", "E1-SUPPORT-ACK"], note="Added signature delivery proof and support ack for pre-arb.", ) ) progress = env._progress_by_case["CB-E1"] assert progress.round_number == 3 assert progress.arbitration_outcome == ArbitrationOutcome.MERCHANT_WINS.value assert progress.arb_fees_paid == ARB_FEE_PER_SIDE assert progress.final_economic_outcome == progress.final_economic_outcome assert progress.final_economic_outcome is not None assert progress.resolution_status == "won_arbitration" assert obs.done is True assert "arbitration" in obs.last_action_result.lower() def test_respond_to_pre_arb_accepted_skips_arbitration(): """If the Issuer accepts in round 2, no arbitration fee is charged and the merchant keeps the full dispute amount.""" env = ChargebackOpsEnvironment() env.reset(task_id="goods_not_received_easy") env._issuer_agent = _ScriptedIssuer( [IssuerDecision.REQUEST_MORE_EVIDENCE, IssuerDecision.ACCEPT] ) _drive_case_into_round_2(env) env.step( ChargebackOpsAction( action_type="respond_to_pre_arb", case_id="CB-E1", compelling_evidence_ids=["E1-SIGNATURE"], ) ) progress = env._progress_by_case["CB-E1"] assert progress.resolution_status == "won_pre_arb" assert progress.arb_fees_paid == 0.0 assert progress.arbitration_outcome is None case = env._lookup_case("CB-E1") assert progress.final_economic_outcome == case.amount def test_accept_arbitration_loss_skips_fees(): """Conceding pre-arb forfeits the dispute amount but avoids the $250 fee.""" env = ChargebackOpsEnvironment() env.reset(task_id="goods_not_received_easy") env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE]) _drive_case_into_round_2(env) env.step( ChargebackOpsAction( action_type="accept_arbitration_loss", case_id="CB-E1" ) ) progress = env._progress_by_case["CB-E1"] case = env._lookup_case("CB-E1") assert progress.resolution_status == "conceded_pre_arb" assert progress.arb_fees_paid == 0.0 assert progress.arbitration_outcome is None assert progress.final_economic_outcome == -case.amount def test_escalate_to_arbitration_from_round_2(): """Merchant can voluntarily file for arbitration from round 2.""" env = ChargebackOpsEnvironment() env.reset(task_id="goods_not_received_easy") env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE]) _drive_case_into_round_2(env) env.step( ChargebackOpsAction( action_type="escalate_to_arbitration", case_id="CB-E1" ) ) progress = env._progress_by_case["CB-E1"] case = env._lookup_case("CB-E1") assert progress.round_number == 3 assert progress.arb_fees_paid == ARB_FEE_PER_SIDE assert progress.arbitration_outcome in { ArbitrationOutcome.MERCHANT_WINS.value, ArbitrationOutcome.ISSUER_WINS.value, } if progress.arbitration_outcome == ArbitrationOutcome.MERCHANT_WINS.value: assert progress.final_economic_outcome == case.amount - ARB_FEE_PER_SIDE else: assert progress.final_economic_outcome == -case.amount - ARB_FEE_PER_SIDE