Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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)