Coverage for app/backend/src/tests/test_bugs.py: 97%
379 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 12:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 12:25 +0000
1import json
2import uuid
3from datetime import UTC, datetime, timedelta
4from unittest.mock import patch
6import grpc
7import pytest
8from google.protobuf import empty_pb2, timestamp_pb2
9from sqlalchemy import func, select
11from couchers.config import config
12from couchers.crypto import random_hex
13from couchers.db import session_scope
14from couchers.models import NativeClientUser, OTAPackage, OTAPlatform
15from couchers.models.logging import EventLog, EventSource, ExperimentExposure, ExposureSource
16from couchers.proto import bugs_pb2
17from couchers.proto.google.api import httpbody_pb2
18from couchers.servicers.bugs import _fetch_signed_manifest
19from tests.fixtures.db import generate_user
20from tests.fixtures.sessions import bugs_session, real_bugs_session
22EAS_CLIENT_ID = uuid.UUID("11111111-1111-1111-1111-111111111111")
25@pytest.fixture(autouse=True)
26def _(testconfig):
27 pass
30def test_bugs_disabled():
31 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
32 bugs.ReportBug(
33 bugs_pb2.ReportBugReq(
34 subject="subject",
35 description="description",
36 results="results",
37 frontend_version="frontend_version",
38 user_agent="user_agent",
39 page="page",
40 )
41 )
42 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
45def test_bugs(db):
46 with bugs_session() as bugs:
48 def dud_post(url, auth, json):
49 assert url == "https://api.github.com/repos/org/repo/issues"
50 assert auth == ("user", "token")
52 expected_body = f"""
53# subject
54## Description
55description
57## Results
58results
60## Diagnostics
61**Backend version**: `{config.VERSION}`
62**Frontend version**: `frontend_version`
63**User Agent**: `user_agent`
64**Locale**: `en`
65**Screen resolution**: 1920x1080
66**Page**: page
67**User**: <not logged in> / `test_sofa_co`""".strip()
69 assert json == {
70 "title": "subject",
71 "body": expected_body,
72 "labels": ["bug: triage needed"],
73 }
75 class _PostReturn:
76 status_code = 201
78 def json(self):
79 return {"number": 11}
81 return _PostReturn()
83 new_config = config.copy()
84 new_config.BUG_TOOL_ENABLED = True
86 with patch("couchers.servicers.bugs.config", new_config):
87 with patch("couchers.servicers.bugs.requests.post", dud_post): 87 ↛ anywhereline 87 didn't jump anywhere: it always raised an exception.
88 res = bugs.ReportBug(
89 bugs_pb2.ReportBugReq(
90 subject="subject",
91 description="description",
92 results="results",
93 frontend_version="frontend_version",
94 user_agent="user_agent",
95 screen_resolution=bugs_pb2.ScreenResolution(width=1920, height=1080),
96 page="page",
97 )
98 )
100 assert res.bug_id == "#11"
101 assert res.bug_url == "https://github.com/org/repo/issues/11"
104def test_bugs_with_user(db, frozen_timewarp):
105 user, token = generate_user(username="testing_user")
107 with bugs_session(token) as bugs:
109 def dud_post(url, auth, json):
110 assert url == "https://api.github.com/repos/org/repo/issues"
111 assert auth == ("user", "token")
113 expected_body = f"""
114# subject
115## Description
116description
118## Results
119results
121## Diagnostics
122**Backend version**: `{config.VERSION}`
123**Frontend version**: `frontend_version`
124**User Agent**: `user_agent`
125**Locale**: `en`
126**Screen resolution**: 390x844
127**Page**: page
128**User**: [@testing_user](http://localhost:3000/user/testing_user) (1) / `test_sofa_co`
129**Sentry (this user)**: https://couchers.sentry.io/issues/?project=1234&query=user.id%3A1&start=2019-12-31T00%3A00%3A00&end=2020-01-01T01%3A00%3A00&utc=true""".strip()
131 assert json == {
132 "title": "subject",
133 "body": expected_body,
134 "labels": ["bug: triage needed"],
135 }
137 class _PostReturn:
138 status_code = 201
140 def json(self):
141 return {"number": 11}
143 return _PostReturn()
145 new_config = config.copy()
146 new_config.BUG_TOOL_ENABLED = True
148 with patch("couchers.servicers.bugs.config", new_config):
149 with patch("couchers.servicers.bugs.requests.post", dud_post): 149 ↛ anywhereline 149 didn't jump anywhere: it always raised an exception.
150 res = bugs.ReportBug(
151 bugs_pb2.ReportBugReq(
152 subject="subject",
153 description="description",
154 results="results",
155 frontend_version="frontend_version",
156 user_agent="user_agent",
157 screen_resolution=bugs_pb2.ScreenResolution(width=390, height=844),
158 page="page",
159 )
160 )
162 assert res.bug_id == "#11"
163 assert res.bug_url == "https://github.com/org/repo/issues/11"
166def test_bugs_with_sentry_replay(db):
167 with bugs_session() as bugs:
169 def dud_post(url, auth, json):
170 expected_body = f"""
171# subject
172## Description
173description
175## Results
176results
178## Diagnostics
179**Backend version**: `{config.VERSION}`
180**Frontend version**: `frontend_version`
181**User Agent**: `user_agent`
182**Locale**: `en`
183**Screen resolution**: 1920x1080
184**Page**: page
185**User**: <not logged in> / `test_sofa_co`
186**Session replay**: https://couchers.sentry.io/replays/0123456789abcdef0123456789abcdef/?project=1234""".strip()
188 assert json["body"] == expected_body
190 class _PostReturn:
191 status_code = 201
193 def json(self):
194 return {"number": 11}
196 return _PostReturn()
198 new_config = config.copy()
199 new_config.BUG_TOOL_ENABLED = True
201 with patch("couchers.servicers.bugs.config", new_config):
202 with patch("couchers.servicers.bugs.requests.post", dud_post): 202 ↛ anywhereline 202 didn't jump anywhere: it always raised an exception.
203 bugs.ReportBug(
204 bugs_pb2.ReportBugReq(
205 subject="subject",
206 description="description",
207 results="results",
208 frontend_version="frontend_version",
209 user_agent="user_agent",
210 screen_resolution=bugs_pb2.ScreenResolution(width=1920, height=1080),
211 page="page",
212 sentry_replay_id="0123456789abcdef0123456789abcdef",
213 )
214 )
217def test_bugs_invalid_sentry_replay_id_omitted(db):
218 # A malformed/garbage replay id must not be interpolated into the issue at all.
219 with bugs_session() as bugs:
221 def dud_post(url, auth, json):
222 assert "Session replay" not in json["body"]
224 class _PostReturn:
225 status_code = 201
227 def json(self):
228 return {"number": 11}
230 return _PostReturn()
232 new_config = config.copy()
233 new_config.BUG_TOOL_ENABLED = True
235 with patch("couchers.servicers.bugs.config", new_config):
236 with patch("couchers.servicers.bugs.requests.post", dud_post): 236 ↛ anywhereline 236 didn't jump anywhere: it always raised an exception.
237 bugs.ReportBug(
238 bugs_pb2.ReportBugReq(
239 subject="subject",
240 description="description",
241 results="results",
242 sentry_replay_id="not-a-replay-id](https://evil.example)",
243 )
244 )
247def test_bugs_fails_on_network_error(db):
248 with bugs_session() as bugs:
250 def dud_post(url, auth, json):
251 class _PostReturn:
252 status_code = 400
254 return _PostReturn()
256 new_config = config.copy()
257 new_config.BUG_TOOL_ENABLED = True
259 with patch("couchers.servicers.bugs.config", new_config):
260 with patch("couchers.servicers.bugs.requests.post", dud_post): 260 ↛ anywhereline 260 didn't jump anywhere: it always raised an exception.
261 with pytest.raises(grpc.RpcError) as e:
262 res = bugs.ReportBug(
263 bugs_pb2.ReportBugReq(
264 subject="subject",
265 description="description",
266 results="results",
267 frontend_version="frontend_version",
268 user_agent="user_agent",
269 page="page",
270 )
271 )
272 assert e.value.code() == grpc.StatusCode.INTERNAL
275def test_version():
276 with bugs_session() as bugs:
277 res = bugs.Version(empty_pb2.Empty())
278 assert res.version == "testing_version"
281def test_status(db):
282 for _ in range(5):
283 generate_user()
285 with bugs_session() as bugs:
286 nonce = random_hex()
287 res = bugs.Status(bugs_pb2.StatusReq(nonce=nonce))
288 assert res.nonce == nonce
289 assert res.version == "testing_version"
290 assert res.coucher_count == 5
293def test_GetDescriptors():
294 with bugs_session() as bugs:
295 res = bugs.GetDescriptors(empty_pb2.Empty())
296 # test we got something roughly binary back
297 assert res.content_type == "application/octet-stream"
298 assert len(res.data) > 2**12
301def _get_events(session, event_type=None):
302 stmt = select(EventLog).order_by(EventLog.id)
303 if event_type: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 stmt = stmt.where(EventLog.event_type == event_type)
305 return session.execute(stmt).scalars().all()
308def test_report_diagnostics_anonymous(db):
309 with bugs_session() as bugs:
310 bugs.ReportDiagnostics(
311 bugs_pb2.ReportDiagnosticsReq(
312 frontend_version="1.2.3",
313 infos=[
314 bugs_pb2.DiagnosticInfo(
315 tag="page.viewed",
316 properties_json='{"path": "/"}',
317 value=1,
318 ),
319 bugs_pb2.DiagnosticInfo(
320 tag="session.started",
321 properties_json='{"referrer": "google.com"}',
322 value=1,
323 ),
324 ],
325 )
326 )
328 with session_scope() as session:
329 events = _get_events(session)
330 assert len(events) == 2
332 e0 = events[0]
333 assert e0.event_type == "page.viewed"
334 assert e0.properties == {"path": "/"}
335 assert e0.user_id is None
336 assert e0.source == EventSource.frontend
337 assert e0.value == 1
338 assert e0.version == "1.2.3"
340 e1 = events[1]
341 assert e1.event_type == "session.started"
342 assert e1.properties == {"referrer": "google.com"}
343 assert e1.source == EventSource.frontend
346def test_report_diagnostics_authenticated(db):
347 user, token = generate_user()
349 with bugs_session(token) as bugs:
350 bugs.ReportDiagnostics(
351 bugs_pb2.ReportDiagnosticsReq(
352 frontend_version="1.2.3",
353 infos=[
354 bugs_pb2.DiagnosticInfo(
355 tag="page.viewed",
356 properties_json='{"path": "/search"}',
357 value=1,
358 ),
359 ],
360 )
361 )
363 with session_scope() as session:
364 events = _get_events(session)
365 assert len(events) == 1
366 assert events[0].user_id == user.id
367 assert events[0].source == EventSource.frontend
370def test_report_diagnostics_with_value(db):
371 with bugs_session() as bugs:
372 bugs.ReportDiagnostics(
373 bugs_pb2.ReportDiagnosticsReq(
374 frontend_version="1.2.3",
375 infos=[
376 bugs_pb2.DiagnosticInfo(
377 tag="search.result_hovered",
378 properties_json='{"user_id": 5}',
379 value=1500.5,
380 ),
381 ],
382 )
383 )
385 with session_scope() as session:
386 events = _get_events(session)
387 assert len(events) == 1
388 assert events[0].value == pytest.approx(1500.5)
391def test_report_diagnostics_with_occurred(db):
392 ts = timestamp_pb2.Timestamp()
393 ts.FromDatetime(datetime(2026, 1, 15, 10, 30, 0, tzinfo=UTC))
395 with bugs_session() as bugs:
396 bugs.ReportDiagnostics(
397 bugs_pb2.ReportDiagnosticsReq(
398 frontend_version="1.2.3",
399 infos=[
400 bugs_pb2.DiagnosticInfo(
401 tag="page.viewed",
402 properties_json="{}",
403 value=1,
404 occurred=ts,
405 ),
406 ],
407 )
408 )
410 with session_scope() as session:
411 events = _get_events(session)
412 assert len(events) == 1
413 assert events[0].occurred.year == 2026
414 assert events[0].occurred.month == 1
415 assert events[0].occurred.day == 15
416 assert events[0].occurred.hour == 10
417 assert events[0].occurred.minute == 30
420def test_report_diagnostics_invalid_json(db):
421 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
422 bugs.ReportDiagnostics(
423 bugs_pb2.ReportDiagnosticsReq(
424 frontend_version="1.2.3",
425 infos=[
426 bugs_pb2.DiagnosticInfo(
427 tag="page.viewed",
428 properties_json="not valid json{{{",
429 value=1,
430 ),
431 ],
432 )
433 )
434 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
437def test_report_diagnostics_empty_batch(db):
438 with bugs_session() as bugs:
439 bugs.ReportDiagnostics(
440 bugs_pb2.ReportDiagnosticsReq(
441 frontend_version="1.2.3",
442 infos=[],
443 )
444 )
446 with session_scope() as session:
447 events = _get_events(session)
448 assert len(events) == 0
451def test_report_diagnostics_too_many(db):
452 infos = [bugs_pb2.DiagnosticInfo(tag=f"event.{i}", properties_json="{}", value=1) for i in range(101)]
454 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
455 bugs.ReportDiagnostics(
456 bugs_pb2.ReportDiagnosticsReq(
457 frontend_version="1.2.3",
458 infos=infos,
459 )
460 )
461 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
464def test_report_diagnostics_frontend_version(db):
465 with bugs_session() as bugs:
466 bugs.ReportDiagnostics(
467 bugs_pb2.ReportDiagnosticsReq(
468 frontend_version="abc-def-123",
469 infos=[
470 bugs_pb2.DiagnosticInfo(
471 tag="page.viewed",
472 properties_json="{}",
473 value=1,
474 ),
475 ],
476 )
477 )
479 with session_scope() as session:
480 events = _get_events(session)
481 assert len(events) == 1
482 assert events[0].version == "abc-def-123"
485def test_check_native_status_anonymous(db):
486 with bugs_session() as bugs:
487 res = bugs.CheckNativeStatus(
488 bugs_pb2.CheckNativeStatusReq(
489 eas_client_id=str(EAS_CLIENT_ID), app_version="1.1.20", platform="ios", user_state="logged_out"
490 )
491 )
493 # No build timestamps reported -> no clock runs -> no update asked for.
494 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE
495 assert res.update_info.required is False
498def test_check_native_status_authenticated(db):
499 _, token = generate_user()
501 with bugs_session(token) as bugs:
502 res = bugs.CheckNativeStatus(
503 bugs_pb2.CheckNativeStatusReq(
504 eas_client_id=str(EAS_CLIENT_ID), platform="android", user_state="authenticated"
505 )
506 )
508 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE
509 assert res.update_info.required is False
512def test_check_native_status_authenticated_records_mapping(db):
513 user, token = generate_user()
515 with bugs_session(token) as bugs:
516 bugs.CheckNativeStatus(
517 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="authenticated")
518 )
520 with session_scope() as session:
521 row = session.execute(
522 select(NativeClientUser).where(NativeClientUser.eas_client_id == EAS_CLIENT_ID)
523 ).scalar_one()
524 assert row.user_id == user.id
527def test_check_native_status_anonymous_does_not_record_mapping(db):
528 with bugs_session() as bugs:
529 bugs.CheckNativeStatus(
530 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="logged_out")
531 )
533 with session_scope() as session:
534 count = session.execute(select(func.count()).select_from(NativeClientUser)).scalar_one()
535 assert count == 0
538def test_check_native_status_append_only_log_of_sightings(db):
539 # A shared install — same eas-client-id, two users — produces two rows; newest is user_b.
540 user_a, token_a = generate_user()
541 user_b, token_b = generate_user()
543 with bugs_session(token_a) as bugs:
544 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios"))
545 with bugs_session(token_b) as bugs:
546 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios"))
548 with session_scope() as session:
549 rows = (
550 session.execute(
551 select(NativeClientUser)
552 .where(NativeClientUser.eas_client_id == EAS_CLIENT_ID)
553 .order_by(NativeClientUser.id)
554 )
555 .scalars()
556 .all()
557 )
558 assert [r.user_id for r in rows] == [user_a.id, user_b.id]
561def test_check_native_status_blocks_expired_binary(db):
562 # A native binary older than the (default 91-day) store window -> required store update, blocking.
563 embedded_created_at = timestamp_pb2.Timestamp()
564 embedded_created_at.FromDatetime(datetime.now(UTC) - timedelta(days=120))
565 with bugs_session() as bugs:
566 res = bugs.CheckNativeStatus(
567 bugs_pb2.CheckNativeStatusReq(
568 eas_client_id=str(EAS_CLIENT_ID), platform="ios", embedded_created_at=embedded_created_at
569 )
570 )
572 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_STORE
573 assert res.update_info.required is True
574 assert res.update_info.act_by.ToDatetime(tzinfo=UTC) <= datetime.now(UTC)
577def _multipart_part_json(body, name):
578 """Extract and parse the JSON body of a named part from a multipart/mixed body."""
579 marker = f'name="{name}"'
580 start = body.index("\r\n\r\n", body.index(marker)) + 4
581 end = body.index("\r\n--", start)
582 return json.loads(body[start:end])
585_OTA_CDN_ROOT = "https://cdn.testing.invalid/native/ota"
586_CDN_CONTENT_TYPE = "multipart/mixed; boundary=COUCHERS_OTA_BOUNDARY"
589class _FakeCDNResponse:
590 # Echoes the requested URL back as the body so tests can assert which version was fetched and that
591 # the bytes are served verbatim — standing in for the pre-signed manifest the CDN holds.
592 def __init__(self, url):
593 self.headers = {"content-type": _CDN_CONTENT_TYPE}
594 self.content = url.encode()
596 def raise_for_status(self):
597 pass
600def _patch_cdn():
601 return patch("couchers.servicers.bugs.requests.get", side_effect=lambda url, timeout=None: _FakeCDNResponse(url))
604def _add_ota_package(*, platform, fingerprint, version, created_at, banned=False):
605 with session_scope() as session:
606 creator, _ = generate_user()
607 package = OTAPackage(
608 creator_user_id=creator.id,
609 platform=platform,
610 fingerprint=fingerprint,
611 version=version,
612 manifest_created_at=created_at,
613 manifest_id=f"id-{version}",
614 banned_at=created_at if banned else None,
615 banned_by_user_id=creator.id if banned else None,
616 banned_reason="test ban" if banned else None,
617 )
618 session.add(package)
619 session.flush()
622def test_native_update_manifest_serves_matching_package(db, feature_flags):
623 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
624 _fetch_signed_manifest.cache_clear()
625 _add_ota_package(
626 platform=OTAPlatform.ios,
627 fingerprint="ios-fingerprint",
628 version="v1.3.1.aaaa",
629 created_at=datetime(2026, 5, 31, tzinfo=UTC),
630 )
631 with _patch_cdn():
632 with real_bugs_session() as (bugs, metadata_interceptor):
633 res = bugs.GetNativeUpdateManifest(
634 httpbody_pb2.HttpBody(),
635 metadata=(
636 ("eas-client-id", str(EAS_CLIENT_ID)),
637 ("expo-platform", "ios"),
638 ("expo-runtime-version", "ios-fingerprint"),
639 ),
640 )
642 # the signed bytes are fetched from the CDN under the package's version and served verbatim
643 assert res.content_type == _CDN_CONTENT_TYPE
644 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.aaaa/ios/manifest"
645 # the client requires these response headers or it rejects the manifest
646 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
647 assert metadata_interceptor.latest_headers["expo-sfv-version"] == "0"
650def test_native_update_manifest_resolves_per_platform(db, feature_flags):
651 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
652 _fetch_signed_manifest.cache_clear()
653 _add_ota_package(
654 platform=OTAPlatform.ios,
655 fingerprint="shared-fingerprint",
656 version="v1.3.1.ios",
657 created_at=datetime(2026, 5, 31, tzinfo=UTC),
658 )
659 _add_ota_package(
660 platform=OTAPlatform.android,
661 fingerprint="shared-fingerprint",
662 version="v1.3.1.android",
663 created_at=datetime(2026, 5, 31, tzinfo=UTC),
664 )
665 with _patch_cdn():
666 with real_bugs_session() as (bugs, _metadata_interceptor):
667 res = bugs.GetNativeUpdateManifest(
668 httpbody_pb2.HttpBody(),
669 metadata=(
670 ("eas-client-id", str(EAS_CLIENT_ID)),
671 ("expo-platform", "android"),
672 ("expo-runtime-version", "shared-fingerprint"),
673 ),
674 )
676 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.android/android/manifest"
679def test_native_update_manifest_serves_newest_by_created_at(db, feature_flags):
680 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
681 _fetch_signed_manifest.cache_clear()
682 # the newer createdAt wins regardless of insertion order
683 _add_ota_package(
684 platform=OTAPlatform.ios,
685 fingerprint="ios-fingerprint",
686 version="v1.3.2.newer",
687 created_at=datetime(2026, 5, 31, tzinfo=UTC),
688 )
689 _add_ota_package(
690 platform=OTAPlatform.ios,
691 fingerprint="ios-fingerprint",
692 version="v1.3.1.older",
693 created_at=datetime(2026, 5, 30, tzinfo=UTC),
694 )
695 with _patch_cdn():
696 with real_bugs_session() as (bugs, _metadata_interceptor):
697 res = bugs.GetNativeUpdateManifest(
698 httpbody_pb2.HttpBody(),
699 metadata=(
700 ("eas-client-id", str(EAS_CLIENT_ID)),
701 ("expo-platform", "ios"),
702 ("expo-runtime-version", "ios-fingerprint"),
703 ),
704 )
706 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.2.newer/ios/manifest"
709def test_native_update_manifest_banned_package_excluded(db, feature_flags):
710 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
711 _fetch_signed_manifest.cache_clear()
712 _add_ota_package(
713 platform=OTAPlatform.ios,
714 fingerprint="ios-fingerprint",
715 version="v1.3.1.good",
716 created_at=datetime(2026, 5, 30, tzinfo=UTC),
717 )
718 _add_ota_package(
719 platform=OTAPlatform.ios,
720 fingerprint="ios-fingerprint",
721 version="v1.3.2.bad",
722 created_at=datetime(2026, 5, 31, tzinfo=UTC),
723 banned=True,
724 )
725 with _patch_cdn():
726 with real_bugs_session() as (bugs, _metadata_interceptor):
727 res = bugs.GetNativeUpdateManifest(
728 httpbody_pb2.HttpBody(),
729 metadata=(
730 ("eas-client-id", str(EAS_CLIENT_ID)),
731 ("expo-platform", "ios"),
732 ("expo-runtime-version", "ios-fingerprint"),
733 ),
734 )
736 # the newest is banned, so new check-ins get the previous one (a re-stamp would supersede it)
737 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.good/ios/manifest"
740def test_native_update_manifest_runtime_mismatch_returns_directive(db):
741 _add_ota_package(
742 platform=OTAPlatform.ios,
743 fingerprint="ios-fingerprint",
744 version="v1.3.1.aaaa",
745 created_at=datetime(2026, 5, 31, tzinfo=UTC),
746 )
747 with _patch_cdn() as cdn_get:
748 with real_bugs_session() as (bugs, _metadata_interceptor):
749 res = bugs.GetNativeUpdateManifest(
750 httpbody_pb2.HttpBody(),
751 metadata=(
752 ("eas-client-id", str(EAS_CLIENT_ID)),
753 ("expo-platform", "ios"),
754 ("expo-runtime-version", "some-other-fingerprint"),
755 ),
756 )
758 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
759 # a mismatch must not even fetch — the manifest would be rejected on this build
760 cdn_get.assert_not_called()
763def test_native_update_manifest_only_banned_package_returns_directive(db):
764 _add_ota_package(
765 platform=OTAPlatform.ios,
766 fingerprint="ios-fingerprint",
767 version="v1.3.1.aaaa",
768 created_at=datetime(2026, 5, 31, tzinfo=UTC),
769 banned=True,
770 )
771 with real_bugs_session() as (bugs, _metadata_interceptor):
772 res = bugs.GetNativeUpdateManifest(
773 httpbody_pb2.HttpBody(),
774 metadata=(
775 ("eas-client-id", str(EAS_CLIENT_ID)),
776 ("expo-platform", "ios"),
777 ("expo-runtime-version", "ios-fingerprint"),
778 ),
779 )
781 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
784def test_native_update_manifest_without_runtime_version_returns_directive(db):
785 _add_ota_package(
786 platform=OTAPlatform.ios,
787 fingerprint="ios-fingerprint",
788 version="v1.3.1.aaaa",
789 created_at=datetime(2026, 5, 31, tzinfo=UTC),
790 )
791 with real_bugs_session() as (bugs, metadata_interceptor):
792 res = bugs.GetNativeUpdateManifest(
793 httpbody_pb2.HttpBody(),
794 metadata=(
795 ("eas-client-id", str(EAS_CLIENT_ID)),
796 ("expo-platform", "ios"),
797 ),
798 )
800 body = res.data.decode()
801 assert _multipart_part_json(body, "directive") == {"type": "noUpdateAvailable"}
802 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
805def test_native_update_manifest_no_package_returns_directive(db):
806 with real_bugs_session() as (bugs, _metadata_interceptor):
807 res = bugs.GetNativeUpdateManifest(
808 httpbody_pb2.HttpBody(),
809 metadata=(
810 ("eas-client-id", str(EAS_CLIENT_ID)),
811 ("expo-platform", "ios"),
812 ("expo-runtime-version", "ios-fingerprint"),
813 ),
814 )
816 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
819def _ota_check_req(*, created_at, update_id=""):
820 ts = timestamp_pb2.Timestamp()
821 ts.FromDatetime(created_at)
822 return bugs_pb2.CheckNativeStatusReq(
823 eas_client_id=str(EAS_CLIENT_ID),
824 platform="ios",
825 runtime_version="ios-fingerprint",
826 launch_source="ota",
827 update_id=update_id,
828 created_at=ts,
829 )
832def test_check_native_status_ota_block_with_newer_bundle(db):
833 _add_ota_package(
834 platform=OTAPlatform.ios,
835 fingerprint="ios-fingerprint",
836 version="v1.3.2.newer",
837 created_at=datetime.now(UTC) - timedelta(days=1),
838 )
839 with bugs_session() as bugs:
840 res = bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
842 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA
843 assert res.update_info.required is True
844 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_AGE
847def test_check_native_status_ota_block_without_target_raises(db):
848 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
849 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
852def test_check_native_status_ota_block_only_older_target_raises(db):
853 _add_ota_package(
854 platform=OTAPlatform.ios,
855 fingerprint="ios-fingerprint",
856 version="v1.3.1.older",
857 created_at=datetime.now(UTC) - timedelta(days=50),
858 )
859 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
860 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
863def test_check_native_status_banned_ota_block_with_successor(db):
864 _add_ota_package(
865 platform=OTAPlatform.ios,
866 fingerprint="ios-fingerprint",
867 version="v1.bad",
868 created_at=datetime.now(UTC) - timedelta(days=5),
869 banned=True,
870 )
871 _add_ota_package(
872 platform=OTAPlatform.ios,
873 fingerprint="ios-fingerprint",
874 version="v1.good",
875 created_at=datetime.now(UTC) - timedelta(days=1),
876 )
877 with bugs_session() as bugs:
878 res = bugs.CheckNativeStatus(
879 _ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad")
880 )
882 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA
883 assert res.update_info.required is True
884 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_BANNED
887def test_check_native_status_banned_ota_block_no_successor_raises(db):
888 _add_ota_package(
889 platform=OTAPlatform.ios,
890 fingerprint="ios-fingerprint",
891 version="v1.bad",
892 created_at=datetime.now(UTC) - timedelta(days=5),
893 banned=True,
894 )
895 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
896 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad"))
899def test_log_experiment_exposure(db):
900 user, token = generate_user()
902 with bugs_session(token) as bugs:
903 bugs.LogExperimentExposure(
904 bugs_pb2.LogExperimentExposureReq(
905 experiment_key="my_experiment",
906 experiment_name="My Experiment",
907 variation_id=1,
908 variation_key="treatment",
909 variation_name="Treatment",
910 hash_attribute="id",
911 hash_value=str(user.id),
912 feature_id="my_feature",
913 in_experiment=True,
914 bucket=0.5,
915 hash_used=True,
916 sticky_bucket_used=False,
917 )
918 )
920 with session_scope() as session:
921 exposure = session.execute(select(ExperimentExposure)).scalar_one()
922 assert exposure.user_id == user.id
923 assert exposure.experiment_key == "my_experiment"
924 assert exposure.variation_id == 1
925 assert exposure.source == ExposureSource.client
926 assert exposure.data == {
927 "experiment_name": "My Experiment",
928 "variation_key": "treatment",
929 "variation_name": "Treatment",
930 "hash_attribute": "id",
931 "hash_value": str(user.id),
932 "bucket": 0.5,
933 "in_experiment": True,
934 "hash_used": True,
935 "sticky_bucket_used": False,
936 "feature_id": "my_feature",
937 }
940def test_log_experiment_exposure_deduped(db):
941 user, token = generate_user()
943 with bugs_session(token) as bugs:
944 for _ in range(3):
945 bugs.LogExperimentExposure(
946 bugs_pb2.LogExperimentExposureReq(
947 experiment_key="my_experiment",
948 variation_id=1,
949 variation_key="treatment",
950 hash_attribute="id",
951 hash_value=str(user.id),
952 )
953 )
955 with session_scope() as session:
956 exposure = session.execute(select(ExperimentExposure)).scalar_one()
957 # unset optional fields are stored as null, not a misleading 0/false
958 assert exposure.data["bucket"] is None
959 assert exposure.data["hash_used"] is None
960 assert exposure.data["sticky_bucket_used"] is None
963def test_log_experiment_exposure_anonymous_ignored(db):
964 with bugs_session() as bugs:
965 bugs.LogExperimentExposure(
966 bugs_pb2.LogExperimentExposureReq(
967 experiment_key="my_experiment",
968 variation_id=1,
969 variation_key="treatment",
970 hash_attribute="id",
971 hash_value="123",
972 )
973 )
975 with session_scope() as session:
976 count = session.execute(select(func.count()).select_from(ExperimentExposure)).scalar_one()
977 assert count == 0