"""Integration tests: model surface, ai_log_job translation fan-out, route-level localized fetch, settings PATCH validation.""" from __future__ import annotations import pytest def _build_session_factory(tmp_path): """Per-test sqlite engine + factory. Mirrors test_referral_conversion.py.""" from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app import db as db_mod from app.db import Base import app.models # noqa: F401 — registers models on Base.metadata engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/loc.db") factory = async_sessionmaker(engine, expire_on_commit=False) db_mod._engine = engine db_mod._session_factory = factory async def _setup(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) return engine, factory, _setup def test_user_has_lang_column_with_default_en(): from sqlalchemy import inspect from app.models import User cols = {c.name: c for c in inspect(User).columns} assert "lang" in cols assert cols["lang"].nullable is False # SQLAlchemy default may be a callable or a literal — check both. default = cols["lang"].default assert default is not None if hasattr(default, "arg"): assert default.arg == "en" def test_strategic_log_translation_model_columns(): from sqlalchemy import inspect from app.models import StrategicLogTranslation cols = {c.name: c for c in inspect(StrategicLogTranslation).columns} assert "log_id" in cols assert "lang" in cols assert "content_md" in cols assert "generated_at" in cols assert "llm_model" in cols assert "llm_cost_usd" in cols assert cols["log_id"].nullable is False assert cols["lang"].nullable is False assert cols["content_md"].nullable is False @pytest.mark.asyncio async def test_log_translation_fanout_no_active_non_en_users(tmp_path, monkeypatch): """When no users have an active non-en lang, the fan-out makes no translation calls and no rows are inserted.""" from unittest.mock import AsyncMock from sqlalchemy import select from app.db import utcnow from app.models import StrategicLog, StrategicLogTranslation, User from app.jobs import ai_log_job _, factory, setup = _build_session_factory(tmp_path) await setup() fake_translate = AsyncMock() monkeypatch.setattr(ai_log_job, "translate", fake_translate) # Seed an English user (no non-en users). async with factory() as session: session.add(User(id=1, email="en@x", tier="paid", lang="en")) slog = StrategicLog( generated_at=utcnow(), content="# Open\n\nDown 0.4%.", model="test-model", tone="INTERMEDIATE", analysis="NORMAL", ) session.add(slog) await session.commit() log_id = slog.id async with factory() as session: await ai_log_job.translate_log_for_active_languages(session, log_id) fake_translate.assert_not_awaited() async with factory() as session: rows = (await session.execute(select(StrategicLogTranslation))).scalars().all() assert rows == [] @pytest.mark.asyncio async def test_log_translation_fanout_italian_user(tmp_path, monkeypatch): """One user at lang=it triggers one translation; the row lands with the right lang and log_id.""" from sqlalchemy import select from app.db import utcnow from app.models import StrategicLog, StrategicLogTranslation, User from app.services.openrouter import LogResult from app.jobs import ai_log_job _, factory, setup = _build_session_factory(tmp_path) await setup() async def _fake_translate(client, text, target_lang): assert target_lang == "it" return "# Apertura\n\nIn calo 0,4%.", LogResult( content="# Apertura\n\nIn calo 0,4%.", model="deepseek/deepseek-v4-flash", prompt_tokens=300, completion_tokens=80, cost_usd=0.00002, ) monkeypatch.setattr(ai_log_job, "translate", _fake_translate) async with factory() as session: session.add(User(id=2, email="it@x", tier="paid", lang="it")) slog = StrategicLog( generated_at=utcnow(), content="# Open\n\nDown 0.4%.", model="test-model", tone="INTERMEDIATE", analysis="NORMAL", ) session.add(slog) await session.commit() log_id = slog.id async with factory() as session: await ai_log_job.translate_log_for_active_languages(session, log_id) async with factory() as session: rows = (await session.execute(select(StrategicLogTranslation))).scalars().all() assert len(rows) == 1 row = rows[0] assert row.log_id == log_id assert row.lang == "it" assert row.content_md.startswith("# Apertura") assert row.llm_model == "deepseek/deepseek-v4-flash" assert row.llm_cost_usd == pytest.approx(0.00002) @pytest.mark.asyncio async def test_log_translation_fanout_per_language_failure_isolated(tmp_path, monkeypatch): """If one language's translation fails, the others (if any) still land and the job does not raise.""" from sqlalchemy import select from app.db import utcnow from app.models import StrategicLog, StrategicLogTranslation, User from app.jobs import ai_log_job _, factory, setup = _build_session_factory(tmp_path) await setup() async def _fake_translate(client, text, target_lang): raise RuntimeError("upstream down") monkeypatch.setattr(ai_log_job, "translate", _fake_translate) async with factory() as session: session.add(User(id=3, email="it@x", tier="paid", lang="it")) slog = StrategicLog( generated_at=utcnow(), content="# Open", model="test-model", tone="INTERMEDIATE", analysis="NORMAL", ) session.add(slog) await session.commit() log_id = slog.id # Must NOT raise. async with factory() as session: await ai_log_job.translate_log_for_active_languages(session, log_id) async with factory() as session: rows = (await session.execute(select(StrategicLogTranslation))).scalars().all() assert rows == [] @pytest.mark.asyncio async def test_analyse_threads_lang_into_system_prompt(tmp_path, monkeypatch): """When lang='it', the system prompt sent to call_llm contains 'Respond in Italian.' — the LLM does the rest.""" from app.services import portfolio_analysis as pa from app.services.openrouter import LogResult captured = {} async def _fake_call_llm(client, messages, **kw): captured["messages"] = messages return LogResult( content="Analisi del portafoglio in italiano.", model="m", prompt_tokens=400, completion_tokens=100, cost_usd=0.0001, ) monkeypatch.setattr(pa, "call_llm", _fake_call_llm) _, factory, setup = _build_session_factory(tmp_path) await setup() payload = { "positions": [{"yahoo_ticker": "AAPL", "qty": 10, "avg_cost": 150.0, "currency": "USD", "name": "Apple Inc"}], "prices": {"AAPL": {"p": 172.4, "c": "USD"}}, "fx": {"USD": 1.0}, "base_currency": "USD", "tone": "INTERMEDIATE", "analysis": "NORMAL", "lang": "it", } req = pa.parse_request(payload) assert req.lang == "it" async with factory() as session: await pa.analyse(session, req) system = next(m["content"] for m in captured["messages"] if m["role"] == "system") assert "Respond in Italian" in system @pytest.mark.asyncio async def test_analyse_no_clause_when_lang_is_en(tmp_path, monkeypatch): from app.services import portfolio_analysis as pa from app.services.openrouter import LogResult captured = {} async def _fake_call_llm(client, messages, **kw): captured["messages"] = messages return LogResult( content="Portfolio analysis in English.", model="m", prompt_tokens=400, completion_tokens=100, cost_usd=0.0001, ) monkeypatch.setattr(pa, "call_llm", _fake_call_llm) _, factory, setup = _build_session_factory(tmp_path) await setup() payload = { "positions": [{"yahoo_ticker": "AAPL", "qty": 10, "avg_cost": 150.0, "currency": "USD", "name": "Apple Inc"}], "prices": {"AAPL": {"p": 172.4, "c": "USD"}}, "fx": {"USD": 1.0}, "base_currency": "USD", "tone": "INTERMEDIATE", "analysis": "NORMAL", "lang": "en", } req = pa.parse_request(payload) async with factory() as session: await pa.analyse(session, req) system = next(m["content"] for m in captured["messages"] if m["role"] == "system") assert "Respond in" not in system @pytest.mark.asyncio async def test_digest_translates_variants_per_active_lang(monkeypatch): """After English variants are built, the job translates each to every active non-en lang. The result is an in-memory mapping the send loop consults.""" from unittest.mock import MagicMock from app.jobs import email_digest_job as ed from app.services.openrouter import LogResult english_variants = { "NOVICE": "**Today.** Markets calmer.", "INTERMEDIATE": "**Today.** Indices slightly down.", "PRO": "**Today.** Risk-off rotation, breadth weak.", } translate_calls: list[tuple[str, str]] = [] async def _fake_translate(client, text, target_lang): translate_calls.append((text, target_lang)) return f"[IT] {text}", LogResult( content=f"[IT] {text}", model="m", prompt_tokens=10, completion_tokens=10, cost_usd=0.0, ) monkeypatch.setattr(ed, "translate", _fake_translate) client = MagicMock() table = await ed._translate_variants_for_active_langs( client, english_variants, ["it"], ) # Three tones × one non-en lang = three translation calls. assert len(translate_calls) == 3 assert {lang for _, lang in translate_calls} == {"it"} # English entries are present unchanged. assert table[("NOVICE", "en")] == english_variants["NOVICE"] assert table[("PRO", "en")] == english_variants["PRO"] # Italian entries are populated. assert table[("INTERMEDIATE", "it")].startswith("[IT] ") @pytest.mark.asyncio async def test_digest_translation_failure_falls_back_to_english(monkeypatch): """When translate() fails for a (tone, lang) cell, the table entry for that cell is the English variant of the same tone — the user still gets a digest, just in English that day.""" from unittest.mock import MagicMock from app.jobs import email_digest_job as ed english_variants = {"INTERMEDIATE": "**Today.** Indices down."} async def _fake_translate(client, text, target_lang): raise RuntimeError("upstream down") monkeypatch.setattr(ed, "translate", _fake_translate) client = MagicMock() table = await ed._translate_variants_for_active_langs( client, english_variants, ["it"], ) assert table[("INTERMEDIATE", "it")] == english_variants["INTERMEDIATE"] def test_digest_pick_variant_uses_user_lang(): """The variant-picker helper consults user.digest_tone + user.lang.""" from app.jobs import email_digest_job as ed table = { ("NOVICE", "en"): "novice en", ("NOVICE", "it"): "novice it", ("INTERMEDIATE", "en"): "intermediate en", ("INTERMEDIATE", "it"): "intermediate it", } assert ed._pick_variant(table, tone="NOVICE", lang="it") == "novice it" assert ed._pick_variant(table, tone="INTERMEDIATE", lang="en") == "intermediate en" # Missing lang → fallback to English variant of the same tone. assert ed._pick_variant(table, tone="NOVICE", lang="de") == "novice en" # Missing tone → fallback to INTERMEDIATE/en (the safe default). assert ed._pick_variant(table, tone="UNKNOWN", lang="en") == "intermediate en" @pytest.mark.asyncio async def test_log_endpoint_serves_italian_when_user_is_italian(tmp_path): """When a user with lang='it' opens /log, the served content is the Italian translation, not the English original.""" from app.db import utcnow from app.models import StrategicLog, StrategicLogTranslation, User _, factory, setup = _build_session_factory(tmp_path) await setup() async with factory() as session: session.add(User(id=10, email="it@x", tier="paid", lang="it")) slog = StrategicLog( generated_at=utcnow(), content="# Open\n\nDown 0.4%.", model="test-model", tone="INTERMEDIATE", analysis="NORMAL", ) session.add(slog) await session.commit() session.add(StrategicLogTranslation( log_id=slog.id, lang="it", content_md="# Apertura\n\nIn calo 0,4%.", generated_at=utcnow(), llm_model="m", llm_cost_usd=0.0, )) await session.commit() log_id = slog.id # Test the resolver directly. from app.routers.pages import _resolve_log_content async with factory() as session: user = await session.get(User, 10) content = await _resolve_log_content(session, log_id, user.lang) assert "Apertura" in content assert "Open" not in content @pytest.mark.asyncio async def test_log_endpoint_falls_back_to_english_when_no_translation(tmp_path): """User lang='it' but no IT translation exists → English fallback.""" from app.db import utcnow from app.models import StrategicLog, User _, factory, setup = _build_session_factory(tmp_path) await setup() async with factory() as session: session.add(User(id=11, email="it2@x", tier="paid", lang="it")) slog = StrategicLog( generated_at=utcnow(), content="# Open\n\nDown 0.4%.", model="test-model", tone="INTERMEDIATE", analysis="NORMAL", ) session.add(slog) await session.commit() log_id = slog.id from app.routers.pages import _resolve_log_content async with factory() as session: user = await session.get(User, 11) content = await _resolve_log_content(session, log_id, user.lang) assert "Open" in content @pytest.mark.asyncio async def test_patch_language_accepts_active(tmp_path): """PATCH /api/settings/language accepts 'en' and 'it' and persists.""" from app.models import User from app.routers.api import patch_language_prefs, LanguagePrefsIn _, factory, setup = _build_session_factory(tmp_path) await setup() async with factory() as session: session.add(User(id=20, email="u@x", tier="paid", lang="en")) await session.commit() class _P: is_admin = False def __init__(self, u): self.user = u async with factory() as session: user = await session.get(User, 20) result = await patch_language_prefs( payload=LanguagePrefsIn(lang="it"), principal=_P(user), session=session, ) assert result.lang == "it" async with factory() as session: user = await session.get(User, 20) assert user.lang == "it" @pytest.mark.asyncio async def test_patch_language_rejects_wip(tmp_path): """PATCH rejects 'es'/'fr'/'de'/'xx' with 400 — ACTIVE_LANGUAGES gate.""" from fastapi import HTTPException from app.models import User from app.routers.api import patch_language_prefs, LanguagePrefsIn _, factory, setup = _build_session_factory(tmp_path) await setup() async with factory() as session: session.add(User(id=21, email="u2@x", tier="paid", lang="en")) await session.commit() class _P: is_admin = False def __init__(self, u): self.user = u for bad in ("es", "fr", "de", "xx"): async with factory() as session: user = await session.get(User, 21) with pytest.raises(HTTPException) as exc: await patch_language_prefs( payload=LanguagePrefsIn(lang=bad), principal=_P(user), session=session, ) assert exc.value.status_code == 400