|
| 1 | +"""Shared fixtures and helpers for chatkit integration tests. |
| 2 | +
|
| 3 | +Provides mock MCP servers (account, transaction, payment), SSE parsing |
| 4 | +utilities, and the chatkit ASGI app + async client fixtures. |
| 5 | +
|
| 6 | +Requirements: |
| 7 | +- Azure OpenAI credentials (AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_CHAT_DEPLOYMENT_NAME) |
| 8 | +- MCP servers are started in-process as mock servers with sample data |
| 9 | +""" |
| 10 | + |
| 11 | +import asyncio |
| 12 | +import json |
| 13 | +import logging |
| 14 | +import socket |
| 15 | +import threading |
| 16 | +from typing import List |
| 17 | + |
| 18 | +import pytest |
| 19 | +import uvicorn |
| 20 | +from fastapi import FastAPI |
| 21 | +from fastmcp import FastMCP |
| 22 | +from httpx import ASGITransport, AsyncClient |
| 23 | + |
| 24 | +# Suppress noisy MCP client teardown errors (cancel scope / async generator cleanup) |
| 25 | +logging.getLogger("asyncio").setLevel(logging.CRITICAL) |
| 26 | + |
| 27 | + |
| 28 | +# --------------------------------------------------------------------------- |
| 29 | +# Mock MCP servers with sample data matching business-api services |
| 30 | +# --------------------------------------------------------------------------- |
| 31 | + |
| 32 | +def _create_account_mcp_app() -> FastAPI: |
| 33 | + """Create a mock Account MCP server with sample data.""" |
| 34 | + mcp = FastMCP("Account MCP Server") |
| 35 | + |
| 36 | + accounts_by_username = { |
| 37 | + "bob.user@contoso.com": [ |
| 38 | + { |
| 39 | + "id": "1010", |
| 40 | + "userName": "bob.user@contoso.com", |
| 41 | + "accountHolderFullName": "Bob User", |
| 42 | + "currency": "EUR", |
| 43 | + "activationDate": "2022-01-01", |
| 44 | + "balance": "10000", |
| 45 | + "paymentMethods": [ |
| 46 | + {"id": "345678", "type": "BankTransfer", "activationDate": "2022-01-01", "expirationDate": "9999-01-01"}, |
| 47 | + {"id": "55555", "type": "Visa", "name": "Primary Platinum", "activationDate": "2024-03-01", "expirationDate": "2027-03-01"}, |
| 48 | + {"id": "66666", "type": "Visa", "name": "Secondary Gold", "activationDate": "2025-11-01", "expirationDate": "2028-11-01"}, |
| 49 | + ], |
| 50 | + } |
| 51 | + ], |
| 52 | + "alice.user@contoso.com": [ |
| 53 | + { |
| 54 | + "id": "1000", |
| 55 | + "userName": "alice.user@contoso.com", |
| 56 | + "accountHolderFullName": "Alice User", |
| 57 | + "currency": "USD", |
| 58 | + "activationDate": "2022-01-01", |
| 59 | + "balance": "5000", |
| 60 | + "paymentMethods": [ |
| 61 | + {"id": "12345", "type": "Visa", "activationDate": "2022-01-01", "expirationDate": "2025-01-01"}, |
| 62 | + {"id": "23456", "type": "BankTransfer", "activationDate": "2022-01-01", "expirationDate": "9999-01-01"}, |
| 63 | + ], |
| 64 | + } |
| 65 | + ], |
| 66 | + } |
| 67 | + |
| 68 | + account_details = { |
| 69 | + "1010": { |
| 70 | + "id": "1010", |
| 71 | + "userName": "bob.user@contoso.com", |
| 72 | + "accountHolderFullName": "Bob User", |
| 73 | + "currency": "EUR", |
| 74 | + "activationDate": "2022-01-01", |
| 75 | + "balance": "10000", |
| 76 | + "paymentMethods": [ |
| 77 | + {"id": "345678", "type": "BankTransfer", "activationDate": "2022-01-01", "expirationDate": "9999-01-01"}, |
| 78 | + {"id": "55555", "type": "Visa", "name": "Primary Platinum", "activationDate": "2024-03-01", "expirationDate": "2027-03-01"}, |
| 79 | + {"id": "66666", "type": "Visa", "name": "Secondary Gold", "activationDate": "2025-11-01", "expirationDate": "2028-11-01"}, |
| 80 | + ], |
| 81 | + }, |
| 82 | + "1000": { |
| 83 | + "id": "1000", |
| 84 | + "userName": "alice.user@contoso.com", |
| 85 | + "accountHolderFullName": "Alice User", |
| 86 | + "currency": "USD", |
| 87 | + "activationDate": "2022-01-01", |
| 88 | + "balance": "5000", |
| 89 | + "paymentMethods": [ |
| 90 | + {"id": "12345", "type": "Visa", "activationDate": "2022-01-01", "expirationDate": "2025-01-01"}, |
| 91 | + {"id": "23456", "type": "BankTransfer", "activationDate": "2022-01-01", "expirationDate": "9999-01-01"}, |
| 92 | + ], |
| 93 | + }, |
| 94 | + } |
| 95 | + |
| 96 | + @mcp.tool(name="getAccountsByUserName", description="Get the list of all accounts for a specific user") |
| 97 | + def get_accounts_by_user_name(userName: str) -> list: |
| 98 | + return accounts_by_username.get(userName, []) |
| 99 | + |
| 100 | + @mcp.tool(name="getAccountDetails", description="Get account details and available payment methods") |
| 101 | + def get_account_details(accountId: str) -> dict | None: |
| 102 | + return account_details.get(accountId) |
| 103 | + |
| 104 | + @mcp.tool(name="getRegisteredBeneficiary", description="Get list of registered beneficiaries for a specific account") |
| 105 | + def get_registered_beneficiary(accountId: str) -> list: |
| 106 | + return [ |
| 107 | + {"id": "1", "fullName": "Mike ThePlumber", "bankCode": "123456789", "bankName": "Intesa Sanpaolo"}, |
| 108 | + {"id": "2", "fullName": "Jane TheElectrician", "bankCode": "987654321", "bankName": "UBS"}, |
| 109 | + ] |
| 110 | + |
| 111 | + @mcp.tool(name="getCreditCards", description="Get the list of credit cards bound to an account") |
| 112 | + def get_credit_cards(accountId: str) -> list: |
| 113 | + cards_by_account = { |
| 114 | + "1010": [ |
| 115 | + {"id": "55555", "type": "credit", "circuit": "visa", "name": "Primary Platinum", "activationDate": "2024-03-01", "expirationDate": "2027-03-01", "balance": 900.5, "number": "5111222233335555", "limit": 3000.0, "status": "active"}, |
| 116 | + {"id": "66666", "type": "recharge", "circuit": "visa", "name": "Virtual Gold", "activationDate": "2025-11-01", "expirationDate": "2028-11-01", "balance": 640.25, "number": "5211222233336666", "limit": 2500.0, "status": "active"}, |
| 117 | + {"id": "77777", "type": "credit", "circuit": "amex", "name": "Executive Black", "activationDate": "2024-02-01", "expirationDate": "2029-02-01", "balance": 0, "number": "5311222233337777", "limit": 20000.0, "status": "blocked"}, |
| 118 | + ], |
| 119 | + } |
| 120 | + return cards_by_account.get(accountId, []) |
| 121 | + |
| 122 | + @mcp.tool(name="getCardDetails", description="Get the details of a single credit card") |
| 123 | + def get_card_details(cardId: str) -> dict | None: |
| 124 | + cards = { |
| 125 | + "55555": {"id": "55555", "type": "credit", "circuit": "visa", "name": "Primary Platinum", "balance": 900.5}, |
| 126 | + "66666": {"id": "66666", "type": "recharge", "circuit": "visa", "name": "Virtual Gold", "balance": 640.25}, |
| 127 | + } |
| 128 | + return cards.get(cardId) |
| 129 | + |
| 130 | + mcp_app = mcp.http_app(path="/mcp") |
| 131 | + app = FastAPI(title="Mock Account MCP Server", lifespan=mcp_app.lifespan) |
| 132 | + app.mount("/mcp", mcp_app) |
| 133 | + return app |
| 134 | + |
| 135 | + |
| 136 | +def _create_transaction_mcp_app() -> FastAPI: |
| 137 | + """Create a mock Transaction MCP server with sample data.""" |
| 138 | + mcp = FastMCP("Transaction MCP Server") |
| 139 | + |
| 140 | + all_transactions = { |
| 141 | + "1010": [ |
| 142 | + {"id": "232334", "description": "Payment for office supply services", "type": "payment", "flowType": "outcome", "recipientName": "Contoso", "recipientBankReference": "0002", "accountId": "1010", "paymentType": "CreditCard", "cardId": "55555", "amount": 215.00, "timestamp": "2025-03-02T12:00:00Z", "category": "Supply services", "status": "paid"}, |
| 143 | + {"id": "3321432", "description": "Business Lunch with customer", "type": "payment", "flowType": "outcome", "recipientName": "Duff", "accountId": "1010", "paymentType": "CreditCard", "cardId": "66666", "amount": 134.10, "timestamp": "2025-10-03T12:00:00Z", "category": "Meals", "status": "paid"}, |
| 144 | + {"id": "884995", "description": "Office Air conditioners. Invoice 355TRA1423FFSSS", "type": "payment", "flowType": "outcome", "recipientName": "Contoso Services", "recipientBankReference": "0003", "accountId": "1010", "paymentType": "DirectDebit", "amount": 300.00, "timestamp": "2025-10-03T12:00:00Z", "category": "Services", "status": "paid"}, |
| 145 | + {"id": "3946373", "description": "Metro and Bus subscription 2023-AB56", "type": "payment", "flowType": "outcome", "recipientName": "Speedy Subways", "recipientBankReference": "0005", "accountId": "1010", "paymentType": "CreditCard", "cardId": "66666", "amount": 410.00, "timestamp": "2025-04-05T12:00:00Z", "category": "Retail", "status": "paid"}, |
| 146 | + {"id": "2004764", "description": "Medical eyes checkup payment. Ref: MZ23-5567", "type": "payment", "flowType": "outcome", "recipientName": "Contoso Health", "recipientBankReference": "0001", "accountId": "1010", "paymentType": "CreditCard", "cardId": "66666", "amount": 230.00, "timestamp": "2025-11-01T12:00:00Z", "category": "Health", "status": "paid"}, |
| 147 | + {"id": "49950598", "description": "Payment of the bill 682222", "type": "payment", "flowType": "outcome", "recipientName": "Contoso Services", "recipientBankReference": "0002", "accountId": "1010", "paymentType": "CreditCard", "cardId": "55555", "amount": 200.00, "timestamp": "2025-11-02T12:00:00Z", "category": "Rent", "status": "paid"}, |
| 148 | + {"id": "488624", "description": "Monthly Salary - StartUp.com", "type": "deposit", "flowType": "income", "accountId": "1010", "paymentType": "Transfer", "amount": 3000.00, "timestamp": "2025-10-03T12:00:00Z", "category": "Payroll"}, |
| 149 | + {"id": "3004853", "description": "Stocks vesting accreditation. www.traderepublic.com - FY25Q3", "type": "deposit", "flowType": "income", "accountId": "1010", "paymentType": "Transfer", "amount": 400.00, "timestamp": "2025-8-04T12:00:00Z", "category": "Investment"}, |
| 150 | + {"id": "5001001", "description": "Home power bill 334398", "type": "payment", "flowType": "outcome", "recipientName": "ACME", "recipientBankReference": "0010", "accountId": "1010", "paymentType": "BankTransfer", "amount": 160.40, "timestamp": "2026-04-07T12:00:00Z", "category": "Utilities", "status": "pending"}, |
| 151 | + {"id": "5001002", "description": "Office cleaning services March", "type": "payment", "flowType": "outcome", "recipientName": "ACME Services", "recipientBankReference": "0011", "accountId": "1010", "paymentType": "CreditCard", "cardId": "55555", "amount": 95.00, "timestamp": "2026-03-15T12:00:00Z", "category": "Services", "status": "paid"}, |
| 152 | + ], |
| 153 | + } |
| 154 | + |
| 155 | + last_transactions = { |
| 156 | + "1010": [ |
| 157 | + {"id": "11", "description": "Home power bill 334398", "type": "payment", "flowType": "outcome", "recipientName": "ACME", "recipientBankReference": "0001", "accountId": "1010", "paymentType": "BankTransfer", "amount": 160.40, "timestamp": "2026-04-07T12:00:00Z", "category": "Utilities", "status": "pending"}, |
| 158 | + {"id": "22", "description": "Payment for office supply services", "type": "payment", "flowType": "outcome", "recipientName": "Contoso Services", "recipientBankReference": "0002", "accountId": "1010", "paymentType": "CreditCard", "cardId": "card-8421", "amount": 215.00, "timestamp": "2025-03-02T12:00:00Z", "category": "Supply services", "status": "paid"}, |
| 159 | + {"id": "33", "description": "Business Lunch with customer", "type": "payment", "flowType": "outcome", "recipientName": "Duff", "accountId": "1010", "paymentType": "CreditCard", "cardId": "card-8421", "amount": 134.10, "timestamp": "2025-10-03T12:00:00Z", "category": "Meals", "status": "paid"}, |
| 160 | + {"id": "43", "description": "card withdrawal at atm 00987", "type": "withdrawal", "flowType": "outcome", "accountId": "1010", "paymentType": "DirectDebit", "cardId": "card-3311", "amount": 150.00, "timestamp": "2025-8-04T12:00:00Z", "category": "Insurance"}, |
| 161 | + {"id": "53", "description": "Refund for invoice 19dee", "type": "deposit", "flowType": "income", "recipientName": "oscorp", "recipientBankReference": "0005", "accountId": "1010", "paymentType": "BankTransfer", "amount": 522.00, "timestamp": "2025-4-05T12:00:00Z", "category": "Refunds", "cardId": "card-0098"}, |
| 162 | + ], |
| 163 | + } |
| 164 | + |
| 165 | + @mcp.tool(name="getTransactionsByRecipientName", description="Get transactions by recipient name") |
| 166 | + def get_transactions_by_recipient_name(accountId: str, recipientName: str) -> list: |
| 167 | + transactions = all_transactions.get(accountId, []) |
| 168 | + name_lower = recipientName.lower() if recipientName else "" |
| 169 | + filtered = [t for t in transactions if t.get("recipientName") and name_lower in t["recipientName"].lower()] |
| 170 | + return sorted(filtered, key=lambda t: t["timestamp"], reverse=True) |
| 171 | + |
| 172 | + @mcp.tool(name="getCardTransactions", description="Get credit and debit card transactions") |
| 173 | + def get_card_transactions(accountId: str, cardId: str) -> list: |
| 174 | + transactions = all_transactions.get(accountId, []) |
| 175 | + return [t for t in transactions if t.get("cardId") == cardId] |
| 176 | + |
| 177 | + @mcp.tool(name="getLastTransactions", description="Get the last transactions for an account") |
| 178 | + def get_last_transactions(accountId: str) -> list: |
| 179 | + return sorted( |
| 180 | + last_transactions.get(accountId, []), |
| 181 | + key=lambda t: t["timestamp"], |
| 182 | + reverse=True, |
| 183 | + ) |
| 184 | + |
| 185 | + mcp_app = mcp.http_app(path="/mcp") |
| 186 | + app = FastAPI(title="Mock Transaction MCP Server", lifespan=mcp_app.lifespan) |
| 187 | + app.mount("/mcp", mcp_app) |
| 188 | + return app |
| 189 | + |
| 190 | + |
| 191 | +def _create_payment_mcp_app() -> FastAPI: |
| 192 | + """Create a mock Payment MCP server with a single processPayment tool.""" |
| 193 | + mcp = FastMCP("Payment MCP Server") |
| 194 | + |
| 195 | + @mcp.tool(name="processPayment", description="Submit a payment request") |
| 196 | + def process_payment( |
| 197 | + account_id: str, |
| 198 | + amount: float, |
| 199 | + description: str, |
| 200 | + timestamp: str, |
| 201 | + recipient_name: str | None = None, |
| 202 | + recipient_bank_code: str | None = None, |
| 203 | + payment_type: str | None = None, |
| 204 | + card_id: str | None = None, |
| 205 | + status: str | None = None, |
| 206 | + category: str | None = None, |
| 207 | + ) -> dict: |
| 208 | + if not account_id or not account_id.isdigit(): |
| 209 | + return {"status": "error", "message": "Invalid accountId"} |
| 210 | + if payment_type == "CreditCard" and not card_id: |
| 211 | + return {"status": "error", "message": "cardId is required for CreditCard payments"} |
| 212 | + return {"status": "ok"} |
| 213 | + |
| 214 | + mcp_app = mcp.http_app(path="/mcp") |
| 215 | + app = FastAPI(title="Mock Payment MCP Server", lifespan=mcp_app.lifespan) |
| 216 | + app.mount("/mcp", mcp_app) |
| 217 | + return app |
| 218 | + |
| 219 | + |
| 220 | +# --------------------------------------------------------------------------- |
| 221 | +# Helper: start a uvicorn server in a background thread on a free port |
| 222 | +# --------------------------------------------------------------------------- |
| 223 | + |
| 224 | +def _find_free_port() -> int: |
| 225 | + """Find an available TCP port on localhost.""" |
| 226 | + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: |
| 227 | + s.bind(("127.0.0.1", 0)) |
| 228 | + return s.getsockname()[1] |
| 229 | + |
| 230 | + |
| 231 | +class _BackgroundServer: |
| 232 | + """Run a uvicorn ASGI server in a daemon thread.""" |
| 233 | + |
| 234 | + def __init__(self, app: FastAPI, port: int): |
| 235 | + self.config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") |
| 236 | + self.server = uvicorn.Server(self.config) |
| 237 | + self._thread: threading.Thread | None = None |
| 238 | + |
| 239 | + def start(self) -> None: |
| 240 | + self._thread = threading.Thread(target=self.server.run, daemon=True) |
| 241 | + self._thread.start() |
| 242 | + # Wait until the server is accepting connections |
| 243 | + import time |
| 244 | + for _ in range(50): |
| 245 | + if self.server.started: |
| 246 | + break |
| 247 | + time.sleep(0.1) |
| 248 | + |
| 249 | + def stop(self) -> None: |
| 250 | + self.server.should_exit = True |
| 251 | + if self._thread: |
| 252 | + self._thread.join(timeout=5) |
| 253 | + |
| 254 | + |
| 255 | +# --------------------------------------------------------------------------- |
| 256 | +# SSE helpers |
| 257 | +# --------------------------------------------------------------------------- |
| 258 | + |
| 259 | +def parse_sse_events(raw_body: str) -> List[dict]: |
| 260 | + """Parse an SSE text body into a list of JSON event dicts. |
| 261 | +
|
| 262 | + Each SSE frame looks like: |
| 263 | + data: {"type":"...", ...}\n\n |
| 264 | +
|
| 265 | + Some frames may contain stream_options or other metadata. |
| 266 | + Lines that are not ``data:`` prefixed are ignored. |
| 267 | + """ |
| 268 | + events: List[dict] = [] |
| 269 | + for line in raw_body.splitlines(): |
| 270 | + line = line.strip() |
| 271 | + if line.startswith("data:"): |
| 272 | + payload = line[len("data:"):].strip() |
| 273 | + if payload: |
| 274 | + try: |
| 275 | + events.append(json.loads(payload)) |
| 276 | + except json.JSONDecodeError: |
| 277 | + pass # skip malformed lines |
| 278 | + return events |
| 279 | + |
| 280 | + |
| 281 | +def get_events_by_type(events: List[dict], event_type: str) -> List[dict]: |
| 282 | + """Filter parsed SSE events by their ``type`` field.""" |
| 283 | + return [e for e in events if e.get("type") == event_type] |
| 284 | + |
| 285 | + |
| 286 | +# --------------------------------------------------------------------------- |
| 287 | +# Fixtures |
| 288 | +# --------------------------------------------------------------------------- |
| 289 | + |
| 290 | +@pytest.fixture(scope="session") |
| 291 | +def mock_mcp_ports(): |
| 292 | + """Allocate free ports for the mock MCP servers.""" |
| 293 | + return { |
| 294 | + "account": _find_free_port(), |
| 295 | + "transaction": _find_free_port(), |
| 296 | + "payment": _find_free_port(), |
| 297 | + } |
| 298 | + |
| 299 | + |
| 300 | +@pytest.fixture(scope="session") |
| 301 | +def mock_mcp_servers(mock_mcp_ports): |
| 302 | + """Start mock Account, Transaction, and Payment MCP servers for the whole test session.""" |
| 303 | + account_app = _create_account_mcp_app() |
| 304 | + transaction_app = _create_transaction_mcp_app() |
| 305 | + payment_app = _create_payment_mcp_app() |
| 306 | + |
| 307 | + account_srv = _BackgroundServer(account_app, mock_mcp_ports["account"]) |
| 308 | + transaction_srv = _BackgroundServer(transaction_app, mock_mcp_ports["transaction"]) |
| 309 | + payment_srv = _BackgroundServer(payment_app, mock_mcp_ports["payment"]) |
| 310 | + |
| 311 | + account_srv.start() |
| 312 | + transaction_srv.start() |
| 313 | + payment_srv.start() |
| 314 | + |
| 315 | + yield mock_mcp_ports |
| 316 | + |
| 317 | + account_srv.stop() |
| 318 | + transaction_srv.stop() |
| 319 | + payment_srv.stop() |
| 320 | + |
| 321 | + |
| 322 | +@pytest.fixture(scope="session") |
| 323 | +def chatkit_app(mock_mcp_servers): |
| 324 | + """Create the FastAPI application with MCP URLs pointing to mock servers. |
| 325 | +
|
| 326 | + Settings and the DI container are patched **before** the app is created |
| 327 | + so that agents will connect to the in-process mock MCP servers. |
| 328 | + """ |
| 329 | + import os |
| 330 | + |
| 331 | + # Load configuration from .env.dev via the PROFILE mechanism |
| 332 | + os.environ["PROFILE"] = "dev" |
| 333 | + |
| 334 | + account_port = mock_mcp_servers["account"] |
| 335 | + transaction_port = mock_mcp_servers["transaction"] |
| 336 | + payment_port = mock_mcp_servers["payment"] |
| 337 | + |
| 338 | + # Override MCP URLs to point to in-process mock servers |
| 339 | + os.environ["ACCOUNT_MCP_URL"] = f"http://127.0.0.1:{account_port}/mcp" |
| 340 | + os.environ["TRANSACTION_MCP_URL"] = f"http://127.0.0.1:{transaction_port}/mcp" |
| 341 | + os.environ["PAYMENT_MCP_URL"] = f"http://127.0.0.1:{payment_port}/mcp" |
| 342 | + # Disable Application Insights telemetry during tests |
| 343 | + os.environ["ENABLE_OTEL"] = "false" |
| 344 | + os.environ.pop("APPLICATIONINSIGHTS_CONNECTION_STRING", None) |
| 345 | + |
| 346 | + # Settings will load from .env.dev for Azure OpenAI config; verify it's present |
| 347 | + from app.config.settings import Settings |
| 348 | + test_settings = Settings() |
| 349 | + |
| 350 | + if not test_settings.AZURE_OPENAI_ENDPOINT: |
| 351 | + pytest.skip( |
| 352 | + "AZURE_OPENAI_ENDPOINT is not configured. " |
| 353 | + "Ensure .env.dev has AZURE_OPENAI_ENDPOINT set to run integration tests." |
| 354 | + ) |
| 355 | + |
| 356 | + # Now import and create the app — settings will read .env.dev + overrides above |
| 357 | + from app.main_chatkit_server import create_app |
| 358 | + |
| 359 | + return create_app() |
| 360 | + |
| 361 | + |
| 362 | +@pytest.fixture(scope="session") |
| 363 | +async def client(chatkit_app): |
| 364 | + """Yield an httpx AsyncClient wired to the chatkit ASGI app.""" |
| 365 | + from asgi_lifespan import LifespanManager |
| 366 | + |
| 367 | + async with LifespanManager(chatkit_app) as manager: |
| 368 | + transport = ASGITransport(app=manager.app) |
| 369 | + async with AsyncClient(transport=transport, base_url="http://test") as c: |
| 370 | + yield c |
0 commit comments