Coverage for tests/test_update_prices.py: 96.64%
117 statements
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
1import threading
2from decimal import Decimal
4import httpx2
5import pytest
6from inline_snapshot import snapshot
8from voice_prices import (
9 UpdatePrices,
10 Usage,
11 calc_price,
12 data_snapshot,
13 wait_prices_updated_async,
14 wait_prices_updated_sync,
15)
17pytestmark = pytest.mark.anyio
19PROVIDER_ARRAY_PAYLOAD = (
20 b'[{"id":"openai","name":"OpenAI","api_pattern":"https://api\\\\.openai\\\\.com",'
21 b'"models":[{"id":"gpt-4o","match":{"equals":"gpt-4o"},'
22 b'"prices":{"input_mtok":2.5,"output_mtok":10}}]}]'
23)
26def _mock_update_prices_get(monkeypatch: pytest.MonkeyPatch, content: bytes = PROVIDER_ARRAY_PAYLOAD) -> None:
27 class Response:
28 def __init__(self, content: bytes) -> None:
29 self.content = content
31 def raise_for_status(self) -> None:
32 pass
34 def fake_get(url: str, timeout: httpx2.Timeout) -> Response:
35 assert url in {
36 'https://example.test/prices.json',
37 'https://raw.githubusercontent.com/mahimailabs/voice-prices/refs/heads/main/prices/data.json',
38 }
39 assert timeout is not None
40 return Response(content)
42 monkeypatch.setattr(httpx2, 'get', fake_get)
45def test_update_prices_fetch_parses_provider_array(monkeypatch: pytest.MonkeyPatch):
46 content = (
47 b'[{"id":"openai","name":"OpenAI","api_pattern":"https://api\\\\.openai\\\\.com",'
48 b'"models":[{"id":"gpt-4o-mini","match":{"equals":"gpt-4o-mini"},'
49 b'"prices":{"input_mtok":0.15,"output_mtok":0.6}}]}]'
50 )
52 _mock_update_prices_get(monkeypatch, content)
54 snapshot = UpdatePrices(url='https://example.test/prices.json').fetch()
56 assert snapshot is not None
57 assert snapshot.from_auto_update is True
58 provider, model = snapshot.find_provider_model('gpt-4o-mini', None, 'openai', None)
59 assert provider.id == 'openai'
60 assert model.id == 'gpt-4o-mini'
63def test_update_prices_wait_on_start(monkeypatch: pytest.MonkeyPatch):
64 _mock_update_prices_get(monkeypatch)
65 assert data_snapshot._custom_snapshot is None
66 with UpdatePrices() as update_prices:
67 update_prices.wait()
68 assert data_snapshot._custom_snapshot is not None
69 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='gpt-4o', provider_id='openai')
70 assert price.input_price == snapshot(Decimal('0.0025'))
71 assert price.output_price == snapshot(Decimal('0.001'))
72 assert price.total_price == snapshot(Decimal('0.0035'))
73 assert price.provider.id == snapshot('openai')
74 assert price.auto_update_timestamp is not None
77def test_wait_prices_updated_sync(monkeypatch: pytest.MonkeyPatch):
78 _mock_update_prices_get(monkeypatch)
79 assert data_snapshot._custom_snapshot is None
80 with UpdatePrices():
81 wait_prices_updated_sync()
82 assert data_snapshot._custom_snapshot is not None
83 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='gpt-4o', provider_id='openai')
84 assert price.input_price == snapshot(Decimal('0.0025'))
85 assert price.output_price == snapshot(Decimal('0.001'))
86 assert price.total_price == snapshot(Decimal('0.0035'))
87 assert price.provider.id == snapshot('openai')
88 assert price.auto_update_timestamp is not None
91async def test_wait_prices_updated_async(monkeypatch: pytest.MonkeyPatch):
92 _mock_update_prices_get(monkeypatch)
93 assert data_snapshot._custom_snapshot is None
94 with UpdatePrices():
95 await wait_prices_updated_async()
96 assert data_snapshot._custom_snapshot is not None
97 price = calc_price(Usage(input_tokens=1000, output_tokens=100), model_ref='gpt-4o', provider_id='openai')
98 assert price.input_price == snapshot(Decimal('0.0025'))
99 assert price.output_price == snapshot(Decimal('0.001'))
100 assert price.total_price == snapshot(Decimal('0.0035'))
101 assert price.provider.id == snapshot('openai')
102 assert price.auto_update_timestamp is not None
105def test_update_prices_stop_clears_snapshot_after_in_flight_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
106 fetch_started = threading.Event()
107 allow_fetch_return = threading.Event()
108 stop_errors: list[BaseException] = []
110 class Response:
111 content = PROVIDER_ARRAY_PAYLOAD
113 def raise_for_status(self) -> None:
114 pass
116 def fake_get(url: str, timeout: httpx2.Timeout) -> Response:
117 assert url == 'https://example.test/prices.json'
118 assert timeout is not None
119 fetch_started.set()
120 assert allow_fetch_return.wait(timeout=5)
121 return Response()
123 def stop_update_prices(update_prices: UpdatePrices) -> None:
124 try:
125 update_prices.stop()
126 except BaseException as exc:
127 stop_errors.append(exc)
129 monkeypatch.setattr(httpx2, 'get', fake_get)
130 update_prices = UpdatePrices(url='https://example.test/prices.json', update_interval=3600)
131 update_prices.start()
132 try:
133 assert fetch_started.wait(timeout=5)
135 stop_thread = threading.Thread(target=stop_update_prices, args=(update_prices,))
136 stop_thread.start()
137 assert update_prices._stop_event.wait(timeout=5)
138 allow_fetch_return.set()
139 stop_thread.join(timeout=5)
141 assert not stop_thread.is_alive()
142 if stop_errors: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 raise stop_errors[0]
144 assert data_snapshot._custom_snapshot is None
145 finally:
146 allow_fetch_return.set()
147 update_prices.stop()
148 data_snapshot.set_custom_snapshot(None)
151@pytest.mark.default_cassette('fail.yaml')
152@pytest.mark.vcr()
153def test_update_prices_failed():
154 assert data_snapshot._custom_snapshot is None
155 with UpdatePrices(url='https://demo-endpoints.pydantic.workers.dev/bin?status=404') as update_prices:
156 with pytest.raises(httpx2.HTTPStatusError):
157 update_prices.wait()
158 assert data_snapshot._custom_snapshot is None
161@pytest.mark.default_cassette('fail.yaml')
162@pytest.mark.vcr()
163def test_update_prices_failed_stop():
164 assert data_snapshot._custom_snapshot is None
165 update_prices = UpdatePrices(url='https://demo-endpoints.pydantic.workers.dev/bin?status=404')
166 update_prices.start()
167 with pytest.raises(httpx2.HTTPStatusError):
168 update_prices.stop()
169 assert data_snapshot._custom_snapshot is None
172def test_update_prices_multiple(monkeypatch: pytest.MonkeyPatch):
173 _mock_update_prices_get(monkeypatch)
174 with UpdatePrices():
175 with pytest.raises(
176 RuntimeError,
177 match='UpdatePrices global task already started, only one UpdatePrices can be active at a time',
178 ):
179 with UpdatePrices():
180 pass