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
@@ -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