read.markets/tests/test_localization_integration.py
Giorgio Gilestro 1ea71bc160 log: serve translated content when available; English fallback
Adds module-level _resolve_log_content(session, log_id, lang) helper
to app/routers/pages.py: looks up StrategicLogTranslation by (log_id,
lang) when lang != 'en'; falls back silently to the English original
when no translation row exists yet (the expected case for the first
hour after a new language activates, or when translation fails for a
specific log).

log_page / log_page_day pull cu.user.lang and thread it through
_log_page_context so the template renders the right variant.

Two tests cover both branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:13:57 +02:00

397 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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