tests/test_free_credits.py (8773 bytes)
1 """Free credits for conversions on the server. 2 3 The rules under test: 4 5 anonymous one free credit, while the server takes conversions. A buyer 6 whose email came from Stripe is in this group until the email 7 is verified. 8 verified an account that signed in with an emailed link gets two free 9 credits in all (a free credit that a device used before counts 10 toward the two), one more each day, and one more for every two 11 books that the server converted for it 12 daily the daily credit does not carry over to the next day 13 order a server job spends the daily credit, then a free credit, then 14 a bought one 15 refund a failed job gives its credit back to the same kind 16 merge a second device that signs in adds no free credits 17 """ 18 19 from __future__ import annotations 20 21 import time 22 from datetime import timedelta 23 from urllib.parse import parse_qs, urlparse 24 25 import pytest 26 27 from backend import billing, runner 28 from backend.db import JobStatus 29 from backend.settings import Settings, settings 30 31 from .conftest import ( 32 account_id, checkout_event, get_account_row, get_job_row, make_job, 33 post_webhook, 34 ) 35 36 BOOK = { 37 "audio_filename": "free.m4b", "audio_bytes": 4321, 38 "text_filename": "free.epub", "language": "en", 39 } 40 SRT = "1\n00:00:00,000 --> 00:00:01,000\nhello\n\n" 41 42 43 @pytest.fixture 44 def cloud(monkeypatch): 45 """The server takes conversions, and sign-in links come back in the response.""" 46 monkeypatch.setattr(settings, "cloud_enabled", True) 47 monkeypatch.setattr(settings, "dev_login_links", True) 48 49 50 def unique(name: str) -> str: 51 """An email for one test only: an email joins accounts across tests.""" 52 return f"{name}-{time.time_ns()}@example.com" 53 54 55 def sign_in(client, email: str) -> None: 56 link = client.post("/api/auth/request", json={"email": email}).json()["dev_link"] 57 token = parse_qs(urlparse(link).query)["login"][0] 58 assert client.post("/api/auth/verify", json={"token": token}).status_code == 200 59 60 61 def buy(client, plan: str, email: str) -> None: 62 event = checkout_event(f"cs_{time.time_ns()}", account_id(client), plan, email=email) 63 assert post_webhook(client, event).status_code == 200 64 65 66 def start(client, job_id: str): 67 return client.post(f"/api/jobs/{job_id}/start", json={"language": "en"}) 68 69 70 def account(client) -> dict: 71 return client.get("/api/account").json() 72 73 74 def days_later(monkeypatch, days: int) -> None: 75 later = billing._today() + timedelta(days=days) 76 monkeypatch.setattr(billing, "_today", lambda: later) 77 78 79 def convert_in_browser(client, n: int) -> None: 80 job = client.post("/api/local/jobs", json={**BOOK, "audio_bytes": 100 + n}).json() 81 r = client.post(f"/api/local/jobs/{job['id']}/finish", 82 json={"srt": SRT, "filename": "free.srt"}) 83 assert r.status_code == 200 84 85 86 def test_defaults(): 87 fields = Settings.model_fields 88 assert fields["free_credits_anonymous"].default == 1 89 assert fields["free_credits_verified"].default == 2 90 assert fields["daily_free_credit"].default is True 91 assert fields["books_per_bonus_credit"].default == 2 92 93 94 def test_a_new_visitor_has_one_free_credit(client, cloud): 95 a = account(client) 96 assert a["signed_in"] is False and a["email_verified"] is False 97 assert a["credits"] == 1 and a["free_credits"] == 1 98 assert a["cloud_allowed"] is True 99 assert a["free_credits_with_account"] == 2 and a["daily_free_credit"] is True 100 101 102 def test_the_free_credit_pays_for_one_server_job(client, cloud): 103 job_id = make_job(client) 104 assert start(client, job_id).status_code == 200 105 job = get_job_row(job_id) 106 assert job.free_credit_spent == 1 and job.credit_spent == 0 107 108 a = account(client) 109 assert a["credits"] == 0 and a["cloud_allowed"] is False 110 assert start(client, make_job(client)).status_code == 402 111 112 113 def test_a_verified_email_gets_two_free_credits_in_all_and_one_a_day(client, cloud): 114 assert start(client, make_job(client)).status_code == 200 # the anonymous credit 115 assert account(client)["free_credits_with_account"] == 1 116 117 sign_in(client, unique("verify")) 118 a = account(client) 119 assert a["signed_in"] is True and a["email_verified"] is True 120 assert a["free_credits"] == 2 # one of the two, and the daily credit 121 assert a["free_credits_with_account"] == 0 122 123 124 def test_a_buyer_gets_account_credits_only_after_verifying_the_email(client, cloud): 125 email = unique("buyer") 126 buy(client, "pack10", email) 127 a = account(client) 128 assert a["signed_in"] is True and a["email_verified"] is False 129 assert a["credits"] == 11 and a["free_credits"] == 1 # 10 bought, the anonymous one 130 assert a["free_credits_with_account"] == 2 131 132 sign_in(client, email) # the same address: this verifies the account 133 a = account(client) 134 assert a["email_verified"] is True 135 assert a["free_credits"] == 3 and a["credits"] == 13 136 137 138 def test_the_daily_credit_does_not_carry_over(client, cloud, monkeypatch): 139 sign_in(client, unique("daily")) 140 assert account(client)["free_credits"] == 3 # two, and today's 141 142 days_later(monkeypatch, 1) 143 assert account(client)["free_credits"] == 3 # not 4: yesterday's credit is gone 144 for _ in range(3): 145 assert start(client, make_job(client)).status_code == 200 146 assert start(client, make_job(client)).status_code == 402 147 148 days_later(monkeypatch, 1) 149 assert account(client)["free_credits"] == 1 # the credit of the new day 150 job_id = make_job(client) 151 assert start(client, job_id).status_code == 200 152 assert get_job_row(job_id).daily_credit_on == billing._today() 153 154 155 def test_every_two_books_converted_on_the_server_earn_a_free_credit(client, cloud): 156 sign_in(client, unique("bonus")) 157 before = account(client)["free_credits"] 158 159 make_job(client, JobStatus.succeeded) 160 assert account(client)["free_credits"] == before 161 make_job(client, JobStatus.succeeded) 162 assert account(client)["free_credits"] == before + 1 163 164 # Books converted in the browser do not count: the server cannot check them. 165 convert_in_browser(client, 1) 166 convert_in_browser(client, 2) 167 assert account(client)["free_credits"] == before + 1 168 169 170 def test_free_credits_are_spent_before_bought_ones(client, cloud): 171 buy(client, "single", unique("order")) # a retired plan: one credit 172 assert account(client)["credits"] == 2 # one bought, the anonymous one 173 174 job_id = make_job(client) 175 assert start(client, job_id).status_code == 200 176 assert get_job_row(job_id).free_credit_spent == 1 177 row = get_account_row(account_id(client)) 178 assert row.purchased_credits == 1 and row.free_credits_used == 1 179 180 181 def test_a_failed_job_gives_the_free_credit_back(client, cloud): 182 job_id = make_job(client) 183 assert start(client, job_id).status_code == 200 184 runner.run_job(job_id) # fails: nothing was staged 185 186 assert account(client)["free_credits"] == 1 187 assert get_job_row(job_id).free_credit_spent == 0 188 assert get_account_row(account_id(client)).purchased_credits == 0 189 190 191 def test_a_failed_job_gives_the_daily_credit_back(client, cloud): 192 sign_in(client, unique("refund")) 193 job_id = make_job(client) 194 assert start(client, job_id).status_code == 200 195 assert get_job_row(job_id).daily_credit_on == billing._today() 196 197 runner.run_job(job_id) # fails: nothing was staged 198 assert get_job_row(job_id).daily_credit_on is None 199 assert account(client)["free_credits"] == 3 200 201 202 def test_a_second_device_adds_no_free_credits(client, second_client, cloud): 203 email = unique("merge") 204 sign_in(client, email) 205 assert account(client)["free_credits"] == 3 206 207 # The second device spends its anonymous credit, then signs in as the same person. 208 assert start(second_client, make_job(second_client)).status_code == 200 209 sign_in(second_client, email) 210 assert account(second_client)["free_credits"] == 2 211 assert account(client)["free_credits"] == 2 212 213 # A new cookie that signs in again brings no free credit along either. 214 client.post("/api/auth/signout") 215 assert account(client)["free_credits"] == 1 216 sign_in(client, email) 217 assert account(client)["free_credits"] == 2 218 219 220 def test_no_free_credits_while_the_server_takes_no_conversions(client): 221 # conftest leaves SUBPLZ_WEB_CLOUD_ENABLED off. 222 a = account(client) 223 assert a["credits"] == 0 and a["free_credits"] == 0 224 assert a["free_credits_with_account"] == 0 and a["daily_free_credit"] is False 225 assert start(client, make_job(client)).status_code == 402 226 227 228 def test_browser_jobs_leave_the_free_credit_alone(client, cloud): 229 r = client.post("/api/local/jobs", json=BOOK) 230 assert r.status_code == 200 and r.json()["tier"] == "free" 231 assert account(client)["free_credits"] == 1