"""Offline smoke test for the Bale bot — no Bale network, no postgres, no AI keys. It stubs the Bale client, the ERP API client, STT and the NLU module, replaces the draft store with an in-memory fake, then drives the real handler pipeline end-to-end through fake updates (text, voice, inline-button callbacks). Run inside the bot container (or anywhere the requirements are installed): python tools/smoke_test.py Exit code 0 = all checks passed. """ import asyncio import os import sys import types from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # asyncpg is only needed when a real connection opens; stub it so app.db imports. try: import asyncpg # noqa: F401 except ImportError: sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg")) CHAT = 100200300 _passed = _failed = 0 def check(name: str, ok: bool, detail: str = "") -> None: global _passed, _failed ok = bool(ok) mark = "PASS" if ok else "FAIL" print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else "")) _passed += ok _failed += (not ok) # --------------------------------------------------------------------------- # fakes # --------------------------------------------------------------------------- class FakeBale: def __init__(self): self.sent: list[dict] = [] self.edited: list[dict] = [] self.answered: list[str] = [] self._mid = 0 async def send_message(self, chat_id, text, reply_markup=None, reply_to=None): self._mid += 1 self.sent.append({"chat_id": chat_id, "text": text, "reply_markup": reply_markup}) return {"message_id": self._mid, "chat": {"id": chat_id}} async def edit_message_text(self, chat_id, message_id, text, reply_markup=None): self.edited.append({"chat_id": chat_id, "message_id": message_id, "text": text, "reply_markup": reply_markup}) return {"message_id": message_id} async def answer_callback_query(self, callback_query_id, text=None): self.answered.append(callback_query_id) async def send_chat_action(self, chat_id, action="typing"): pass async def download_file(self, file_id): return b"fake-ogg-bytes" def last_text(self) -> str: return self.sent[-1]["text"] if self.sent else "" def last_markup(self) -> dict | None: return self.sent[-1]["reply_markup"] if self.sent else None class FakeApi: """Canned ERP responses + captured mutation payloads.""" def __init__(self): self.created_quotes: list[dict] = [] self.created_suppliers: list[dict] = [] self.created_materials: list[dict] = [] self.accept_payloads: list[dict] = [] self.arrive_payloads: list[dict] = [] self.login_calls: list[dict] = [] async def login(self, username, password): self.login_calls.append({"username": username, "password": password}) if username == "mohaghegh" and password == "secret": return {"access_token": "jwt", "user": {"id": "user-1", "first_name": "علی", "last_name": "محقق", "role": "procurement_manager"}} raise RuntimeError("invalid credentials") async def list_suppliers(self, q=None, page_size=20): return [{"id": "sup-1", "name": "سازان نو", "category_id": "cat-2"}] async def create_supplier(self, payload, actor_id=None): self.created_suppliers.append(payload) return {"id": "sup-new", "name": payload["name"], "category": {"id": payload["category_id"], "name": "لوله و اتصالات"}} async def list_materials(self, q=None, page_size=20): return [ {"id": "mat-1", "name": "میلگرد A3 سایز ۱۴", "canonical_unit_id": 3}, {"id": "mat-2", "name": "میلگرد A3 سایز ۱۶", "canonical_unit_id": 3}, {"id": "mat-3", "name": "شیر فلکه چهارطرفه", "canonical_unit_id": 2}, ] async def create_material(self, payload, actor_id=None): self.created_materials.append(payload) return {"id": "mat-new", "name": payload["name"]} async def list_units(self, q=None, page_size=100): return [{"id": 2, "code": "adad", "name_fa": "عدد"}, {"id": 3, "code": "kg", "name_fa": "کیلوگرم"}] async def list_projects(self, page_size=100): return [{"id": "prj-1", "name": "برج آسمان"}] async def list_categories(self, page_size=100): return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"}, {"id": "cat-2", "name": "لوله و اتصالات"}] def _expanded(self, quote_id, status): """Realistic GET /quotes?expand=line_items,termsheets,supplier shape.""" return { "id": quote_id, "supplier": {"name": "سازان نو"}, "project_id": None, "status": status, "quote_date": "2026-09-10T00:00:00Z", "expiry_date": "2026-09-20", "line_items": [ {"id": "li-1", "material": {"name": "میلگرد A3 سایز ۱۴"}, "unit": {"name_fa": "کیلوگرم"}, "quantity": 500, "unit_price": 45000, "projects": [], "decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None}, {"id": "li-2", "material": {"name": "شیر فلکه چهارطرفه"}, "unit": {"name_fa": "عدد"}, "quantity": 200, "unit_price": 90000, "projects": [], "decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None}, ], "termsheets": [{"id": "ts-1", "label": "۳ فاز", "discount_type": "none", "discount_value": 0, "schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 1000}]}], } async def list_quotes(self, *, status=None, q=None, expand=None, sort=None, page=1, page_size=10): if status == "offered": return [self._expanded("q-aaaa1111", "offered")], {"total": 1} if status == "accepted": return [self._expanded("q-bbbb2222", "accepted")], {"total": 1} return [], {"total": 0} async def get_quote(self, quote_id, expand=None, actor_id=None): return self._expanded(quote_id, "accepted" if quote_id.startswith("q-bbbb") else "offered") async def create_quote(self, payload, actor_id=None): self.created_quotes.append(payload) return {"id": "q-new", "supplier": {"name": "سازان نو"}, "line_items": payload.get("line_items") or [], "termsheets": payload.get("termsheets") or []} async def accept_quote(self, quote_id, payload, actor_id=None): self.accept_payloads.append({"quote_id": quote_id, "payload": payload}) return {"id": quote_id, "status": "accepted", "line_items": [{"id": "li-1", "decision": {}}]} async def arrive_items(self, quote_id, payload, actor_id=None): self.arrive_payloads.append({"quote_id": quote_id, "payload": payload}) return {"id": quote_id, "status": "arrived", "items": [{"line_item_id": it["line_item_id"], "received_qty": float(it["received_qty"])} for it in payload["items"]]} # --------------------------------------------------------------------------- # in-memory draft store + link store # --------------------------------------------------------------------------- MEM: dict = {"drafts": {}, "links": {}} def _install_fake_stores() -> None: import uuid as _uuid import app.drafts as drafts import app.runtime as runtime async def create(chat_id, kind, payload, source=None): draft_id = _uuid.uuid4() draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload, missing=None, status="active", source=source, review_message_id=None) MEM["drafts"][str(draft_id)] = draft return draft async def get_active(chat_id, kind=None): found = [d for d in MEM["drafts"].values() if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)] return max(found, key=lambda d: d.id) if found else None async def get_by_prefix(prefix, chat_id=None): for draft in MEM["drafts"].values(): if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id): return draft return None async def save_payload(draft, missing=None, review_message_id=None): draft.missing = missing if review_message_id: draft.review_message_id = review_message_id async def set_status(draft_id, status): if draft_id in MEM["drafts"]: MEM["drafts"][draft_id].status = status async def expire_stale(ttl_hours): return 0 drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale async def link_get(self, chat_id): return MEM["links"].get(chat_id) async def link_upsert(self, chat_id, user_id, username, display_name, role): MEM["links"][chat_id] = types.SimpleNamespace(chat_id=chat_id, user_id=user_id, casdoor_username=username, display_name=display_name, role=role) return MEM["links"][chat_id] async def link_delete(self, chat_id): return MEM["links"].pop(chat_id, None) is not None runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete async def fake_log(self, chat_id, direction, kind, body=None): pass NLU_QUEUE: list[dict] = [] async def fake_understand(settings, text, draft_summary=None): return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0} async def fake_transcribe(settings, audio, mime_type=None, filename=None): return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن " "پرداخت سه فاز") # --------------------------------------------------------------------------- # driver # --------------------------------------------------------------------------- def build_ctx(): import app.handlers as H from app.resolve import Resolver from app.runtime import BotContext bale, api = FakeBale(), FakeApi() ctx = BotContext(bale, api) ctx.bale = bale ctx.api = api ctx.resolver = Resolver(api) ctx._log = types.MethodType(fake_log, ctx) H.understand = fake_understand H.transcribe = fake_transcribe return ctx, bale, api def text_update(update_id, text): return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}} def voice_update(update_id): return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "voice": {"file_id": "v1", "mime_type": "audio/ogg", "duration": 6}}} def callback_update(update_id, data): return {"update_id": update_id, "callback_query": {"id": f"cb{update_id}", "data": data, "message": {"chat": {"id": CHAT}}}} async def main() -> int: _install_fake_stores() from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload import app.handlers as H from app.nlu import _extract_json, normalize_digits # ---- pure logic ------------------------------------------------------- check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو") check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1}) payload = new_quote_payload() merge_quote_patch(payload, { "supplier_name": "سازان نو", "items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}], "payment_terms": [{"label": "۳ فاز"}], }) merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]}) check("merge: no dup item", len(payload["items"]) == 1) check("merge: qty updated", payload["items"][0]["quantity"] == 600) check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز") missing = compute_quote_missing(payload) check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing)) body, problems = build_quote_request(payload) check("build: blocked without supplier", body is None) # ---- pipeline: linking gate ------------------------------------------- ctx, bale, api = build_ctx() await H.handle_update(ctx, text_update(1, "سلام")) check("gate: unlinked asked username", "نام کاربری" in bale.last_text()) await H.handle_update(ctx, text_update(2, "mohaghegh")) await H.handle_update(ctx, text_update(3, "secret")) check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh") check("link: linked greeting", "اتصال انجام شد" in bale.last_text()) # ---- voice → draft quote ---------------------------------------------- NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": { "supplier_name": "سازان نو", "project_name": "برج آسمان", "items": [{"material_name": "میلگرد", "quantity": 500, "unit_name": "کیلوگرم", "unit_price": 45000}], "payment_terms": [{"label": "۳ فاز", "description": "سه قسط"}], }}) await H.handle_update(ctx, voice_update(4)) draft = await _active("quote") check("voice: draft created", draft is not None) check("voice: supplier resolved", draft.payload.get("supplier_id") == "sup-1") check("voice: project resolved", draft.payload.get("project_id") == "prj-1") item = (draft.payload.get("items") or [{}])[0] check("voice: material ambiguous → pick buttons", "mat1" in (draft.payload.get("options") or {})) check("voice: review shows missing list", "وضعیت فیلدها" in bale.last_text()) check("voice: review mentions unit", "کیلوگرم" in bale.last_text()) # pick the material via inline button markup = bale.last_markup() or {} pick_data = _first_callback(markup, "pk:") check("voice: pick callback present", pick_data is not None and ":mat1:0" in pick_data) await H.handle_update(ctx, callback_update(5, pick_data)) item = (draft.payload.get("items") or [{}])[0] check("pick: material resolved", item.get("material_id") == "mat-1") check("pick: unit resolved from spoken name", str(item.get("unit_id")) == "3" and item.get("unit_explicit") is True) # refine price via follow-up text NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": { "items": [{"material_name": "میلگرد", "unit_price": 47000}]}}) await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد")) draft = await _active("quote") check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000) # commit via ✅ await H.handle_update(ctx, callback_update(7, f"q:ok:{draft.id[:8]}")) check("commit: quote posted", len(api.created_quotes) == 1) body = api.created_quotes[0] check("commit: supplier id", body.get("supplier_id") == "sup-1") check("commit: line item temp/refs", body["line_items"][0]["temp_id"] == "t1" and body["item_termsheets"][0]["line_item_temp_id"] == "t1") check("commit: price linked", body["item_termsheets"][0]["unit_price"] == 47000) check("commit: termsheet carried", body["termsheets"][0]["label"] == "۳ فاز") check("commit: report sent", "ثبت شد" in bale.last_text()) check("commit: draft committed", (await _active("quote")) is None) # ---- add supplier ------------------------------------------------------ NLU_QUEUE.append({"intent": "add_supplier", "confidence": 0.9, "patch": { "supplier": {"name": "پروفیل بر", "category_name": "لوله و اتصالات", "phone": "0912"}}}) await H.handle_update(ctx, text_update(8, "تأمین‌کننده جدید پروفیل بر دسته لوله و اتصالات تلفن 0912")) draft = await _active("supplier") check("supplier: draft created", draft is not None) check("supplier: category resolved", draft.payload.get("category_id") == "cat-2") await H.handle_update(ctx, callback_update(9, f"s:ok:{draft.id[:8]}")) check("supplier: posted", api.created_suppliers and api.created_suppliers[0]["name"] == "پروفیل بر") check("supplier: report", "تأمین‌کننده ثبت شد" in bale.last_text()) # ---- status query ------------------------------------------------------ NLU_QUEUE.append({"intent": "status_query", "confidence": 0.9, "patch": {"query": {"kind": "pending"}}}) await H.handle_update(ctx, text_update(10, "استعلام‌های در انتظار چیه؟")) check("query: pending digest", "در انتظار بررسی" in bale.last_text() and "#q-aaaa11" in bale.last_text()) # ---- accept flow ------------------------------------------------------- NLU_QUEUE.append({"intent": "accept_quote", "confidence": 0.9, "patch": {"target": {"supplier_name": "سازان نو"}}}) await H.handle_update(ctx, text_update(11, "استعلام سازان نو را قبول کن")) check("accept: summary shown", "پذیرش استعلام" in bale.last_text()) flow = ctx.pending.get(CHAT) check("accept: pending flow", flow is not None and flow.kind == "accept") await H.handle_update(ctx, callback_update(12, "ac:ok")) check("accept: payload posted", len(api.accept_payloads) == 1) payload = api.accept_payloads[0]["payload"] check("accept: termsheet echoed with steps", payload["accepted_termsheets"][0]["id"] == "ts-1" and payload["accepted_termsheets"][0]["schedule_steps"]) check("accept: decision price", payload["item_decisions"][0]["adjusted_unit_price"] == 45000) check("accept: decision index", payload["item_decisions"][0]["accepted_termsheet_index"] == 0) check("accept: report", "پذیرش انجام شد" in bale.last_text()) # ---- arrive flow ------------------------------------------------------- NLU_QUEUE.append({"intent": "arrive_items", "confidence": 0.9, "patch": {"target": {"supplier_name": "سازان نو"}}}) await H.handle_update(ctx, text_update(13, "اقلام سازان نو رسید")) check("arrive: items shown", "اعلام رسیدن" in bale.last_text()) await H.handle_update(ctx, text_update(14, "۵۰۰ و ۲۰۰")) flow = ctx.pending.get(CHAT) check("arrive: quantities parsed", flow is not None and flow.kind == "arrive_confirm" and flow.data["quantities"] == {"li-1": 500.0, "li-2": 200.0}) await H.handle_update(ctx, callback_update(15, "ar:ok")) check("arrive: payload posted", len(api.arrive_payloads) == 1 and api.arrive_payloads[0]["payload"]["items"][0]["received_qty"] == 500) check("arrive: report", "ثبت رسید انجام شد" in bale.last_text()) # ---- add material ------------------------------------------------------ NLU_QUEUE.append({"intent": "add_material", "confidence": 0.9, "patch": {"material": {"name": "میلگرد A3 سایز ۱۸", "unit_name": "کیلوگرم"}}}) await H.handle_update(ctx, text_update(16, "کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم")) draft = await _active("material") check("material: draft created", draft is not None) check("material: unit resolved", str(draft.payload.get("canonical_unit_id")) == "3") await H.handle_update(ctx, callback_update(17, f"m:ok:{draft.id[:8]}")) check("material: posted", api.created_materials and api.created_materials[0]["canonical_unit_id"] == 3) # ---- cancel / unknown -------------------------------------------------- NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}}) await H.handle_update(ctx, text_update(18, "استعلام از سازان نو")) await H.handle_update(ctx, text_update(19, "/cancel")) check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text()) NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}}) await H.handle_update(ctx, text_update(20, "اسپاگتی بپز")) check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text()) print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed") return 1 if _failed else 0 async def _active(kind): from app import drafts as draft_store return await draft_store.get_active(CHAT, kind) def _first_callback(markup: dict | None, prefix: str) -> str | None: for row in (markup or {}).get("inline_keyboard") or []: for btn in row: if btn.get("callback_data", "").startswith(prefix): return btn["callback_data"] return None if __name__ == "__main__": sys.exit(asyncio.run(main()))