From 671faed7079474f8224ebbdc70b31ec8ffd2c642 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 22:49:21 +0200 Subject: [PATCH] news: clamp free + anonymous to last 6h; paid keeps 24h Co-Authored-By: Claude Sonnet 4.6 --- app/routers/api.py | 15 +++++-- app/services/access.py | 4 ++ app/templates/partials/news.html | 7 +++ tests/test_news_window.py | 73 ++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/test_news_window.py diff --git a/app/routers/api.py b/app/routers/api.py index 84e8ad6..66206f6 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -19,7 +19,7 @@ from collections import defaultdict import httpx from pydantic import BaseModel, Field -from app.auth import require_token +from app.auth import require_token, maybe_current_user, CurrentUser from app.config import get_settings from app.db import get_session, utcnow from app.services.openrouter import ( @@ -228,11 +228,18 @@ async def news_list( limit: int = Query(50, ge=1, le=500), tags: str | None = Query(None, description="comma-separated include list"), exclude_tags: str | None = Query(None, description="comma-separated exclude list"), + principal: CurrentUser | None = Depends(maybe_current_user), as_: str | None = Query(default=None, alias="as"), ): from app.services.news_tagging import TAG_LABELS, TAG_VOCABULARY + from app.services.access import FREE_NEWS_WINDOW_HOURS, is_paid_active - cutoff = utcnow() - timedelta(hours=since_hours) + effective_hours = since_hours + capped = not is_paid_active(principal) + if capped: + effective_hours = min(since_hours, FREE_NEWS_WINDOW_HOURS) + + cutoff = utcnow() - timedelta(hours=effective_hours) stmt = select(Headline).where(Headline.published_at >= cutoff) if category: stmt = stmt.where(Headline.category == category) @@ -275,7 +282,9 @@ async def news_list( "tag_vocabulary": TAG_VOCABULARY, "tag_labels": TAG_LABELS, "active_include": sorted(include), - "active_exclude": sorted(exclude)}, + "active_exclude": sorted(exclude), + "capped": capped, + "window_hours": effective_hours}, ) return [HeadlineOut.model_validate(r, from_attributes=True) for r in filtered] diff --git a/app/services/access.py b/app/services/access.py index 9066f1d..731114a 100644 --- a/app/services/access.py +++ b/app/services/access.py @@ -22,6 +22,10 @@ from fastapi import Depends, HTTPException, status from app.auth import CurrentUser, require_auth from app.models import User +# How many hours of news the free tier sees. Paid sees whatever the +# endpoint's `since_hours` param requests (up to its own max). +FREE_NEWS_WINDOW_HOURS = 6.0 + def _utcnow() -> datetime: return datetime.now(timezone.utc) diff --git a/app/templates/partials/news.html b/app/templates/partials/news.html index 36b8a59..5f19f4d 100644 --- a/app/templates/partials/news.html +++ b/app/templates/partials/news.html @@ -33,3 +33,10 @@ {% endfor %} {% endif %} +{% if capped %} +
+ Free tier — showing the last {{ window_hours|int }} hours of news. + Upgrade + for the full 24-hour feed plus daily and weekly email digests. +
+{% endif %} diff --git a/tests/test_news_window.py b/tests/test_news_window.py new file mode 100644 index 0000000..ddaa0fa --- /dev/null +++ b/tests/test_news_window.py @@ -0,0 +1,73 @@ +"""Free vs paid window clamp on /api/news. + +Integration-style: spins up a real router over an in-memory aiosqlite DB. +Skips on hosts that lack aiosqlite + httpx — same pattern as +test_portfolio_sync_api.py.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone + +import pytest + + +def _build_app(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.auth import sign_session + from app.db import Base + from app.models import Headline, User + from app.routers import api as api_router + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/news.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + now = datetime.now(timezone.utc) + + async def _seed(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with factory() as s: + s.add(User(id=1, email="free@x", tier="free")) + s.add(User(id=2, email="paid@x", tier="paid")) + for hours_old, title in ((1, "fresh"), (12, "mid"), (20, "old")): + s.add(Headline( + source="test", title=title, url=f"https://e/{title}", + category="general", + published_at=now - timedelta(hours=hours_old), + fetched_at=now, + tags=[], + )) + await s.commit() + + asyncio.run(_seed()) + + app = FastAPI() + app.include_router(api_router.router, prefix="/api") + client = TestClient(app) + return client, sign_session(1), sign_session(2) + + +def test_free_user_clamped_to_6h(tmp_path): + client, free_sess, _ = _build_app(tmp_path) + r = client.get("/api/news?since_hours=24", + cookies={"cassandra_session": free_sess}) + assert r.status_code == 200, r.text + titles = [h["title"] for h in r.json()] + assert "fresh" in titles + assert "mid" not in titles # 12h ago, beyond 6h + assert "old" not in titles + + +def test_paid_user_full_24h(tmp_path): + client, _, paid_sess = _build_app(tmp_path) + r = client.get("/api/news?since_hours=24", + cookies={"cassandra_session": paid_sess}) + assert r.status_code == 200, r.text + titles = [h["title"] for h in r.json()] + assert {"fresh", "mid", "old"} <= set(titles)