- pyproject already sets asyncio_mode=auto, so async def tests are collected as async automatically. Removed the redundant decorator from four files (test_i18n, test_llm_csv_parser, test_ticker_validate, test_localization_integration); the bare async def is enough. - StrategicLogTranslation.log_id used the _PK autoincrement type for a non-PK FK column. Replaced with a portable BigInteger that emits Integer on SQLite and BigInteger elsewhere — matches the migration's sa.BigInteger() declaration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
447 lines
16 KiB
Python
447 lines
16 KiB
Python
"""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
|
||
|
||
|
||
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 == []
|
||
|
||
|
||
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)
|
||
|
||
|
||
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 == []
|
||
|
||
|
||
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
|
||
|
||
|
||
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
|
||
|
||
|
||
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] ")
|
||
|
||
|
||
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"
|
||
|
||
|
||
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
|
||
|
||
|
||
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
|
||
|
||
|
||
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"
|
||
|
||
|
||
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
|