tests/test_captions.py (10542 bytes)
1 """Cloud captions for Subrep: hour packs, the balance, and the speech proxy.""" 2 3 from __future__ import annotations 4 5 import io 6 import wave 7 from contextlib import contextmanager 8 9 import pytest 10 import stripe 11 from hypothesis import given 12 from hypothesis import settings as hsettings 13 from hypothesis import strategies as st 14 from sqlalchemy import event 15 16 from backend import accounts, captions 17 from backend.db import Account, SessionLocal 18 from backend.settings import settings 19 20 from .conftest import FakeStripeObject, account_id, checkout_event, post_webhook 21 22 HOUR = 3600 23 SECOND = captions.BYTES_PER_SECOND 24 25 26 @pytest.fixture 27 def speech(monkeypatch): 28 """A server with a key, and the speech service faked: it answers with a fixed text.""" 29 monkeypatch.setattr(settings, "caption_api_key", "gsk_test") 30 calls = [] 31 32 def fake(pcm, language): 33 calls.append((len(pcm), language)) 34 return "こんにちは" 35 36 monkeypatch.setattr(captions, "transcribe", fake) 37 return calls 38 39 40 def give(client, seconds): 41 with SessionLocal() as s: 42 captions.add(s, account_id(client), seconds) 43 s.commit() 44 45 46 def left(client): 47 return client.get("/api/captions").json()["seconds_left"] 48 49 50 def post_piece(client, n_bytes, lang="ja"): 51 return client.post(f"/api/captions/transcribe?lang={lang}", content=b"\0" * n_bytes) 52 53 54 # --- packs ------------------------------------------------------------------- 55 56 def test_the_book_page_does_not_show_the_caption_packs(client): 57 ids = {p["id"] for p in client.get("/api/pricing").json()["plans"]} 58 assert not ids & set(captions.HOURS) 59 60 61 def test_the_app_sees_three_packs_and_an_empty_balance(client): 62 state = client.get("/api/captions").json() 63 assert [(p["id"], p["hours"], p["price_display"]) for p in state["packs"]] == [ 64 ("captions20", 20, "$4.99"), ("captions80", 80, "$16.99"), 65 ("captions200", 200, "$39.00"), 66 ] 67 assert state["seconds_left"] == 0 68 69 70 def test_checkout_sells_the_pack_to_this_account(client, monkeypatch): 71 seen = {} 72 monkeypatch.setattr(stripe.checkout.Session, "create", 73 lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/pay"})) 74 r = client.post("/api/captions/checkout", json={"pack_id": "captions80"}) 75 assert r.status_code == 200 and r.json()["url"] == "https://stripe.test/pay" 76 assert seen["metadata"]["plan_id"] == "captions80" 77 assert seen["client_reference_id"] == account_id(client) 78 assert seen["line_items"][0]["price_data"]["unit_amount"] == 1699 79 assert client.post("/api/captions/checkout", json={"pack_id": "pack10"}).status_code == 404 80 81 82 def test_a_paid_pack_gives_its_hours_once(client): 83 acct = account_id(client) 84 event = checkout_event("cs_captions", acct, "captions20") 85 assert post_webhook(client, event).status_code == 200 86 assert post_webhook(client, event).status_code == 200 # Stripe retries 87 assert left(client) == 20 * HOUR 88 # A caption pack is not a book credit. 89 assert client.get("/api/account").json()["credits"] == 0 90 91 92 def test_a_merge_moves_the_hours(client, second_client): 93 give(client, 5 * HOUR) 94 give(second_client, 2 * HOUR) 95 with SessionLocal() as s: 96 src = s.get(Account, account_id(client)) 97 dst = s.get(Account, account_id(second_client)) 98 accounts.merge(s, src, dst) 99 s.commit() 100 assert captions.seconds_left(s, dst.id) == 7 * HOUR 101 assert captions.seconds_left(s, src.id) == 0 102 103 104 # --- transcribe -------------------------------------------------------------- 105 106 def test_no_key_on_the_server_is_503(client, monkeypatch): 107 monkeypatch.setattr(settings, "caption_api_key", "") 108 give(client, HOUR) 109 assert post_piece(client, 3 * SECOND).status_code == 503 110 assert left(client) == HOUR 111 112 113 def test_no_hours_is_402_and_the_service_is_not_called(client, speech): 114 assert post_piece(client, 3 * SECOND).status_code == 402 115 assert speech == [] 116 117 118 def test_a_piece_costs_its_length_rounded_up(client, speech): 119 give(client, 60) 120 r = post_piece(client, int(2.5 * SECOND)) 121 assert r.status_code == 200 122 assert r.json() == {"text": "こんにちは", "seconds": 3, "seconds_left": 57} 123 assert speech == [(int(2.5 * SECOND), "ja")] 124 125 126 def test_the_last_seconds_do_not_pay_for_a_longer_piece(client, speech): 127 give(client, 2) 128 assert post_piece(client, 3 * SECOND).status_code == 402 129 assert left(client) == 2 130 131 132 def test_a_failure_of_the_service_gives_the_seconds_back(client, monkeypatch): 133 monkeypatch.setattr(settings, "caption_api_key", "gsk_test") 134 135 def down(pcm, language): 136 raise captions.TranscribeError("speech service answered 500") 137 138 monkeypatch.setattr(captions, "transcribe", down) 139 give(client, 60) 140 assert post_piece(client, 4 * SECOND).status_code == 502 141 assert left(client) == 60 142 143 144 @pytest.mark.parametrize("n_bytes, status", [(0, 400), (3, 400), (31 * SECOND, 413)]) 145 def test_a_bad_piece_is_refused_without_cost(client, speech, n_bytes, status): 146 give(client, HOUR) 147 assert post_piece(client, n_bytes).status_code == status 148 assert left(client) == HOUR and speech == [] 149 150 151 # --- properties -------------------------------------------------------------- 152 153 @hsettings(max_examples=50, deadline=None) 154 @given(given_seconds=st.integers(0, 100), 155 pieces=st.lists(st.integers(1, 40 * SECOND), max_size=12), 156 failures=st.lists(st.booleans(), max_size=12)) 157 def test_the_balance_never_goes_below_zero_and_no_second_is_lost(given_seconds, pieces, failures): 158 with SessionLocal() as s: 159 acct = accounts.new_account(s) 160 s.commit() 161 captions.add(s, acct.id, given_seconds) 162 s.commit() 163 for i, n in enumerate(pieces): 164 cost = captions.billed_seconds(n) 165 if captions.spend(s, acct.id, cost) and i < len(failures) and failures[i]: 166 captions.refund(s, acct.id, cost) 167 row = s.get(captions.CaptionBalance, acct.id) 168 s.refresh(row) 169 assert row.seconds_left >= 0 170 assert row.seconds_left + row.seconds_used == given_seconds 171 172 173 @contextmanager 174 def before_the_first_write(action): 175 """Run `action` in another session, and commit it, just before the first 176 INSERT or UPDATE of caption_balances. 177 178 Postgres lets another transaction commit between a SELECT and a later 179 write of a transaction (READ COMMITTED). SQLite lets it only while the 180 transaction has not written yet, so a test must call add in a new 181 transaction to see that race on SQLite. 182 """ 183 engine = SessionLocal.kw["bind"] 184 results = [] 185 186 def before(conn, cursor, statement, parameters, context, executemany): 187 if not results and statement.startswith( 188 ("INSERT INTO caption_balances", "UPDATE caption_balances")): 189 results.append(None) # the other session writes too 190 with SessionLocal() as other: 191 results[0] = action(other) 192 other.commit() 193 194 event.listen(engine, "before_cursor_execute", before) 195 try: 196 yield results 197 finally: 198 event.remove(engine, "before_cursor_execute", before) 199 200 201 @hsettings(max_examples=50, deadline=None) 202 @given(start=st.none() | st.integers(0, 100), ours=st.integers(1, 100), 203 theirs=st.integers(1, 100), spend=st.booleans()) 204 def test_a_credit_loses_no_credit_or_spend_of_another_session(start, ours, theirs, spend): 205 """Another session gives or spends `theirs` seconds while add gives `ours`. 206 With `start` None, the account has no row yet.""" 207 with SessionLocal() as s: 208 acct = accounts.new_account(s).id 209 s.commit() 210 if start is not None: 211 captions.add(s, acct, start) 212 s.commit() 213 214 def other(session): 215 if spend: 216 return captions.spend(session, acct, theirs) 217 return captions.add(session, acct, theirs) 218 219 with SessionLocal() as s, before_the_first_write(other) as done: 220 captions.add(s, acct, ours) 221 s.commit() 222 223 assert done 224 spent = theirs if spend and done == [True] else 0 225 credited = (start or 0) + ours + (0 if spend else theirs) 226 with SessionLocal() as s: 227 row = s.get(captions.CaptionBalance, acct) 228 assert (row.seconds_left, row.seconds_used) == (credited - spent, spent) 229 230 231 @given(st.binary(max_size=4 * SECOND).filter(lambda b: len(b) % 2 == 0)) 232 def test_wav_holds_the_pcm_unchanged(pcm): 233 with wave.open(io.BytesIO(captions.wav(pcm))) as w: 234 assert (w.getnchannels(), w.getsampwidth(), w.getframerate()) == (1, 2, 16_000) 235 assert w.readframes(w.getnframes()) == pcm 236 237 238 @given(st.integers(1, 60 * SECOND)) 239 def test_the_billed_seconds_cover_the_piece(n_bytes): 240 billed = captions.billed_seconds(n_bytes) 241 assert billed * SECOND >= n_bytes > (billed - 1) * SECOND 242 243 244 245 def test_the_request_is_the_openai_form(monkeypatch): 246 """transcribe() against a local server that reads the form as the service does.""" 247 import json 248 import threading 249 from email.parser import BytesParser 250 from email.policy import HTTP 251 from http.server import BaseHTTPRequestHandler, HTTPServer 252 253 seen = {} 254 255 class Service(BaseHTTPRequestHandler): 256 def do_POST(self): 257 body = self.rfile.read(int(self.headers["Content-Length"])) 258 head = f"Content-Type: {self.headers['Content-Type']}\r\n\r\n".encode() 259 form = BytesParser(policy=HTTP).parsebytes(head + body) 260 for part in form.iter_parts(): 261 seen[part.get_param("name", header="content-disposition")] = part.get_payload(decode=True) 262 seen["auth"] = self.headers["Authorization"] 263 out = json.dumps({"text": " 今日は "}).encode() 264 self.send_response(200) 265 self.send_header("Content-Length", str(len(out))) 266 self.end_headers() 267 self.wfile.write(out) 268 269 def log_message(self, *args): 270 pass 271 272 server = HTTPServer(("127.0.0.1", 0), Service) 273 threading.Thread(target=server.handle_request, daemon=True).start() 274 monkeypatch.setattr(settings, "caption_api_url", f"http://127.0.0.1:{server.server_port}/") 275 monkeypatch.setattr(settings, "caption_api_key", "gsk_test") 276 pcm = bytes(range(256)) * 10 277 278 assert captions.transcribe(pcm, "ja") == "今日は" 279 server.server_close() 280 assert seen["auth"] == "Bearer gsk_test" 281 assert seen["model"] == b"openai/whisper-large-v3-turbo" 282 assert seen["language"] == b"ja" 283 assert seen["file"] == captions.wav(pcm)