mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user