Skip to content

Commit 00c4064

Browse files
author
Davide Antelmo
committed
refactoring tests + new payment-with-image test
1 parent e8059ff commit 00c4064

6 files changed

Lines changed: 1194 additions & 842 deletions

File tree

app/backend/tests/conftest.py

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
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

Comments
 (0)