from __future__ import annotations from datetime import datetime, timedelta import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.api.posts import router as posts_router from app.database import get_db from app.models import Base, Post @pytest.mark.asyncio async def test_posts_paged_filters_and_counts(): engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True) session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) now = datetime(2026, 5, 30, 12, 0, 0) async with session_factory() as session: session.add_all([ Post( external_id="truth-buy", text="Bullish signal", source="truth", published_at=now, sentiment="bullish", signal="buy", ai_confidence=91, ai_reasoning="AI saw upside", relevant=True, ), Post( external_id="truth-short", text="Bearish signal", source="truth", published_at=now - timedelta(minutes=1), sentiment="bearish", signal="short", ai_confidence=88, ai_reasoning="AI saw downside", relevant=True, ), Post( external_id="truth-hold", text="Neutral but scored", source="truth", published_at=now - timedelta(minutes=2), sentiment="neutral", signal="hold", ai_confidence=45, ai_reasoning="No trade edge", relevant=True, ), Post( external_id="truth-noise", text="Off-topic golf post", source="truth", published_at=now - timedelta(minutes=3), sentiment="neutral", signal=None, ai_confidence=0, ai_reasoning=None, relevant=False, ), Post( external_id="macro-buy", text="Macro buy", source="btc_bottom_reversal", published_at=now - timedelta(minutes=4), sentiment="bullish", signal="buy", ai_confidence=97, ai_reasoning="Macro setup", relevant=True, ), Post( external_id="archive-breakout", text="Legacy breakout signal", source="breakout", published_at=now - timedelta(minutes=5), sentiment="bullish", signal="buy", ai_confidence=73, ai_reasoning="Old scanner", relevant=True, ), Post( external_id="archive-sma", text="Legacy sma reclaim signal", source="sma_reclaim", published_at=now - timedelta(minutes=6), sentiment="bullish", signal="short", ai_confidence=64, ai_reasoning="Old scanner 2", relevant=True, ), ]) await session.commit() app = FastAPI() app.include_router(posts_router, prefix="/api") async def override_get_db(): async with session_factory() as session: yield session app.dependency_overrides[get_db] = override_get_db transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://testserver") as client: res = await client.get("/api/posts-paged", params={"source": "truth", "limit": 10, "page": 1}) assert res.status_code == 200 data = res.json() assert data["total"] == 4 assert len(data["items"]) == 4 assert data["counts"] == { "all": 4, "actionable": 2, "buy": 1, "short": 1, "off_topic": 1, } assert data["source_counts"] == [{"source": "truth", "count": 4, "latest": "2026-05-30T12:00:00.000Z"}] actionable = await client.get( "/api/posts-paged", params={"source": "truth", "signal": "actionable", "limit": 10, "page": 1}, ) assert actionable.status_code == 200 actionable_data = actionable.json() assert actionable_data["total"] == 2 assert {item["signal"] for item in actionable_data["items"]} == {"buy", "short"} assert actionable_data["counts"]["all"] == 4 scored_only = await client.get( "/api/posts-paged", params={"source": "truth", "ai_scored_only": "true", "limit": 10, "page": 1}, ) assert scored_only.status_code == 200 scored_data = scored_only.json() assert scored_data["total"] == 3 assert all(item["ai_confidence"] > 0 or item["ai_reasoning"] for item in scored_data["items"]) assert scored_data["counts"] == { "all": 3, "actionable": 2, "buy": 1, "short": 1, "off_topic": 1, } bearish_buy = await client.get( "/api/posts-paged", params={ "source": "truth", "sentiment": "bearish", "signal": "buy", "limit": 10, "page": 1, }, ) assert bearish_buy.status_code == 200 bearish_buy_data = bearish_buy.json() assert bearish_buy_data["total"] == 0 assert bearish_buy_data["counts"] == { "all": 1, "actionable": 1, "buy": 0, "short": 1, "off_topic": 0, } archive = await client.get( "/api/posts-paged", params={ "archive_only": "true", "limit": 10, "page": 1, }, ) assert archive.status_code == 200 archive_data = archive.json() assert archive_data["total"] == 2 assert len(archive_data["items"]) == 2 assert {item["source"] for item in archive_data["items"]} == {"breakout", "sma_reclaim"} assert archive_data["source_counts"] == [ {"source": "breakout", "count": 1, "latest": "2026-05-30T11:55:00.000Z"}, {"source": "sma_reclaim", "count": 1, "latest": "2026-05-30T11:54:00.000Z"}, ] # Regression: selecting one archive source via source_in must narrow the # paged items/total, but source_counts (the chip bar) must STILL list # every archived source so the UI can offer a way back to "all". archive_one = await client.get( "/api/posts-paged", params={ "archive_only": "true", "source_in": "breakout", "limit": 10, "page": 1, }, ) assert archive_one.status_code == 200 archive_one_data = archive_one.json() assert archive_one_data["total"] == 1 assert {item["source"] for item in archive_one_data["items"]} == {"breakout"} # Chip bar is NOT collapsed to the selected source. assert archive_one_data["source_counts"] == [ {"source": "breakout", "count": 1, "latest": "2026-05-30T11:55:00.000Z"}, {"source": "sma_reclaim", "count": 1, "latest": "2026-05-30T11:54:00.000Z"}, ] archive_compat = await client.get( "/api/posts-paged", params={ "archive_only": "true", "source_not_in": "truth", "limit": 10, "page": 1, }, ) assert archive_compat.status_code == 200 archive_compat_data = archive_compat.json() assert archive_compat_data["total"] == 2 assert {item["source"] for item in archive_compat_data["items"]} == {"breakout", "sma_reclaim"} await engine.dispose()