"""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_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-profile-review-errors-test.sqlite") os.environ["KANSHO_FAKE_PROVIDER"] = "1" os.environ["KANSHO_FAKE_DETECT"] = "1" Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True) 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()