news: clamp free + anonymous to last 6h; paid keeps 24h
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a7d657e1b4
commit
671faed707
4 changed files with 96 additions and 3 deletions
|
|
@ -19,7 +19,7 @@ from collections import defaultdict
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel, Field
|
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.config import get_settings
|
||||||
from app.db import get_session, utcnow
|
from app.db import get_session, utcnow
|
||||||
from app.services.openrouter import (
|
from app.services.openrouter import (
|
||||||
|
|
@ -228,11 +228,18 @@ async def news_list(
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
tags: str | None = Query(None, description="comma-separated include list"),
|
tags: str | None = Query(None, description="comma-separated include list"),
|
||||||
exclude_tags: str | None = Query(None, description="comma-separated exclude 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"),
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
):
|
):
|
||||||
from app.services.news_tagging import TAG_LABELS, TAG_VOCABULARY
|
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)
|
stmt = select(Headline).where(Headline.published_at >= cutoff)
|
||||||
if category:
|
if category:
|
||||||
stmt = stmt.where(Headline.category == category)
|
stmt = stmt.where(Headline.category == category)
|
||||||
|
|
@ -275,7 +282,9 @@ async def news_list(
|
||||||
"tag_vocabulary": TAG_VOCABULARY,
|
"tag_vocabulary": TAG_VOCABULARY,
|
||||||
"tag_labels": TAG_LABELS,
|
"tag_labels": TAG_LABELS,
|
||||||
"active_include": sorted(include),
|
"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]
|
return [HeadlineOut.model_validate(r, from_attributes=True) for r in filtered]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ from fastapi import Depends, HTTPException, status
|
||||||
from app.auth import CurrentUser, require_auth
|
from app.auth import CurrentUser, require_auth
|
||||||
from app.models import User
|
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:
|
def _utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,10 @@
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if capped %}
|
||||||
|
<div class="news-capped-note" style="margin-top:14px; padding:10px 12px; border:1px dashed var(--border); color:var(--muted); font-size:12px; line-height:1.55;">
|
||||||
|
Free tier — showing the last {{ window_hours|int }} hours of news.
|
||||||
|
<a href="/pricing" style="color:var(--accent);">Upgrade</a>
|
||||||
|
for the full 24-hour feed plus daily and weekly email digests.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
|
||||||
73
tests/test_news_window.py
Normal file
73
tests/test_news_window.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue