[30388f42] feat: Add automated test infrastructure for core services

This commit is contained in:
2026-03-08 13:41:58 +00:00
parent 3a6183a85e
commit c467d62580
23 changed files with 420 additions and 8 deletions

View File

@@ -4,7 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>B2B Marketing Assistant powered by Gemini</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚀</text></svg>">
<title>B2B Marketing Assistant</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script type="importmap"> <script type="importmap">
{ {

View File

@@ -3,3 +3,5 @@ requests
beautifulsoup4 beautifulsoup4
lxml lxml
python-dotenv python-dotenv
pytest
httpx

View File

@@ -0,0 +1,3 @@
#!/bin/bash
export PYTHONPATH=$PYTHONPATH:/app
python3 -m pytest -v /app/tests/test_orchestrator.py

View File

@@ -0,0 +1,57 @@
import pytest
import json
import os
import sys
from unittest.mock import patch, MagicMock
# Add current dir to sys.path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import b2b_marketing_orchestrator as orchestrator
@pytest.fixture
def mock_gemini_response():
return """## Schritt 1: Angebot (WAS)
Kurzresuemee:
* Test Summary 1
* Test Summary 2
| Produkt/Loesung | Beschreibung | Kernfunktionen | Differenzierung | Primaere Quelle (URL) |
|---|---|---|---|---|
| Test Bot | A great robot | AI | Faster | https://example.com/bot |
"""
@patch("b2b_marketing_orchestrator.load_api_key", return_value="fake_key")
@patch("b2b_marketing_orchestrator.get_text_from_url", return_value="<html><body>Test Content</body></html>")
@patch("b2b_marketing_orchestrator.find_relevant_links", return_value=[])
@patch("b2b_marketing_orchestrator.call_gemini_api")
def test_start_generation(mock_call, mock_links, mock_scrape, mock_key, mock_gemini_response):
mock_call.return_value = mock_gemini_response
result = orchestrator.start_generation("https://example.com", "de", "DACH", "Cleaning")
assert "offer" in result
assert result["offer"]["headers"] == ["Produkt/Loesung", "Beschreibung", "Kernfunktionen", "Differenzierung", "Primaere Quelle (URL)"]
assert len(result["offer"]["rows"]) == 1
assert result["offer"]["rows"][0][0] == "Test Bot"
# Note: Orchestrator uses a hardcoded summary for Step 1 in its return dict
assert "Angebotsanalyse" in result["offer"]["summary"][0]
def test_parse_markdown_table():
md = """
| Header 1 | Header 2 |
|---|---|
| Val 1 | Val 2 |
"""
result = orchestrator.parse_markdown_table(md)
assert result["headers"] == ["Header 1", "Header 2"]
assert result["rows"] == [["Val 1", "Val 2"]]
@patch("b2b_marketing_orchestrator.load_api_key", return_value="fake_key")
@patch("b2b_marketing_orchestrator.call_gemini_api", return_value="Test Product, Nice Bot, AI, Best, https://test.com")
def test_enrich_product(mock_call, mock_key):
result = orchestrator.enrich_product("Test Product", "https://test.com", "de")
assert len(result) == 5
assert result[0] == "Test Product"
assert result[1] == "Nice Bot"

View File

@@ -0,0 +1,107 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from unittest.mock import MagicMock, patch
import os
import sys
# Add backend to sys.path to resolve imports
sys.path.append(os.path.join(os.path.dirname(__file__), "../.."))
from backend.app import app, get_db, authenticate_user
from backend.database import Base
# --- Test Database Setup ---
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
# Override Auth to bypass password check for tests
def override_authenticate_user():
return "test_user"
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[authenticate_user] = override_authenticate_user
@pytest.fixture(scope="module")
def client():
# Create tables
Base.metadata.create_all(bind=engine)
with TestClient(app) as c:
yield c
# Drop tables
Base.metadata.drop_all(bind=engine)
# --- Tests ---
def test_health_check(client):
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
@patch("backend.app.run_discovery_task") # Mock the background task function directly
def test_provision_new_company(mock_discovery_task, client):
# Data simulating a webhook from Connector
payload = {
"so_contact_id": 12345,
"so_person_id": 999,
"crm_name": "Test Firma GmbH",
"crm_website": "www.test-firma.de",
"job_title": "CTO",
"campaign_tag": "standard"
}
# Call the API
response = client.post("/api/provision/superoffice-contact", json=payload)
# Assertions
assert response.status_code == 200
data = response.json()
assert data["company_name"] == "Test Firma GmbH"
# Verify Background Task was triggered
# Note: FastAPI TestClient runs background tasks synchronously by default!
# So patching the function in app.py is the right way to intercept it.
mock_discovery_task.assert_called()
def test_create_contact_manual(client):
# First create a company to attach contact to
# We can use the DB session directly or the API
from backend.database import Company
db = TestingSessionLocal()
company = Company(name="Manual Corp", status="NEW")
db.add(company)
db.commit()
db.refresh(company)
company_id = company.id
db.close()
payload = {
"company_id": company_id,
"first_name": "Hans",
"last_name": "Tester",
"email": "hans@manual.corp",
"job_title": "CEO",
"role": "Wirtschaftlicher Entscheider",
"is_primary": True
}
response = client.post("/api/contacts", json=payload)
assert response.status_code == 200
data = response.json()
assert data["email"] == "hans@manual.corp"
assert data["role"] == "Wirtschaftlicher Entscheider"

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Company Explorer (Robotics)</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>">
<title>Company Explorer</title>
</head> </head>
<body class="bg-slate-950 text-slate-100"> <body class="bg-slate-950 text-slate-100">
<div id="root"></div> <div id="root"></div>

View File

@@ -10,3 +10,5 @@ thefuzz
wikipedia wikipedia
pydantic pydantic
pydantic-settings pydantic-settings
pytest
httpx

View File

@@ -0,0 +1,3 @@
#!/bin/bash
export PYTHONPATH=$PYTHONPATH:/app
python3 -m pytest -v /app/backend/tests/test_api_integration.py

View File

@@ -4,7 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>B2B Competitor Analysis Agent</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚔️</text></svg>">
<title>Competitor Analysis Agent</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {

View File

@@ -8,3 +8,5 @@ fastapi
uvicorn uvicorn
schedule schedule
sqlalchemy sqlalchemy
pytest
httpx

View File

@@ -0,0 +1,3 @@
#!/bin/bash
export PYTHONPATH=$PYTHONPATH:/app
python3 -m pytest -v /app/tests/test_webhook.py

View File

@@ -0,0 +1,46 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
import os
import sys
# Resolve paths
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
# Mock environment before importing app
with patch.dict(os.environ, {"WEBHOOK_TOKEN": "test_secret_token"}):
from webhook_app import app, queue
client = TestClient(app)
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_webhook_invalid_token():
response = client.post("/webhook?token=wrong_token", json={"Event": "test"})
assert response.status_code == 403
@patch("webhook_app.queue.add_job")
def test_webhook_valid_token(mock_add_job):
payload = {
"Event": "contact.created",
"PrimaryKey": 123,
"Name": "Test Company"
}
response = client.post("/webhook?token=test_secret_token", json=payload)
assert response.status_code == 200
assert response.json() == {"status": "queued"}
# Verify job was added to queue
mock_add_job.assert_called_once_with("contact.created", payload)
def test_stats_endpoint():
# Mock get_stats result
with patch.object(queue, 'get_stats', return_value={"PENDING": 5, "COMPLETED": 10}):
response = client.get("/stats")
assert response.status_code == 200
assert response.json() == {"PENDING": 5, "COMPLETED": 10}

View File

@@ -4,6 +4,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✍️</text></svg>">
<title>Content Engine</title> <title>Content Engine</title>
</head> </head>
<body> <body>

43
docs/TESTING.md Normal file
View File

@@ -0,0 +1,43 @@
# Testing & Qualitätssicherung
Dieses Projekt nutzt automatisierte Integrationstests, um die Stabilität der Microservices sicherzustellen. Alle Tests sind so konzipiert, dass sie **innerhalb** der Docker-Container laufen und externe Abhängigkeiten (KI-APIs) mocken.
## 🚀 Alle Tests ausführen
Führen Sie die folgenden Befehle auf dem Host-System aus, um die jeweilige Test-Suite zu starten:
### 1. Company Explorer (Intelligence Core)
```bash
docker exec company-explorer /app/run_tests.sh
```
### 2. SuperOffice Connector (CRM Sync)
```bash
docker exec connector-superoffice /app/run_tests.sh
```
### 3. Lead Engine (Trading Twins)
```bash
docker exec lead-engine /app/run_tests.sh
```
### 4. B2B Marketing Assistant (Analysis)
```bash
docker exec b2b-marketing-assistant /app/run_tests.sh
```
---
## 🛠️ Test-Architektur
* **Framework:** `pytest`
* **API-Testing:** `httpx` & `FastAPI TestClient`
* **Datenbank:** SQLite In-Memory (oder temporäre Datei-DB)
* **Mocking:** `unittest.mock` wird verwendet, um Aufrufe an Gemini/OpenAI zu simulieren.
## 📝 Neue Tests hinzufügen
Um neue Tests für einen Dienst zu erstellen:
1. Erstellen Sie eine Datei in `service-name/tests/test_*.py`.
2. Stellen Sie sicher, dass das `run_tests.sh` Skript im Dienst-Ordner diese Datei inkludiert.
3. Kopieren Sie die Datei ggf. in den Container (`docker cp`).

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>parcelLab Market Intelligence</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📊</text></svg>">
<title>Market Intelligence</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style> <style>

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Roboplanet GTM Architect</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏛️</text></svg>">
<title>GTM Architect</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script> <script>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🗺️</text></svg>">
<title>Heatmap Tool</title> <title>Heatmap Tool</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="

View File

@@ -6,3 +6,5 @@ fastapi
uvicorn[standard] uvicorn[standard]
msal msal
sqlalchemy sqlalchemy
pytest
httpx

3
lead-engine/run_tests.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
export PYTHONPATH=$PYTHONPATH:/app
python3 -m pytest -v /app/tests/test_lead_engine.py

View File

@@ -0,0 +1,83 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from unittest.mock import MagicMock, patch
import os
import sys
# Resolve paths
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.append(BASE_DIR)
from trading_twins.manager import app, SessionLocal
from trading_twins.models import Base, ProposalJob
# --- Test Database Setup ---
# Use a local file for testing to ensure visibility across sessions
TEST_DB_FILE = "test_trading_twins.db"
SQLALCHEMY_DATABASE_URL = f"sqlite:///./{TEST_DB_FILE}"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="module", autouse=True)
def setup_database():
# Create tables
Base.metadata.create_all(bind=engine)
yield
# Cleanup
Base.metadata.drop_all(bind=engine)
if os.path.exists(TEST_DB_FILE):
os.remove(TEST_DB_FILE)
@pytest.fixture
def client():
# Patch the SessionLocal in manager.py to use our test DB
with patch("trading_twins.manager.SessionLocal", TestingSessionLocal):
with TestClient(app) as c:
yield c
def test_trigger_test_lead(client):
with patch("trading_twins.manager.process_lead") as mock_process:
response = client.get("/test_lead")
assert response.status_code == 202
assert "Test lead triggered" in response.json()["status"]
mock_process.assert_called()
def test_stop_job(client):
db = TestingSessionLocal()
job = ProposalJob(job_uuid="test_uuid_1", customer_email="test@example.com", status="pending")
db.add(job)
db.commit()
db.close()
response = client.get("/stop/test_uuid_1")
assert response.status_code == 200
assert response.text == "Gestoppt."
db = TestingSessionLocal()
updated_job = db.query(ProposalJob).filter(ProposalJob.job_uuid == "test_uuid_1").first()
assert updated_job.status == "cancelled"
db.close()
def test_send_now(client):
db = TestingSessionLocal()
job = ProposalJob(job_uuid="test_uuid_2", customer_email="test@example.com", status="pending")
db.add(job)
db.commit()
db.close()
response = client.get("/send_now/test_uuid_2")
assert response.status_code == 200
assert response.text == "Wird gesendet."
db = TestingSessionLocal()
updated_job = db.query(ProposalJob).filter(ProposalJob.job_uuid == "test_uuid_2").first()
assert updated_job.status == "send_now"
db.close()
def test_book_slot_fail_no_job(client):
response = client.get("/book_slot/invalid_uuid/123456789")
assert response.status_code == 400
assert response.text == "Fehler."

48
test_suite.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# --- GLOBAL TEST RUNNER ---
# Runs tests for all integrated services
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
run_container_tests() {
local container=$1
local test_dir=$2
local test_file=$3
echo -e "\n${GREEN}>>> Testing Service: $container <<<${NC}"
# 1. Install dependencies
docker exec $container pip install pytest httpx > /dev/null 2>&1
# 2. Copy tests and runner to container
docker cp $test_dir/tests/. $container:/app/tests/
docker cp $test_dir/run_tests.sh $container:/app/run_tests.sh
# 3. Execute
docker exec $container chmod +x /app/run_tests.sh
docker exec $container /app/run_tests.sh
if [ $? -eq 0 ]; then
echo -e "${GREEN}SUCCESS: $container tests passed.${NC}"
else
echo -e "${RED}FAILURE: $container tests failed.${NC}"
fi
}
# 1. Company Explorer
run_container_tests "company-explorer" "company-explorer/backend" "test_api_integration.py"
# 2. Connector
run_container_tests "connector-superoffice" "connector-superoffice" "test_webhook.py"
# 3. Lead Engine
run_container_tests "lead-engine" "lead-engine" "test_lead_engine.py"
# 4. B2B Marketing Assistant
run_container_tests "b2b-marketing-assistant" "b2b-marketing-assistant" "test_orchestrator.py"
echo -e "\n${GREEN}=== Test Suite Finished ===${NC}"

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎙️</text></svg>">
<title>Meeting Assistant</title> <title>Meeting Assistant</title>
</head> </head>
<body class="bg-slate-50 dark:bg-slate-950"> <body class="bg-slate-50 dark:bg-slate-950">