feat(sponsor-prompt): in-app toast at earned milestones

ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
      sponsor conversion at 0.08% — roughly an order of magnitude under
      industry-benchmark for OSS with visible CTA. The Settings banner from
      0d4b9d4e gives passive every-visit visibility on one page; this adds
      opt-out-able active visibility at moments where the user has just
      earned something with Bambuddy.

      Five trigger families with a 14-day cross-family cooldown: prints
      (100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
      energy), archives (50/250/1000), anniversary (1 year), version-update
      (re-armable on each major bump). New sponsor_toast_state table with
      nullable user_id so auth-disabled installs get the same trigger logic
      through one code path (NULL-keyed install-default row).
This commit is contained in:
maziggy
2026-06-28 12:38:31 +02:00
25 changed files with 967 additions and 13 deletions
+55
View File
@@ -0,0 +1,55 @@
"""API routes for the in-app sponsor toast."""
import logging
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.user import User
from backend.app.schemas.sponsor_prompt import (
SponsorPromptCheckResponse,
SponsorPromptDismissRequest,
)
from backend.app.services import sponsor_prompt as service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sponsor-prompt", tags=["sponsor-prompt"])
def _user_id(current_user: User | None) -> int | None:
return current_user.id if current_user is not None else None
@router.get("/check", response_model=SponsorPromptCheckResponse)
async def check_sponsor_prompt(
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
db: AsyncSession = Depends(get_db),
):
"""Return the next eligible sponsor-toast trigger, or `{show: false}`."""
trigger = await service.evaluate(db, _user_id(current_user))
await db.commit()
if trigger is None:
return SponsorPromptCheckResponse(show=False)
return SponsorPromptCheckResponse(
show=True,
milestone=trigger.milestone,
family=trigger.family,
threshold=trigger.threshold,
payload=trigger.payload,
)
@router.post("/dismiss", status_code=status.HTTP_204_NO_CONTENT)
async def dismiss_sponsor_prompt(
data: SponsorPromptDismissRequest,
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
db: AsyncSession = Depends(get_db),
):
"""Anchor the 14-day cooldown and record the milestone as shown."""
await service.dismiss(db, _user_id(current_user), data.milestone)
await db.commit()
return None
+2
View File
@@ -57,6 +57,7 @@ from backend.app.api.routes import (
slice_jobs,
slicer_presets,
smart_plugs,
sponsor_prompt,
spoolbuddy,
spoolman,
spoolman_inventory,
@@ -6525,6 +6526,7 @@ app.include_router(user_notifications.router, prefix=app_settings.api_prefix)
app.include_router(spoolman.router, prefix=app_settings.api_prefix)
app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
app.include_router(camera.router, prefix=app_settings.api_prefix)
app.include_router(external_links.router, prefix=app_settings.api_prefix)
+2
View File
@@ -25,6 +25,7 @@ from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
from backend.app.models.sponsor_toast_state import SponsorToastState
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spool_catalog import SpoolCatalogEntry
@@ -75,6 +76,7 @@ __all__ = [
"SpoolUsageHistory",
"ColorCatalogEntry",
"SpoolBuddyDevice",
"SponsorToastState",
"UserEmailPreference",
"UserOTPCode",
"UserTOTP",
+39
View File
@@ -0,0 +1,39 @@
"""Per-user (or install-default) state for the sponsor-prompt toast.
A single row stores which sponsor-toast milestones have already fired for a
given user, when the most recent toast was shown (for the 14-day cooldown),
and the app version last seen so we can fire the "version-update" trigger
exactly once per major bump.
``user_id`` is nullable: in auth-disabled installs (no user concept), the
service stores everything against a single NULL-keyed row.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class SponsorToastState(Base):
__tablename__ = "sponsor_toast_state"
__table_args__ = (UniqueConstraint("user_id", name="uq_sponsor_toast_state_user_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
last_shown_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# JSON-serialised list[str] of milestone keys already fired (e.g. ["prints-100", "cost-100"]).
# Stored as Text for SQLite/Postgres uniformity; the service serialises with json.dumps.
milestones_seen: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
last_seen_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+23
View File
@@ -0,0 +1,23 @@
"""Pydantic schemas for the sponsor-prompt API."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class SponsorPromptCheckResponse(BaseModel):
"""Result of GET /sponsor-prompt/check."""
show: bool
milestone: str | None = None
family: str | None = None
threshold: int | None = None
payload: dict[str, Any] = Field(default_factory=dict)
class SponsorPromptDismissRequest(BaseModel):
"""Body of POST /sponsor-prompt/dismiss."""
milestone: str
+256
View File
@@ -0,0 +1,256 @@
"""Sponsor-prompt trigger evaluator and dismiss handler.
Drives the in-app "support keeps Bambuddy independent" toast. Trigger families
fire at milestones the user has earned (prints, archives, filament cost,
anniversary) plus a soft version-update nudge after a major upgrade.
A 14-day cooldown applies across ALL families: if any toast fired in the last
14 days, no new toast fires. Each individual milestone is shown at most once
per user (or once per install in auth-disabled mode); version-update is the
exception — it re-arms every time the running version is newer than the one
last acknowledged.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import APP_VERSION
from backend.app.models.archive import PrintArchive
from backend.app.models.print_log import PrintLogEntry
from backend.app.models.sponsor_toast_state import SponsorToastState
from backend.app.models.user import User
logger = logging.getLogger(__name__)
COOLDOWN_DAYS = 14
PRINT_MILESTONES = (100, 500, 1000, 2500, 5000)
COST_MILESTONES = (100, 500, 1000)
ARCHIVE_MILESTONES = (50, 250, 1000)
ANNIVERSARY_YEARS = 1
@dataclass
class Trigger:
"""Evaluated trigger result returned to the frontend."""
milestone: str # e.g. "prints-500", "anniversary-1", "version-update"
family: str # "prints" | "cost" | "archives" | "anniversary" | "version-update"
threshold: int | None = None
payload: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# State helpers
# ---------------------------------------------------------------------------
async def _get_or_create_state(db: AsyncSession, user_id: int | None) -> SponsorToastState:
"""Fetch the state row for this user (or the install-default NULL row).
Creates the row lazily on first access so the migration doesn't need to
seed anything.
"""
if user_id is None:
stmt = select(SponsorToastState).where(SponsorToastState.user_id.is_(None))
else:
stmt = select(SponsorToastState).where(SponsorToastState.user_id == user_id)
result = await db.execute(stmt)
state = result.scalar_one_or_none()
if state is None:
state = SponsorToastState(user_id=user_id, milestones_seen="[]")
db.add(state)
await db.flush()
return state
def _within_cooldown(state: SponsorToastState) -> bool:
if state.last_shown_at is None:
return False
cutoff = datetime.now(timezone.utc) - timedelta(days=COOLDOWN_DAYS)
last = state.last_shown_at
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return last >= cutoff
def _seen_milestones(state: SponsorToastState) -> set[str]:
try:
raw = json.loads(state.milestones_seen or "[]")
return set(raw) if isinstance(raw, list) else set()
except (json.JSONDecodeError, TypeError):
logger.warning(
"sponsor_toast_state.milestones_seen for user=%s was not valid JSON; resetting",
state.user_id,
)
return set()
# ---------------------------------------------------------------------------
# Per-family checks
# ---------------------------------------------------------------------------
def _user_filter(column, user_id: int | None):
return column.is_(None) if user_id is None else column == user_id
async def _check_anniversary(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
milestone = f"anniversary-{ANNIVERSARY_YEARS}"
if milestone in seen:
return None
if user_id is None:
# Install-anchor = earliest users.created_at (the first admin row).
result = await db.execute(select(func.min(User.created_at)))
anchor = result.scalar()
else:
result = await db.execute(select(User.created_at).where(User.id == user_id))
anchor = result.scalar()
if anchor is None:
return None
if anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - anchor < timedelta(days=365 * ANNIVERSARY_YEARS):
return None
return Trigger(milestone=milestone, family="anniversary")
async def _check_prints(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
stmt = (
select(func.count())
.select_from(PrintLogEntry)
.where(
PrintLogEntry.status == "completed",
_user_filter(PrintLogEntry.created_by_id, user_id),
)
)
completed = (await db.execute(stmt)).scalar() or 0
# Pick the LARGEST milestone the user has crossed but not yet seen.
for threshold in sorted(PRINT_MILESTONES, reverse=True):
key = f"prints-{threshold}"
if completed >= threshold and key not in seen:
return Trigger(
milestone=key,
family="prints",
threshold=threshold,
payload={"count": completed},
)
return None
async def _check_archives(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
stmt = select(func.count()).select_from(PrintArchive).where(_user_filter(PrintArchive.created_by_id, user_id))
archived = (await db.execute(stmt)).scalar() or 0
for threshold in sorted(ARCHIVE_MILESTONES, reverse=True):
key = f"archives-{threshold}"
if archived >= threshold and key not in seen:
return Trigger(
milestone=key,
family="archives",
threshold=threshold,
payload={"count": archived},
)
return None
async def _check_cost(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
stmt = (
select(func.coalesce(func.sum(PrintLogEntry.cost), 0) + func.coalesce(func.sum(PrintLogEntry.energy_cost), 0))
.select_from(PrintLogEntry)
.where(_user_filter(PrintLogEntry.created_by_id, user_id))
)
total = float((await db.execute(stmt)).scalar() or 0)
for threshold in sorted(COST_MILESTONES, reverse=True):
key = f"cost-{threshold}"
if total >= threshold and key not in seen:
return Trigger(
milestone=key,
family="cost",
threshold=threshold,
payload={"total": round(total, 2)},
)
return None
async def _check_version_update(
_db: AsyncSession, _user_id: int | None, _seen: set[str], state: SponsorToastState
) -> Trigger | None:
# version-update is NOT in milestones_seen — it has its own state column
# so it can re-fire on each major bump.
if not APP_VERSION:
return None
last = state.last_seen_version
if last is None:
# First-ever read; treat as already-acknowledged so we don't toast
# immediately on a brand-new install. Persist current version silently.
state.last_seen_version = APP_VERSION
return None
if last == APP_VERSION:
return None
return Trigger(
milestone="version-update",
family="version-update",
payload={"from": last, "to": APP_VERSION},
)
# Priority order: most emotional / earned first; version-update is the soft fallback.
_CHECKS = (
_check_anniversary,
_check_prints,
_check_archives,
_check_cost,
_check_version_update,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def evaluate(db: AsyncSession, user_id: int | None) -> Trigger | None:
"""Return the next eligible sponsor-toast trigger, or None."""
state = await _get_or_create_state(db, user_id)
if _within_cooldown(state):
return None
seen = _seen_milestones(state)
for check in _CHECKS:
trigger = await check(db, user_id, seen, state)
if trigger is not None:
return trigger
# No triggers eligible — still commit any in-progress state changes
# (e.g. version-update's first-touch persistence).
await db.flush()
return None
async def dismiss(db: AsyncSession, user_id: int | None, milestone: str) -> None:
"""Mark a milestone as shown (sets cooldown anchor + records seen)."""
state = await _get_or_create_state(db, user_id)
if milestone == "version-update":
# Re-armable: just update last_seen_version, don't add to seen-list.
state.last_seen_version = APP_VERSION
else:
seen = _seen_milestones(state)
if milestone not in seen:
seen.add(milestone)
state.milestones_seen = json.dumps(sorted(seen))
state.last_shown_at = datetime.now(timezone.utc)
await db.flush()
+1
View File
@@ -139,6 +139,7 @@ async def test_engine():
slot_preset,
smart_plug,
smart_plug_energy_snapshot, # noqa: F401
sponsor_toast_state, # noqa: F401
spool,
spool_assignment,
spool_catalog,
@@ -0,0 +1,48 @@
"""Integration tests for /sponsor-prompt routes."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
class TestSponsorPromptAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_check_returns_show_false_for_empty_install(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/sponsor-prompt/check")
assert response.status_code == 200
body = response.json()
assert body["show"] is False
assert body.get("milestone") is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dismiss_requires_milestone(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/sponsor-prompt/dismiss", json={})
# Pydantic missing-field → 422.
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dismiss_returns_204(self, async_client: AsyncClient):
response = await async_client.post(
"/api/v1/sponsor-prompt/dismiss",
json={"milestone": "version-update"},
)
assert response.status_code == 204
@pytest.mark.asyncio
@pytest.mark.integration
async def test_check_then_dismiss_then_recheck_is_silent(self, async_client: AsyncClient):
"""End-to-end: even if no trigger is currently eligible, dismissing
anchors the cooldown, so a subsequent check stays {show: false}."""
first = await async_client.get("/api/v1/sponsor-prompt/check")
assert first.json()["show"] is False
dismiss = await async_client.post(
"/api/v1/sponsor-prompt/dismiss",
json={"milestone": "version-update"},
)
assert dismiss.status_code == 204
second = await async_client.get("/api/v1/sponsor-prompt/check")
assert second.json()["show"] is False
@@ -0,0 +1,327 @@
"""Unit tests for the sponsor-prompt trigger evaluator."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.archive import PrintArchive
from backend.app.models.print_log import PrintLogEntry
from backend.app.models.sponsor_toast_state import SponsorToastState
from backend.app.models.user import User
from backend.app.services import sponsor_prompt as service
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _make_user(db: AsyncSession, *, username: str = "alice", created_days_ago: int = 0) -> User:
user = User(username=username, role="admin")
db.add(user)
await db.flush()
if created_days_ago:
user.created_at = datetime.now(timezone.utc) - timedelta(days=created_days_ago)
await db.flush()
return user
async def _add_completed_prints(db: AsyncSession, *, user_id: int | None, count: int, cost_each: float = 0.0) -> None:
for _ in range(count):
db.add(
PrintLogEntry(
status="completed",
created_by_id=user_id,
cost=cost_each if cost_each else None,
)
)
await db.flush()
async def _add_archives(db: AsyncSession, *, user_id: int | None, count: int) -> None:
for i in range(count):
db.add(
PrintArchive(
filename=f"archive-{i}.zip",
file_path=f"/tmp/archive-{i}.zip",
file_size=1024,
created_by_id=user_id,
)
)
await db.flush()
# ---------------------------------------------------------------------------
# Empty / no-eligibility cases
# ---------------------------------------------------------------------------
class TestEmptyState:
@pytest.mark.asyncio
async def test_evaluate_returns_none_for_fresh_user(self, db_session: AsyncSession):
user = await _make_user(db_session)
trigger = await service.evaluate(db_session, user.id)
assert trigger is None
@pytest.mark.asyncio
async def test_state_row_is_created_lazily(self, db_session: AsyncSession):
user = await _make_user(db_session)
await service.evaluate(db_session, user.id)
from sqlalchemy import select
row = (
await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
).scalar_one_or_none()
assert row is not None
assert row.milestones_seen == "[]"
# ---------------------------------------------------------------------------
# Cooldown
# ---------------------------------------------------------------------------
class TestCooldown:
@pytest.mark.asyncio
async def test_no_toast_within_14d_window(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=200)
# Pre-populate state with a recent last_shown_at
state = SponsorToastState(
user_id=user.id,
last_shown_at=datetime.now(timezone.utc) - timedelta(days=3),
)
db_session.add(state)
await db_session.flush()
trigger = await service.evaluate(db_session, user.id)
assert trigger is None
@pytest.mark.asyncio
async def test_toast_eligible_after_14d_window(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=200)
state = SponsorToastState(
user_id=user.id,
last_shown_at=datetime.now(timezone.utc) - timedelta(days=15),
)
db_session.add(state)
await db_session.flush()
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.family == "prints"
# ---------------------------------------------------------------------------
# Per-family triggers
# ---------------------------------------------------------------------------
class TestPrintMilestones:
@pytest.mark.asyncio
async def test_fires_at_100(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=100)
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.milestone == "prints-100"
assert trigger.threshold == 100
@pytest.mark.asyncio
async def test_picks_highest_unseen_milestone(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=600)
trigger = await service.evaluate(db_session, user.id)
# 500 is the highest crossed milestone (1000 not reached).
assert trigger is not None
assert trigger.milestone == "prints-500"
@pytest.mark.asyncio
async def test_skips_already_seen(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=600)
# Mark prints-500 as already seen — but NOT prints-100.
# Service should fall through to the next-largest unseen, which is prints-100.
state = SponsorToastState(
user_id=user.id,
milestones_seen=json.dumps(["prints-500"]),
)
db_session.add(state)
await db_session.flush()
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.milestone == "prints-100"
@pytest.mark.asyncio
async def test_failed_prints_dont_count(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=50)
for _ in range(60):
db_session.add(PrintLogEntry(status="failed", created_by_id=user.id))
await db_session.flush()
trigger = await service.evaluate(db_session, user.id)
# Only 50 completed → below 100 threshold → no print trigger.
# Anniversary not reached either; no other counter populated.
assert trigger is None
class TestArchiveMilestones:
@pytest.mark.asyncio
async def test_fires_at_50(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_archives(db_session, user_id=user.id, count=50)
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.milestone == "archives-50"
class TestCostMilestones:
@pytest.mark.asyncio
async def test_fires_when_cost_sum_crosses_100(self, db_session: AsyncSession):
# Prints with cost = ~3.5 each, 30 prints → 105.
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=30, cost_each=3.5)
# 30 < 100 prints, so prints-100 not eligible. cost = 105 ≥ 100 → fires.
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.family == "cost"
assert trigger.milestone == "cost-100"
class TestAnniversary:
@pytest.mark.asyncio
async def test_fires_after_1_year(self, db_session: AsyncSession):
user = await _make_user(db_session, created_days_ago=370)
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.milestone == "anniversary-1"
assert trigger.family == "anniversary"
@pytest.mark.asyncio
async def test_does_not_fire_before_1_year(self, db_session: AsyncSession):
user = await _make_user(db_session, created_days_ago=300)
trigger = await service.evaluate(db_session, user.id)
assert trigger is None
class TestVersionUpdate:
@pytest.mark.asyncio
async def test_first_read_silently_anchors(self, db_session: AsyncSession):
user = await _make_user(db_session)
with patch.object(service, "APP_VERSION", "0.3.0"):
trigger = await service.evaluate(db_session, user.id)
assert trigger is None
from sqlalchemy import select
state = (
await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
).scalar_one()
assert state.last_seen_version == "0.3.0"
@pytest.mark.asyncio
async def test_fires_on_version_bump(self, db_session: AsyncSession):
user = await _make_user(db_session)
state = SponsorToastState(user_id=user.id, last_seen_version="0.2.0")
db_session.add(state)
await db_session.flush()
with patch.object(service, "APP_VERSION", "0.3.0"):
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.milestone == "version-update"
assert trigger.payload == {"from": "0.2.0", "to": "0.3.0"}
# ---------------------------------------------------------------------------
# Priority order
# ---------------------------------------------------------------------------
class TestPriorityOrder:
@pytest.mark.asyncio
async def test_anniversary_beats_prints(self, db_session: AsyncSession):
# User old enough for anniversary AND with 100+ prints.
user = await _make_user(db_session, created_days_ago=400)
await _add_completed_prints(db_session, user_id=user.id, count=200)
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.family == "anniversary"
@pytest.mark.asyncio
async def test_prints_beats_archives(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=200)
await _add_archives(db_session, user_id=user.id, count=100)
trigger = await service.evaluate(db_session, user.id)
assert trigger is not None
assert trigger.family == "prints"
# ---------------------------------------------------------------------------
# Dismiss
# ---------------------------------------------------------------------------
class TestDismiss:
@pytest.mark.asyncio
async def test_dismiss_adds_to_seen_and_anchors_cooldown(self, db_session: AsyncSession):
user = await _make_user(db_session)
await _add_completed_prints(db_session, user_id=user.id, count=100)
await service.evaluate(db_session, user.id)
await service.dismiss(db_session, user.id, "prints-100")
from sqlalchemy import select
state = (
await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
).scalar_one()
assert "prints-100" in json.loads(state.milestones_seen)
assert state.last_shown_at is not None
# Re-evaluation must now return None (cooldown).
next_trigger = await service.evaluate(db_session, user.id)
assert next_trigger is None
@pytest.mark.asyncio
async def test_version_update_dismiss_updates_version_not_seen_list(self, db_session: AsyncSession):
user = await _make_user(db_session)
state = SponsorToastState(user_id=user.id, last_seen_version="0.2.0")
db_session.add(state)
await db_session.flush()
with patch.object(service, "APP_VERSION", "0.3.0"):
await service.dismiss(db_session, user.id, "version-update")
from sqlalchemy import select
state = (
await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
).scalar_one()
assert state.last_seen_version == "0.3.0"
assert json.loads(state.milestones_seen) == []
# ---------------------------------------------------------------------------
# Auth-disabled (user_id = None) — NULL-keyed install-default row
# ---------------------------------------------------------------------------
class TestAuthDisabledMode:
@pytest.mark.asyncio
async def test_uses_install_anchor_for_anniversary(self, db_session: AsyncSession):
# In auth-disabled mode, anniversary anchor = MIN(users.created_at).
# Seed a user from >1 year ago.
await _make_user(db_session, username="root", created_days_ago=400)
# Prints written without created_by_id.
await _add_completed_prints(db_session, user_id=None, count=10)
trigger = await service.evaluate(db_session, None)
assert trigger is not None
assert trigger.family == "anniversary"
@pytest.mark.asyncio
async def test_null_keyed_counters_isolated_from_per_user(self, db_session: AsyncSession):
# A user-attributed prints set should NOT show up in the install-default count.
user = await _make_user(db_session, username="alice")
await _add_completed_prints(db_session, user_id=user.id, count=200)
# NULL-keyed install has zero prints.
trigger = await service.evaluate(db_session, None)
# No anniversary either (user only just created).
assert trigger is None