KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS: - raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed - dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack - unchained: Cloudflare 403 → canonical Megaphone podcast feed - lynalden: Cloudflare 202 → FeedBurner mirror - glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1) - browser User-Agent + Accept headers on feed fetch - removed dead feeds with no active replacement: placeholder, dragonfly, niccarter, eugene - pin h2==4.3.0 (required by http2=True) All 25 remaining feeds verified fetching real body content; newest post per feed ≤88d. Bundles in-flight KOL-module work already in the working tree (kol_x ingest, migration 027, tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user