180 lines
7.4 KiB
Python
180 lines
7.4 KiB
Python
"""Profile-review failures expose reason and whether the provider was billed.
|
|
|
|
Run from backend/: python tests/test_profile_review_errors.py
|
|
No live provider calls.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
|
|
os.environ["KANSHO_FAKE_DETECT"] = "1"
|
|
|
|
from fastapi.testclient import TestClient
|
|
from engine import EngineError
|
|
from main import app
|
|
from privacy_gateway import public_cost_report
|
|
from providers import ProviderConfig, complete_chat, provider_error_diagnostics
|
|
|
|
|
|
def expect(ok: bool, message: str) -> None:
|
|
if not ok:
|
|
raise SystemExit(f"FAIL: {message}")
|
|
print(f"OK {message}")
|
|
|
|
|
|
def header(token: str) -> dict:
|
|
return {"X-Auth-Token": token}
|
|
|
|
|
|
def _openrouter() -> ProviderConfig:
|
|
return ProviderConfig(
|
|
role="generate",
|
|
name="openrouter",
|
|
mode="http",
|
|
url="https://openrouter.ai/api/v1/chat/completions",
|
|
model="openai/gpt-4o",
|
|
key="sk-test",
|
|
local=False,
|
|
zdr=True,
|
|
no_train=True,
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
none = public_cost_report({})
|
|
expect(none["billed"] == "no", "empty diagnostics are unbilled")
|
|
expect("keine Kosten entstanden" in none["note"], "empty diagnostics say no provider call")
|
|
|
|
detect_only = public_cost_report(
|
|
{"detect_calls": 2, "detect_cost": 0.0012, "generate_called": False, "generate_requested": False}
|
|
)
|
|
expect(detect_only["billed"] == "yes", "detect cost counts as billed")
|
|
expect("Detect" in detect_only["note"] and "Generate nicht" in detect_only["note"], "detect-only note names both stages")
|
|
|
|
sent = public_cost_report(
|
|
{"detect_calls": 1, "detect_cost": 0.0004, "generate_requested": True, "generate_called": False}
|
|
)
|
|
expect(sent["billed"] == "yes", "known detect cost stays billed if generate is unknown")
|
|
expect("Generate-Aufruf gesendet" in sent["note"], "unknown generate request is explicit")
|
|
|
|
unknown = public_cost_report({"generate_requested": True, "generate_called": False})
|
|
expect(unknown["billed"] == "unknown", "generate request without any usage is unknown")
|
|
|
|
known = public_cost_report(
|
|
{"detect_calls": 1, "detect_cost": 0.0004, "generate_called": True, "cost": 0.021}
|
|
)
|
|
expect(known["billed"] == "yes", "generate usage is billed")
|
|
expect(abs((known["total_cost"] or 0) - 0.0214) < 1e-9, "total adds detect and generate")
|
|
|
|
parsed = provider_error_diagnostics(
|
|
402,
|
|
'{"error":{"message":"Insufficient credits","code":402},"usage":{"cost":0.0}}',
|
|
)
|
|
expect(parsed["http_status"] == 402, "provider diagnostics keep HTTP status")
|
|
expect(parsed["provider_message"] == "Insufficient credits", "provider message is copied")
|
|
expect(parsed.get("cost") == 0.0, "error usage cost is kept")
|
|
|
|
class Reject:
|
|
status_code = 402
|
|
text = '{"error":{"message":"Insufficient credits","code":402}}'
|
|
|
|
def json(self):
|
|
return {"error": {"message": "Insufficient credits", "code": 402}}
|
|
|
|
try:
|
|
with patch("providers.httpx.post", lambda *args, **kwargs: Reject()):
|
|
complete_chat(_openrouter(), [{"role": "user", "content": "x"}], timeout=5)
|
|
raise SystemExit("FAIL: rejected complete_chat should raise")
|
|
except Exception as exc:
|
|
expect(getattr(exc, "code", "") == "provider_rejected", "credit reject is provider_rejected")
|
|
expect("HTTP 402" in str(exc), "reject message includes HTTP status")
|
|
expect("Insufficient credits" in str(exc), "reject message includes provider reason")
|
|
expect(exc.diagnostics.get("generate_requested") is True, "reject marks generate as requested")
|
|
|
|
def boom(*args, **kwargs):
|
|
raise httpx.TimeoutException("timed out")
|
|
|
|
try:
|
|
with patch("providers.httpx.post", boom):
|
|
complete_chat(_openrouter(), [{"role": "user", "content": "x"}], timeout=12)
|
|
raise SystemExit("FAIL: timeout should raise")
|
|
except Exception as exc:
|
|
expect(getattr(exc, "code", "") == "provider_timeout", "timeout is distinct from unreachable")
|
|
expect("12 Sekunden" in str(exc), "timeout names the limit")
|
|
|
|
with TestClient(app) as client:
|
|
setup = client.post(
|
|
"/api/auth/setup",
|
|
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
|
|
)
|
|
expect(setup.status_code == 200, f"setup {setup.text}")
|
|
token = setup.json()["token"]
|
|
headers = header(token)
|
|
imported = client.post(
|
|
"/api/journal/writing-profile/import",
|
|
headers=headers,
|
|
json={"body": "Heute Kirschen am Markt, später der Hafen, und abends nur zwei knappe Zeilen."},
|
|
)
|
|
expect(imported.status_code == 200, f"import {imported.text}")
|
|
|
|
rejected = EngineError(
|
|
"provider_rejected",
|
|
"generate-Provider hat die Anfrage abgelehnt (HTTP 402): Insufficient credits",
|
|
502,
|
|
diagnostics={
|
|
"http_status": 402,
|
|
"provider_message": "Insufficient credits",
|
|
"generate_requested": True,
|
|
"generate_called": False,
|
|
"detect_calls": 1,
|
|
"detect_cost": 0.0015,
|
|
},
|
|
)
|
|
with patch("engine.execute_prompt", side_effect=rejected):
|
|
response = client.post("/api/journal/writing-profile/initial-build/api", headers=headers)
|
|
expect(response.status_code == 502, f"rejected review status {response.text}")
|
|
detail = response.json()["detail"]
|
|
expect(detail["code"] == "provider_rejected", "API keeps provider code")
|
|
expect("Insufficient credits" in detail["message"], "API message keeps provider reason")
|
|
expect(detail["cost_report"]["billed"] == "yes", "detect cost is reported as billed")
|
|
expect("Detect" in detail["cost_report"]["note"], "cost note mentions detect")
|
|
expect(detail["cost_report"]["generate_requested"] is True, "generate was requested")
|
|
expect(detail["cost_report"]["generate_called"] is False, "generate did not complete")
|
|
|
|
invalid = {
|
|
"content": "kein json",
|
|
"diagnostics": {
|
|
"generate_called": True,
|
|
"generate_requested": True,
|
|
"cost": 0.033,
|
|
"detect_calls": 1,
|
|
"detect_cost": 0.002,
|
|
},
|
|
"trace": {"generate_called": True, "purpose": "profile_review"},
|
|
}
|
|
with patch("engine.execute_prompt", return_value=invalid):
|
|
response = client.post("/api/journal/writing-profile/review/api", headers=headers)
|
|
expect(response.status_code == 400, f"invalid result status {response.text}")
|
|
detail = response.json()["detail"]
|
|
expect(detail["code"] == "invalid_review_result", "parse failure keeps store code")
|
|
expect("JSON" in detail["message"] or "json" in detail["message"].lower(), "parse failure explains JSON")
|
|
expect(detail["cost_report"]["billed"] == "yes", "successful generate before parse failure is billed")
|
|
expect(detail["cost_report"]["generate_called"] is True, "parse failure still records generate")
|
|
expect(abs((detail["cost_report"]["cost"] or 0) - 0.033) < 1e-9, "generate cost survives parse failure")
|
|
|
|
print("ALL TESTS PASSED")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|