From 04e6fca76562149ba2ac3d680a6d24526c593bf6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 30 Jun 2026 13:44:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20accumulation=20paresseuse=20et=20d?= =?UTF-8?q?=C3=A9pense=20de=20votes=20#94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/subscriptions/db.py | 51 ++++++++++++++++++++++++++- tests/subscriptions/test_db.py | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/subscriptions/db.py b/src/subscriptions/db.py index b032c88..4d4c682 100644 --- a/src/subscriptions/db.py +++ b/src/subscriptions/db.py @@ -1,5 +1,5 @@ import sqlite3 -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from src.auth.db import get_conn @@ -30,6 +30,10 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() +INITIAL_VOTES = 2 +WEEK_SECONDS = 7 * 24 * 3600 + + def init_schema() -> None: get_conn().executescript(SUBSCRIPTIONS_SCHEMA) @@ -121,3 +125,48 @@ def has_active_subscription(user_id: int) -> bool: def has_used_trial(user_id: int) -> bool: row = get_by_user(user_id) return bool(row and row["trial_used"]) + + +def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None: + get_conn().execute( + "UPDATE subscriptions SET votes_balance = ?, votes_credited_until = ?, " + "updated_at = ? WHERE user_id = ?", + (balance, cursor_iso, _now(), user_id), + ) + + +def credit_pending(user_id: int) -> int: + """Crédite paresseusement les votes acquis et renvoie le solde courant. + + +2 à la première activation (fin d'essai), puis +1 par semaine pleine tant + que l'abonnement est actif. Idempotent : ne crédite que des semaines pleines. + """ + row = get_by_user(user_id) + if row is None: + return 0 + balance = row["votes_balance"] or 0 + if row["status"] != "active": + return balance + now = datetime.now(timezone.utc) + cursor = row["votes_credited_until"] + if cursor is None: + balance += INITIAL_VOTES + _set_votes(user_id, balance, now.isoformat()) + return balance + cur = datetime.fromisoformat(cursor) + weeks = int((now - cur).total_seconds() // WEEK_SECONDS) + if weeks > 0: + balance += weeks + new_cursor = cur + timedelta(seconds=weeks * WEEK_SECONDS) + _set_votes(user_id, balance, new_cursor.isoformat()) + return balance + + +def spend_vote(user_id: int) -> bool: + """Débite 1 vote si le solde le permet. Renvoie True si un vote a été débité.""" + cur = get_conn().execute( + "UPDATE subscriptions SET votes_balance = votes_balance - 1, updated_at = ? " + "WHERE user_id = ? AND votes_balance > 0", + (_now(), user_id), + ) + return cur.rowcount > 0 diff --git a/tests/subscriptions/test_db.py b/tests/subscriptions/test_db.py index 154fa55..c5f25d7 100644 --- a/tests/subscriptions/test_db.py +++ b/tests/subscriptions/test_db.py @@ -17,6 +17,15 @@ def _make_user(email="u@ex.fr"): return auth_db.create_user(email, "hash") +def _activate(uid, cursor_iso=None): + """Met l'abonnement en statut actif avec un curseur d'accumulation donné.""" + db.get_conn().execute( + "UPDATE subscriptions SET status = 'active', votes_credited_until = ? " + "WHERE user_id = ?", + (cursor_iso, uid), + ) + + def test_init_schema_creates_table(users_db_path): db.init_schema() conn = auth_db.get_conn() @@ -127,3 +136,58 @@ def test_init_schema_creates_votes_columns(users_db_path): row = db.get_by_user(uid) assert row["votes_balance"] == 0 assert row["votes_credited_until"] is None + + +def test_credit_pending_grants_initial_two_on_first_active(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + _activate(uid, cursor_iso=None) + balance = db.credit_pending(uid) + assert balance == 2 + assert db.get_by_user(uid)["votes_credited_until"] is not None + + +def test_credit_pending_is_idempotent_same_day(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + _activate(uid, cursor_iso=None) + db.credit_pending(uid) + assert db.credit_pending(uid) == 2 # aucun crédit supplémentaire + + +def test_credit_pending_adds_one_vote_per_full_week(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + fifteen_days_ago = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat() + _activate(uid, cursor_iso=fifteen_days_ago) + # 15 jours = 2 semaines pleines + assert db.credit_pending(uid) == 2 + + +def test_credit_pending_no_credit_when_not_active(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") # statut 'pending' + assert db.credit_pending(uid) == 0 + + +def test_spend_vote_decrements_when_balance_positive(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + _activate(uid, cursor_iso=None) + db.credit_pending(uid) # solde = 2 + assert db.spend_vote(uid) is True + assert db.get_by_user(uid)["votes_balance"] == 1 + + +def test_spend_vote_refused_when_balance_zero(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + _activate(uid, cursor_iso=None) + # solde reste 0 tant que credit_pending n'est pas appelé + assert db.spend_vote(uid) is False