import json from fastapi.testclient import TestClient from sqlalchemy import select import services.sales_service.app as sales_module from services.sales_service.automation_worker import SalesAutomationWorker from services.shared.core import new_id, utc_now_iso from services.shared.db import get_session from services.shared.sales_sql_models import ( SalesAutomationTaskRow, SalesDealRow, SalesEscalationRow, SalesInvoiceRow, SalesPipelineStageRow, ) from services.shared.sql_models import EventOutboxRow def _headers(tenant_id: str = "tenant_worker") -> dict[str, str]: return {"X-User": "admin", "X-Role": "admin", "X-Tenant-ID": tenant_id} def _deal_payload(seed: str = "worker", **overrides: object) -> dict: payload = { "stage_id": "new_qualified_lead", "scenario_type": "quick_sale", "priority": 3, "title": f"Automation deal {seed}", "need_summary": "Need automation coverage.", "document_required": False, "preferred_channel": "telegram", "current_channel": "telegram", } payload.update(overrides) return payload def _create_deal(client: TestClient, tenant_id: str = "tenant_worker", **overrides: object) -> dict: response = client.post("/api/v1/deals", json=_deal_payload(tenant_id, **overrides), headers=_headers(tenant_id)) assert response.status_code == 200 return response.json() def _change_stage(client: TestClient, deal_id: str, stage_code: str, tenant_id: str = "tenant_worker") -> dict: response = client.post( f"/api/v1/deals/{deal_id}/change-stage", json={"target_stage_code": stage_code, "reason": f"test.{stage_code}"}, headers=_headers(tenant_id), ) assert response.status_code == 200 return response.json() def _prepare_invoice(client: TestClient, deal_id: str, tenant_id: str = "tenant_worker") -> dict: _change_stage(client, deal_id, "active_text_communication", tenant_id) _change_stage(client, deal_id, "need_confirmed", tenant_id) created = client.post( f"/api/v1/deals/{deal_id}/invoices", json={"amount": 1000, "currency": "KZT", "due_date": "2026-05-15"}, headers=_headers(tenant_id), ) assert created.status_code == 200 sent = client.post(f"/api/v1/invoices/{created.json()['invoice_id']}/send", headers=_headers(tenant_id)) assert sent.status_code == 200 return sent.json() def _create_task( *, tenant_id: str, deal_id: str, task_type: str, payload: dict | None = None, run_at: str | None = None, status: str = "pending", retry_count: int = 0, max_retries: int = 3, ) -> str: session = get_session() try: now = utc_now_iso() row = SalesAutomationTaskRow( task_id=new_id("tsk"), tenant_id=tenant_id, deal_id=deal_id, task_type=task_type, payload_json=json.dumps(payload or {}, ensure_ascii=False), run_at=run_at or now, status=status, retry_count=retry_count, max_retries=max_retries, locked_at=None, locked_by=None, completed_at=None, failed_at=None, last_error=None, created_at=now, updated_at=now, ) session.add(row) session.commit() return row.task_id finally: session.close() def _task(task_id: str) -> SalesAutomationTaskRow: session = get_session() try: return session.execute(select(SalesAutomationTaskRow).where(SalesAutomationTaskRow.task_id == task_id)).scalar_one() finally: session.close() def _deal(deal_id: str, tenant_id: str) -> SalesDealRow: session = get_session() try: return session.execute( select(SalesDealRow).where(SalesDealRow.deal_id == deal_id, SalesDealRow.tenant_id == tenant_id) ).scalar_one() finally: session.close() def _invoice(invoice_id: str, tenant_id: str) -> SalesInvoiceRow: session = get_session() try: return session.execute( select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_id) ).scalar_one() finally: session.close() def _stage_code(deal_id: str, tenant_id: str) -> str: session = get_session() try: deal = session.execute( select(SalesDealRow).where(SalesDealRow.deal_id == deal_id, SalesDealRow.tenant_id == tenant_id) ).scalar_one() stage = session.execute( select(SalesPipelineStageRow).where( SalesPipelineStageRow.stage_id == deal.stage_id, SalesPipelineStageRow.tenant_id == tenant_id, ) ).scalar_one() return stage.code finally: session.close() def _events(event_type: str, tenant_id: str) -> list[dict]: session = get_session() try: rows = session.execute( select(EventOutboxRow) .where(EventOutboxRow.producer_service == "sales-service", EventOutboxRow.event_type == event_type) .order_by(EventOutboxRow.id.asc()) ).scalars().all() payloads = [] for row in rows: envelope = json.loads(row.payload_json or "{}") payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {} if payload.get("tenant_id") == tenant_id: payloads.append(payload) return payloads finally: session.close() def test_worker_claims_due_pending_task(): client = TestClient(sales_module.app) deal = _create_deal(client) task_id = _create_task(tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="follow_up_customer") worker = SalesAutomationWorker(worker_id="worker-claim") session = get_session() try: claimed = worker.claim_pending_tasks(session) assert [task.task_id for task in claimed] == [task_id] assert claimed[0].status == "running" assert claimed[0].locked_by == "worker-claim" finally: session.rollback() session.close() def test_worker_does_not_claim_future_task(): client = TestClient(sales_module.app) deal = _create_deal(client) _create_task( tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="follow_up_customer", run_at="2999-01-01T00:00:00+00:00", ) worker = SalesAutomationWorker(worker_id="worker-future") session = get_session() try: assert worker.claim_pending_tasks(session) == [] finally: session.rollback() session.close() def test_worker_marks_task_completed_on_success(): client = TestClient(sales_module.app) deal = _create_deal(client) task_id = _create_task(tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="follow_up_customer") assert SalesAutomationWorker(worker_id="worker-complete").run_once() == 1 task = _task(task_id) assert task.status == "completed" assert task.completed_at def test_worker_retries_failed_task(): client = TestClient(sales_module.app) deal = _create_deal(client) task_id = _create_task(tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="unknown_task") SalesAutomationWorker(worker_id="worker-retry").run_once() task = _task(task_id) assert task.status == "pending" assert task.retry_count == 1 assert task.last_error def test_worker_marks_task_failed_after_max_retries(): client = TestClient(sales_module.app) deal = _create_deal(client) task_id = _create_task( tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="unknown_task", max_retries=1, ) SalesAutomationWorker(worker_id="worker-failed").run_once() task = _task(task_id) assert task.status == "failed" assert task.retry_count == 1 assert task.failed_at def test_mark_invoice_overdue_updates_invoice_status(): client = TestClient(sales_module.app) deal = _create_deal(client) invoice = _prepare_invoice(client, deal["deal_id"]) task_id = _create_task( tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="mark_invoice_overdue", payload={"invoice_id": invoice["invoice_id"]}, ) SalesAutomationWorker(worker_id="worker-overdue").run_once() assert _task(task_id).status == "completed" assert _invoice(invoice["invoice_id"], "tenant_worker").status == "overdue" def test_mark_invoice_overdue_transitions_deal_via_state_machine(): client = TestClient(sales_module.app) deal = _create_deal(client, "tenant_worker_stage") invoice = _prepare_invoice(client, deal["deal_id"], "tenant_worker_stage") _create_task( tenant_id="tenant_worker_stage", deal_id=deal["deal_id"], task_type="mark_invoice_overdue", payload={"invoice_id": invoice["invoice_id"]}, ) SalesAutomationWorker(worker_id="worker-overdue-stage").run_once() assert _stage_code(deal["deal_id"], "tenant_worker_stage") == "payment_overdue" assert _events("deal.stage_changed", "tenant_worker_stage")[-1]["to_stage_code"] == "payment_overdue" def test_send_invoice_reminder_creates_follow_up(): client = TestClient(sales_module.app) deal = _create_deal(client, "tenant_worker_reminder") invoice = _prepare_invoice(client, deal["deal_id"], "tenant_worker_reminder") _create_task( tenant_id="tenant_worker_reminder", deal_id=deal["deal_id"], task_type="send_invoice_reminder", payload={"invoice_id": invoice["invoice_id"]}, ) SalesAutomationWorker(worker_id="worker-reminder").run_once() session = get_session() try: follow_up = session.execute( select(SalesAutomationTaskRow).where( SalesAutomationTaskRow.tenant_id == "tenant_worker_reminder", SalesAutomationTaskRow.deal_id == deal["deal_id"], SalesAutomationTaskRow.task_type == "follow_up_customer", ) ).scalars().all() assert len(follow_up) == 1 finally: session.close() def test_follow_up_customer_updates_next_action(): client = TestClient(sales_module.app) deal = _create_deal(client, "tenant_worker_follow") _create_task(tenant_id="tenant_worker_follow", deal_id=deal["deal_id"], task_type="follow_up_customer") SalesAutomationWorker(worker_id="worker-follow").run_once() assert _deal(deal["deal_id"], "tenant_worker_follow").next_action_type == "follow_up_customer" def test_recommend_channel_switch_records_recommendation(): client = TestClient(sales_module.app) deal = _create_deal(client, "tenant_worker_switch") task_id = _create_task( tenant_id="tenant_worker_switch", deal_id=deal["deal_id"], task_type="recommend_channel_switch", payload={"recommended_from_channel": "text", "recommended_to_channel": "voice", "reason": "no_reply_in_text"}, ) SalesAutomationWorker(worker_id="worker-switch").run_once() payload = json.loads(_task(task_id).payload_json) assert payload["automation_result"]["recommended_to_channel"] == "voice" assert _events("deal.channel_switch_recommended", "tenant_worker_switch") def test_escalate_to_human_creates_escalation(): client = TestClient(sales_module.app) deal = _create_deal(client, "tenant_worker_escalation") _create_task( tenant_id="tenant_worker_escalation", deal_id=deal["deal_id"], task_type="escalate_to_human", payload={"reason": "low_ai_confidence", "assigned_to_user_id": "operator-1"}, ) SalesAutomationWorker(worker_id="worker-escalation").run_once() session = get_session() try: rows = session.execute( select(SalesEscalationRow).where( SalesEscalationRow.tenant_id == "tenant_worker_escalation", SalesEscalationRow.deal_id == deal["deal_id"], ) ).scalars().all() assert len(rows) == 1 assert rows[0].assigned_to_user_id == "operator-1" finally: session.close() def test_escalate_to_human_transitions_deal_to_support(): client = TestClient(sales_module.app) deal = _create_deal(client, "tenant_worker_support") _create_task( tenant_id="tenant_worker_support", deal_id=deal["deal_id"], task_type="escalate_to_human", payload={"reason": "customer_requested_human"}, ) SalesAutomationWorker(worker_id="worker-support").run_once() assert _stage_code(deal["deal_id"], "tenant_worker_support") == "transferred_to_support" def test_post_sale_transfer_publishes_event(): client = TestClient(sales_module.app) tenant_id = "tenant_worker_post_sale" deal = _create_deal(client, tenant_id) invoice = _prepare_invoice(client, deal["deal_id"], tenant_id) payment = client.post( "/api/v1/payments/webhook", json={ "deal_id": deal["deal_id"], "invoice_id": invoice["invoice_id"], "payment_provider": "manual", "external_payment_id": "worker-paid-1", "amount": 1000, "currency": "KZT", "status": "success", }, headers=_headers(tenant_id), ) assert payment.status_code == 200 _create_task(tenant_id=tenant_id, deal_id=deal["deal_id"], task_type="post_sale_transfer") SalesAutomationWorker(worker_id="worker-post-sale").run_once() assert _events("deal.transferred_post_sale", tenant_id) assert _stage_code(deal["deal_id"], tenant_id) == "transferred_to_execution" def test_worker_is_tenant_scoped(): client = TestClient(sales_module.app) deal_a = _create_deal(client, "tenant_worker_a") deal_b = _create_deal(client, "tenant_worker_b") invoice_b = _prepare_invoice(client, deal_b["deal_id"], "tenant_worker_b") task_id = _create_task( tenant_id="tenant_worker_a", deal_id=deal_a["deal_id"], task_type="mark_invoice_overdue", payload={"invoice_id": invoice_b["invoice_id"]}, ) SalesAutomationWorker(worker_id="worker-tenant").run_once() assert _task(task_id).status == "pending" assert _invoice(invoice_b["invoice_id"], "tenant_worker_b").status == "sent" def test_worker_is_idempotent_for_completed_task(): client = TestClient(sales_module.app) deal = _create_deal(client, "tenant_worker_done") task_id = _create_task( tenant_id="tenant_worker_done", deal_id=deal["deal_id"], task_type="follow_up_customer", status="completed", ) assert SalesAutomationWorker(worker_id="worker-done").run_once() == 0 task = _task(task_id) assert task.status == "completed" assert _events("deal.follow_up_ready", "tenant_worker_done") == []