Coverage for app/backend/src/tests/test_native_updates.py: 96%
113 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 17:03 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 17:03 +0000
1import uuid
2from datetime import UTC, datetime, timedelta
3from typing import Any, cast
5from google.protobuf.timestamp_pb2 import Timestamp
7from couchers.context import CouchersContext
8from couchers.native_updates import (
9 DEFAULT_OTA_BLOCK_DAYS,
10 DEFAULT_OTA_WARN_DAYS,
11 DEFAULT_STORE_BLOCK_DAYS,
12 DEFAULT_STORE_WARN_DAYS,
13 NativeClientInfo,
14 Severity,
15 UpdateAction,
16 client_info_from_request,
17 decide_native_update,
18)
19from couchers.proto import bugs_pb2
21NOW = datetime(2026, 5, 31, 12, 0, tzinfo=UTC)
22EAS_CLIENT_ID = uuid.UUID("11111111-1111-1111-1111-111111111111")
25class _FakeContext:
26 def __init__(self, flags: dict[str, Any] | None = None) -> None:
27 self._flags = flags or {}
29 def get_boolean_value(self, key: str, default: bool) -> bool:
30 return bool(self._flags.get(key, default))
32 def get_string_value(self, key: str, default: str) -> str:
33 return str(self._flags.get(key, default))
35 def get_integer_value(self, key: str, default: int) -> int:
36 return int(self._flags.get(key, default))
38 def get_float_value(self, key: str, default: float) -> float:
39 return float(self._flags.get(key, default))
41 def get_object_value(self, key: str, default: Any) -> Any:
42 return self._flags.get(key, default)
45def _days_ago(days: float) -> datetime:
46 return NOW - timedelta(days=days)
49def _decide(
50 info: NativeClientInfo,
51 flags: dict[str, Any] | None = None,
52 *,
53 banned: bool = False,
54):
55 context = cast(CouchersContext, _FakeContext(flags))
56 return decide_native_update(context, info, NOW, banned=banned)
59def _ts(dt: datetime) -> Timestamp:
60 out = Timestamp()
61 out.FromDatetime(dt)
62 return out
65def test_client_info_from_full_proto():
66 req = bugs_pb2.CheckNativeStatusReq(
67 eas_client_id=str(EAS_CLIENT_ID),
68 platform="ios",
69 runtime_version="ios-fingerprint",
70 update_id="abc-123",
71 launch_source="ota",
72 created_at=_ts(datetime(2026, 5, 1, tzinfo=UTC)),
73 embedded_created_at=_ts(datetime(2026, 1, 1, tzinfo=UTC)),
74 )
75 info = client_info_from_request(req)
76 assert info.platform == "ios"
77 assert info.runtime_version == "ios-fingerprint"
78 assert info.update_id == "abc-123"
79 assert info.is_ota_launch is True
80 assert info.bundle_created_at == datetime(2026, 5, 1, tzinfo=UTC)
81 assert info.binary_created_at == datetime(2026, 1, 1, tzinfo=UTC)
84def test_client_info_embedded_launch_source():
85 req = bugs_pb2.CheckNativeStatusReq(
86 eas_client_id=str(EAS_CLIENT_ID),
87 platform="android",
88 launch_source="embedded",
89 is_embedded_launch=True,
90 update_id="none",
91 )
92 info = client_info_from_request(req)
93 assert info.is_ota_launch is False
94 # "none" is the placeholder for absent updateId on the client.
95 assert info.update_id is None
98def test_client_info_defaults_when_request_empty():
99 info = client_info_from_request(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID)))
100 assert info == NativeClientInfo(eas_client_id=EAS_CLIENT_ID)
103def test_no_timestamps_means_no_update():
104 decision = _decide(NativeClientInfo(eas_client_id=EAS_CLIENT_ID, platform="ios"))
105 assert decision.action == UpdateAction.none
106 assert decision.severity == Severity.none
109def test_fresh_binary_no_update():
110 info = NativeClientInfo(eas_client_id=EAS_CLIENT_ID, platform="ios", binary_created_at=_days_ago(10))
111 assert _decide(info).severity == Severity.none
114def test_binary_between_warn_and_block_is_store_warn():
115 info = NativeClientInfo(
116 eas_client_id=EAS_CLIENT_ID, platform="ios", binary_created_at=_days_ago(DEFAULT_STORE_WARN_DAYS + 1)
117 )
118 decision = _decide(info)
119 assert decision.action == UpdateAction.store
120 assert decision.severity == Severity.warn
121 assert decision.act_by is not None and decision.act_by > NOW
124def test_binary_past_block_is_store_block():
125 info = NativeClientInfo(
126 eas_client_id=EAS_CLIENT_ID, platform="ios", binary_created_at=_days_ago(DEFAULT_STORE_BLOCK_DAYS + 5)
127 )
128 decision = _decide(info)
129 assert decision.action == UpdateAction.store
130 assert decision.severity == Severity.block
131 assert decision.act_by is not None and decision.act_by <= NOW
134def test_ota_between_warn_and_block_is_ota_warn():
135 info = NativeClientInfo(
136 eas_client_id=EAS_CLIENT_ID,
137 platform="ios",
138 is_ota_launch=True,
139 binary_created_at=_days_ago(5),
140 bundle_created_at=_days_ago(DEFAULT_OTA_WARN_DAYS + 1),
141 )
142 decision = _decide(info)
143 assert decision.action == UpdateAction.ota
144 assert decision.severity == Severity.warn
145 assert decision.act_by is not None and decision.act_by > NOW
148def test_ota_past_block_is_ota_block():
149 info = NativeClientInfo(
150 eas_client_id=EAS_CLIENT_ID,
151 platform="ios",
152 is_ota_launch=True,
153 binary_created_at=_days_ago(5),
154 bundle_created_at=_days_ago(DEFAULT_OTA_BLOCK_DAYS + 2),
155 )
156 decision = _decide(info)
157 assert decision.action == UpdateAction.ota
158 assert decision.severity == Severity.block
161def test_ota_clock_ignored_when_not_running_ota():
162 info = NativeClientInfo(
163 eas_client_id=EAS_CLIENT_ID,
164 platform="ios",
165 is_ota_launch=False,
166 binary_created_at=_days_ago(5),
167 bundle_created_at=_days_ago(DEFAULT_OTA_BLOCK_DAYS + 100),
168 )
169 assert _decide(info).severity == Severity.none
172def test_binary_warn_but_ota_block_resolves_to_ota_block():
173 info = NativeClientInfo(
174 eas_client_id=EAS_CLIENT_ID,
175 platform="ios",
176 is_ota_launch=True,
177 binary_created_at=_days_ago(DEFAULT_STORE_WARN_DAYS + 1),
178 bundle_created_at=_days_ago(DEFAULT_OTA_BLOCK_DAYS + 2),
179 )
180 decision = _decide(info)
181 assert decision.action == UpdateAction.ota
182 assert decision.severity == Severity.block
185def test_store_precedence_when_severities_tie():
186 info = NativeClientInfo(
187 eas_client_id=EAS_CLIENT_ID,
188 platform="ios",
189 is_ota_launch=True,
190 binary_created_at=_days_ago(DEFAULT_STORE_BLOCK_DAYS + 1),
191 bundle_created_at=_days_ago(DEFAULT_OTA_BLOCK_DAYS + 1),
192 )
193 assert _decide(info).action == UpdateAction.store
196def test_binary_block_beats_ota_warn():
197 info = NativeClientInfo(
198 eas_client_id=EAS_CLIENT_ID,
199 platform="ios",
200 is_ota_launch=True,
201 binary_created_at=_days_ago(DEFAULT_STORE_BLOCK_DAYS + 1),
202 bundle_created_at=_days_ago(DEFAULT_OTA_WARN_DAYS + 1),
203 )
204 assert _decide(info).action == UpdateAction.store
207def test_warn_days_drive_warn_threshold():
208 info = NativeClientInfo(eas_client_id=EAS_CLIENT_ID, platform="ios", binary_created_at=_days_ago(8))
209 decision = _decide(info, flags={"native_store_warn_days": 7, "native_store_block_days": 30})
210 assert decision.severity == Severity.warn
213def test_block_days_drive_block_threshold():
214 info = NativeClientInfo(eas_client_id=EAS_CLIENT_ID, platform="ios", binary_created_at=_days_ago(10))
215 decision = _decide(info, flags={"native_store_warn_days": 5, "native_store_block_days": 7})
216 assert decision.action == UpdateAction.store
217 assert decision.severity == Severity.block
220def test_zero_block_disables_clock():
221 info = NativeClientInfo(eas_client_id=EAS_CLIENT_ID, platform="ios", binary_created_at=_days_ago(1000))
222 assert _decide(info, flags={"native_store_block_days": 0}).severity == Severity.none
225def test_banned_bundle_on_ota_launch_forces_block():
226 info = NativeClientInfo(
227 eas_client_id=EAS_CLIENT_ID,
228 platform="ios",
229 is_ota_launch=True,
230 update_id="abc",
231 binary_created_at=_days_ago(5),
232 bundle_created_at=_days_ago(1),
233 )
234 decision = _decide(info, banned=True)
235 assert decision.action == UpdateAction.ota
236 assert decision.severity == Severity.block
237 # Unset deadline = block-now per the proto contract; avoids clock-skew flipping into warn.
238 assert decision.act_by is None
241def test_banned_ignored_when_not_an_ota_launch():
242 info = NativeClientInfo(
243 eas_client_id=EAS_CLIENT_ID, platform="ios", is_ota_launch=False, binary_created_at=_days_ago(5)
244 )
245 assert _decide(info, banned=True).severity == Severity.none