Three recently-added tables (strategic_log_translations, indicator_summary_translations, csv_format_templates) drifted from the codebase's existing naming convention: - llm_model -> model - llm_cost_usd -> cost_usd - content_md -> content (on the two translation tables; csv_format doesn't have a content field) Also added prompt_tokens and completion_tokens to the three tables; they were silently dropped at write time despite LogResult exposing them. All writer call sites (ai_log_job, indicator_summary_job, llm_csv_parser) and reader call sites (api.py localized helpers) updated to match. Tests realigned. Migration 0025 uses batch_alter_table for SQLite compatibility. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
361 lines
12 KiB
Python
361 lines
12 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 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" in cols
|
||
assert "generated_at" in cols
|
||
assert "model" in cols
|
||
assert "cost_usd" in cols
|
||
assert cols["log_id"].nullable is False
|
||
assert cols["lang"].nullable is False
|
||
assert cols["content"].nullable is False
|
||
|
||
|
||
async def test_log_translation_fanout_no_active_non_en_users(db_factory, 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 = db_factory
|
||
|
||
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(db_factory, 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 = db_factory
|
||
|
||
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.startswith("# Apertura")
|
||
assert row.model == "deepseek/deepseek-v4-flash"
|
||
assert row.cost_usd == pytest.approx(0.00002)
|
||
|
||
|
||
async def test_log_translation_fanout_per_language_failure_isolated(db_factory, 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 = db_factory
|
||
|
||
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(db_factory, 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 = db_factory
|
||
|
||
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(db_factory, 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 = db_factory
|
||
|
||
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_patch_language_accepts_active(db_factory):
|
||
"""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 = db_factory
|
||
|
||
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(db_factory):
|
||
"""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 = db_factory
|
||
|
||
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
|