Coverage for app/backend/src/tests/test_bugs.py: 99%
366 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +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")
25def test_bugs_disabled():
26 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
27 bugs.ReportBug(
28 bugs_pb2.ReportBugReq(
29 subject="subject",
30 description="description",
31 results="results",
32 frontend_version="frontend_version",
33 user_agent="user_agent",
34 page="page",
35 )
36 )
37 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
40def test_bugs(db):
41 with bugs_session() as bugs:
43 def dud_post(url, auth, json):
44 assert url == "https://api.github.com/repos/org/repo/issues"
45 assert auth == ("user", "token")
47 expected_body = f"""
48# subject
49## Description
50description
52## Results
53results
55## Diagnostics
56**Backend version**: `{config.VERSION}`
57**Frontend version**: `frontend_version`
58**User Agent**: `user_agent`
59**Locale**: `en`
60**Screen resolution**: 1920x1080
61**Page**: page
62**User**: <not logged in> / `test_sofa_co`""".strip()
64 assert json == {
65 "title": "subject",
66 "body": expected_body,
67 "labels": ["bug: triage needed"],
68 }
70 class _PostReturn:
71 status_code = 201
73 def json(self):
74 return {"number": 11}
76 return _PostReturn()
78 config.BUG_TOOL_ENABLED = True
80 with patch("couchers.servicers.bugs.requests.post", dud_post):
81 res = bugs.ReportBug(
82 bugs_pb2.ReportBugReq(
83 subject="subject",
84 description="description",
85 results="results",
86 frontend_version="frontend_version",
87 user_agent="user_agent",
88 screen_resolution=bugs_pb2.ScreenResolution(width=1920, height=1080),
89 page="page",
90 )
91 )
93 assert res.bug_id == "#11"
94 assert res.bug_url == "https://github.com/org/repo/issues/11"
97def test_bugs_with_user(db, frozen_timewarp):
98 user, token = generate_user(username="testing_user")
100 with bugs_session(token) as bugs:
102 def dud_post(url, auth, json):
103 assert url == "https://api.github.com/repos/org/repo/issues"
104 assert auth == ("user", "token")
106 expected_body = f"""
107# subject
108## Description
109description
111## Results
112results
114## Diagnostics
115**Backend version**: `{config.VERSION}`
116**Frontend version**: `frontend_version`
117**User Agent**: `user_agent`
118**Locale**: `en`
119**Screen resolution**: 390x844
120**Page**: page
121**User**: [@testing_user](http://localhost:3000/user/testing_user) (1) / `test_sofa_co`
122**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()
124 assert json == {
125 "title": "subject",
126 "body": expected_body,
127 "labels": ["bug: triage needed"],
128 }
130 class _PostReturn:
131 status_code = 201
133 def json(self):
134 return {"number": 11}
136 return _PostReturn()
138 config.BUG_TOOL_ENABLED = True
140 with patch("couchers.servicers.bugs.requests.post", dud_post):
141 res = bugs.ReportBug(
142 bugs_pb2.ReportBugReq(
143 subject="subject",
144 description="description",
145 results="results",
146 frontend_version="frontend_version",
147 user_agent="user_agent",
148 screen_resolution=bugs_pb2.ScreenResolution(width=390, height=844),
149 page="page",
150 )
151 )
153 assert res.bug_id == "#11"
154 assert res.bug_url == "https://github.com/org/repo/issues/11"
157def test_bugs_with_sentry_replay(db):
158 with bugs_session() as bugs:
160 def dud_post(url, auth, json):
161 expected_body = f"""
162# subject
163## Description
164description
166## Results
167results
169## Diagnostics
170**Backend version**: `{config.VERSION}`
171**Frontend version**: `frontend_version`
172**User Agent**: `user_agent`
173**Locale**: `en`
174**Screen resolution**: 1920x1080
175**Page**: page
176**User**: <not logged in> / `test_sofa_co`
177**Session replay**: https://couchers.sentry.io/replays/0123456789abcdef0123456789abcdef/?project=1234""".strip()
179 assert json["body"] == expected_body
181 class _PostReturn:
182 status_code = 201
184 def json(self):
185 return {"number": 11}
187 return _PostReturn()
189 config.BUG_TOOL_ENABLED = True
191 with patch("couchers.servicers.bugs.requests.post", dud_post):
192 bugs.ReportBug(
193 bugs_pb2.ReportBugReq(
194 subject="subject",
195 description="description",
196 results="results",
197 frontend_version="frontend_version",
198 user_agent="user_agent",
199 screen_resolution=bugs_pb2.ScreenResolution(width=1920, height=1080),
200 page="page",
201 sentry_replay_id="0123456789abcdef0123456789abcdef",
202 )
203 )
206def test_bugs_invalid_sentry_replay_id_omitted(db):
207 # A malformed/garbage replay id must not be interpolated into the issue at all.
208 with bugs_session() as bugs:
210 def dud_post(url, auth, json):
211 assert "Session replay" not in json["body"]
213 class _PostReturn:
214 status_code = 201
216 def json(self):
217 return {"number": 11}
219 return _PostReturn()
221 config.BUG_TOOL_ENABLED = True
223 with patch("couchers.servicers.bugs.requests.post", dud_post):
224 bugs.ReportBug(
225 bugs_pb2.ReportBugReq(
226 subject="subject",
227 description="description",
228 results="results",
229 sentry_replay_id="not-a-replay-id](https://evil.example)",
230 )
231 )
234def test_bugs_fails_on_network_error(db):
235 with bugs_session() as bugs:
237 def dud_post(url, auth, json):
238 class _PostReturn:
239 status_code = 400
241 return _PostReturn()
243 config.BUG_TOOL_ENABLED = True
245 with patch("couchers.servicers.bugs.requests.post", dud_post):
246 with pytest.raises(grpc.RpcError) as e:
247 res = bugs.ReportBug(
248 bugs_pb2.ReportBugReq(
249 subject="subject",
250 description="description",
251 results="results",
252 frontend_version="frontend_version",
253 user_agent="user_agent",
254 page="page",
255 )
256 )
257 assert e.value.code() == grpc.StatusCode.INTERNAL
260def test_version():
261 with bugs_session() as bugs:
262 res = bugs.Version(empty_pb2.Empty())
263 assert res.version == "testing_version"
266def test_status(db):
267 for _ in range(5):
268 generate_user()
270 with bugs_session() as bugs:
271 nonce = random_hex()
272 res = bugs.Status(bugs_pb2.StatusReq(nonce=nonce))
273 assert res.nonce == nonce
274 assert res.version == "testing_version"
275 assert res.coucher_count == 5
278def test_GetDescriptors():
279 with bugs_session() as bugs:
280 res = bugs.GetDescriptors(empty_pb2.Empty())
281 # test we got something roughly binary back
282 assert res.content_type == "application/octet-stream"
283 assert len(res.data) > 2**12
286def _get_events(session, event_type=None):
287 stmt = select(EventLog).order_by(EventLog.id)
288 if event_type: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 stmt = stmt.where(EventLog.event_type == event_type)
290 return session.execute(stmt).scalars().all()
293def test_report_diagnostics_anonymous(db):
294 with bugs_session() as bugs:
295 bugs.ReportDiagnostics(
296 bugs_pb2.ReportDiagnosticsReq(
297 frontend_version="1.2.3",
298 infos=[
299 bugs_pb2.DiagnosticInfo(
300 tag="page.viewed",
301 properties_json='{"path": "/"}',
302 value=1,
303 ),
304 bugs_pb2.DiagnosticInfo(
305 tag="session.started",
306 properties_json='{"referrer": "google.com"}',
307 value=1,
308 ),
309 ],
310 )
311 )
313 with session_scope() as session:
314 events = _get_events(session)
315 assert len(events) == 2
317 e0 = events[0]
318 assert e0.event_type == "page.viewed"
319 assert e0.properties == {"path": "/"}
320 assert e0.user_id is None
321 assert e0.source == EventSource.frontend
322 assert e0.value == 1
323 assert e0.version == "1.2.3"
325 e1 = events[1]
326 assert e1.event_type == "session.started"
327 assert e1.properties == {"referrer": "google.com"}
328 assert e1.source == EventSource.frontend
331def test_report_diagnostics_authenticated(db):
332 user, token = generate_user()
334 with bugs_session(token) as bugs:
335 bugs.ReportDiagnostics(
336 bugs_pb2.ReportDiagnosticsReq(
337 frontend_version="1.2.3",
338 infos=[
339 bugs_pb2.DiagnosticInfo(
340 tag="page.viewed",
341 properties_json='{"path": "/search"}',
342 value=1,
343 ),
344 ],
345 )
346 )
348 with session_scope() as session:
349 events = _get_events(session)
350 assert len(events) == 1
351 assert events[0].user_id == user.id
352 assert events[0].source == EventSource.frontend
355def test_report_diagnostics_with_value(db):
356 with bugs_session() as bugs:
357 bugs.ReportDiagnostics(
358 bugs_pb2.ReportDiagnosticsReq(
359 frontend_version="1.2.3",
360 infos=[
361 bugs_pb2.DiagnosticInfo(
362 tag="search.result_hovered",
363 properties_json='{"user_id": 5}',
364 value=1500.5,
365 ),
366 ],
367 )
368 )
370 with session_scope() as session:
371 events = _get_events(session)
372 assert len(events) == 1
373 assert events[0].value == pytest.approx(1500.5)
376def test_report_diagnostics_with_occurred(db):
377 ts = timestamp_pb2.Timestamp()
378 ts.FromDatetime(datetime(2026, 1, 15, 10, 30, 0, tzinfo=UTC))
380 with bugs_session() as bugs:
381 bugs.ReportDiagnostics(
382 bugs_pb2.ReportDiagnosticsReq(
383 frontend_version="1.2.3",
384 infos=[
385 bugs_pb2.DiagnosticInfo(
386 tag="page.viewed",
387 properties_json="{}",
388 value=1,
389 occurred=ts,
390 ),
391 ],
392 )
393 )
395 with session_scope() as session:
396 events = _get_events(session)
397 assert len(events) == 1
398 assert events[0].occurred.year == 2026
399 assert events[0].occurred.month == 1
400 assert events[0].occurred.day == 15
401 assert events[0].occurred.hour == 10
402 assert events[0].occurred.minute == 30
405def test_report_diagnostics_invalid_json(db):
406 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
407 bugs.ReportDiagnostics(
408 bugs_pb2.ReportDiagnosticsReq(
409 frontend_version="1.2.3",
410 infos=[
411 bugs_pb2.DiagnosticInfo(
412 tag="page.viewed",
413 properties_json="not valid json{{{",
414 value=1,
415 ),
416 ],
417 )
418 )
419 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
422def test_report_diagnostics_empty_batch(db):
423 with bugs_session() as bugs:
424 bugs.ReportDiagnostics(
425 bugs_pb2.ReportDiagnosticsReq(
426 frontend_version="1.2.3",
427 infos=[],
428 )
429 )
431 with session_scope() as session:
432 events = _get_events(session)
433 assert len(events) == 0
436def test_report_diagnostics_too_many(db):
437 infos = [bugs_pb2.DiagnosticInfo(tag=f"event.{i}", properties_json="{}", value=1) for i in range(101)]
439 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
440 bugs.ReportDiagnostics(
441 bugs_pb2.ReportDiagnosticsReq(
442 frontend_version="1.2.3",
443 infos=infos,
444 )
445 )
446 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
449def test_report_diagnostics_frontend_version(db):
450 with bugs_session() as bugs:
451 bugs.ReportDiagnostics(
452 bugs_pb2.ReportDiagnosticsReq(
453 frontend_version="abc-def-123",
454 infos=[
455 bugs_pb2.DiagnosticInfo(
456 tag="page.viewed",
457 properties_json="{}",
458 value=1,
459 ),
460 ],
461 )
462 )
464 with session_scope() as session:
465 events = _get_events(session)
466 assert len(events) == 1
467 assert events[0].version == "abc-def-123"
470def test_check_native_status_anonymous(db):
471 with bugs_session() as bugs:
472 res = bugs.CheckNativeStatus(
473 bugs_pb2.CheckNativeStatusReq(
474 eas_client_id=str(EAS_CLIENT_ID), app_version="1.1.20", platform="ios", user_state="logged_out"
475 )
476 )
478 # No build timestamps reported -> no clock runs -> no update asked for.
479 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE
480 assert res.update_info.required is False
483def test_check_native_status_authenticated(db):
484 _, token = generate_user()
486 with bugs_session(token) as bugs:
487 res = bugs.CheckNativeStatus(
488 bugs_pb2.CheckNativeStatusReq(
489 eas_client_id=str(EAS_CLIENT_ID), platform="android", user_state="authenticated"
490 )
491 )
493 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE
494 assert res.update_info.required is False
497def test_check_native_status_authenticated_records_mapping(db):
498 user, token = generate_user()
500 with bugs_session(token) as bugs:
501 bugs.CheckNativeStatus(
502 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="authenticated")
503 )
505 with session_scope() as session:
506 row = session.execute(
507 select(NativeClientUser).where(NativeClientUser.eas_client_id == EAS_CLIENT_ID)
508 ).scalar_one()
509 assert row.user_id == user.id
512def test_check_native_status_anonymous_does_not_record_mapping(db):
513 with bugs_session() as bugs:
514 bugs.CheckNativeStatus(
515 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="logged_out")
516 )
518 with session_scope() as session:
519 count = session.execute(select(func.count()).select_from(NativeClientUser)).scalar_one()
520 assert count == 0
523def test_check_native_status_append_only_log_of_sightings(db):
524 # A shared install — same eas-client-id, two users — produces two rows; newest is user_b.
525 user_a, token_a = generate_user()
526 user_b, token_b = generate_user()
528 with bugs_session(token_a) as bugs:
529 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios"))
530 with bugs_session(token_b) as bugs:
531 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios"))
533 with session_scope() as session:
534 rows = (
535 session.execute(
536 select(NativeClientUser)
537 .where(NativeClientUser.eas_client_id == EAS_CLIENT_ID)
538 .order_by(NativeClientUser.id)
539 )
540 .scalars()
541 .all()
542 )
543 assert [r.user_id for r in rows] == [user_a.id, user_b.id]
546def test_check_native_status_blocks_expired_binary(db):
547 # A native binary older than the (default 91-day) store window -> required store update, blocking.
548 embedded_created_at = timestamp_pb2.Timestamp()
549 embedded_created_at.FromDatetime(datetime.now(UTC) - timedelta(days=120))
550 with bugs_session() as bugs:
551 res = bugs.CheckNativeStatus(
552 bugs_pb2.CheckNativeStatusReq(
553 eas_client_id=str(EAS_CLIENT_ID), platform="ios", embedded_created_at=embedded_created_at
554 )
555 )
557 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_STORE
558 assert res.update_info.required is True
559 assert res.update_info.act_by.ToDatetime(tzinfo=UTC) <= datetime.now(UTC)
562def _multipart_part_json(body, name):
563 """Extract and parse the JSON body of a named part from a multipart/mixed body."""
564 marker = f'name="{name}"'
565 start = body.index("\r\n\r\n", body.index(marker)) + 4
566 end = body.index("\r\n--", start)
567 return json.loads(body[start:end])
570_OTA_CDN_ROOT = "https://cdn.testing.invalid/native/ota"
571_CDN_CONTENT_TYPE = "multipart/mixed; boundary=COUCHERS_OTA_BOUNDARY"
574class _FakeCDNResponse:
575 # Echoes the requested URL back as the body so tests can assert which version was fetched and that
576 # the bytes are served verbatim — standing in for the pre-signed manifest the CDN holds.
577 def __init__(self, url):
578 self.headers = {"content-type": _CDN_CONTENT_TYPE}
579 self.content = url.encode()
581 def raise_for_status(self):
582 pass
585def _patch_cdn():
586 return patch("couchers.servicers.bugs.requests.get", side_effect=lambda url, timeout=None: _FakeCDNResponse(url))
589def _add_ota_package(*, platform, fingerprint, version, created_at, banned=False):
590 with session_scope() as session:
591 creator, _ = generate_user()
592 package = OTAPackage(
593 creator_user_id=creator.id,
594 platform=platform,
595 fingerprint=fingerprint,
596 version=version,
597 manifest_created_at=created_at,
598 manifest_id=f"id-{version}",
599 banned_at=created_at if banned else None,
600 banned_by_user_id=creator.id if banned else None,
601 banned_reason="test ban" if banned else None,
602 )
603 session.add(package)
604 session.flush()
607def test_native_update_manifest_serves_matching_package(db, feature_flags):
608 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
609 _fetch_signed_manifest.cache_clear()
610 _add_ota_package(
611 platform=OTAPlatform.ios,
612 fingerprint="ios-fingerprint",
613 version="v1.3.1.aaaa",
614 created_at=datetime(2026, 5, 31, tzinfo=UTC),
615 )
616 with _patch_cdn():
617 with real_bugs_session() as (bugs, metadata_interceptor):
618 res = bugs.GetNativeUpdateManifest(
619 httpbody_pb2.HttpBody(),
620 metadata=(
621 ("eas-client-id", str(EAS_CLIENT_ID)),
622 ("expo-platform", "ios"),
623 ("expo-runtime-version", "ios-fingerprint"),
624 ),
625 )
627 # the signed bytes are fetched from the CDN under the package's version and served verbatim
628 assert res.content_type == _CDN_CONTENT_TYPE
629 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.aaaa/ios/manifest"
630 # the client requires these response headers or it rejects the manifest
631 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
632 assert metadata_interceptor.latest_headers["expo-sfv-version"] == "0"
635def test_native_update_manifest_resolves_per_platform(db, feature_flags):
636 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
637 _fetch_signed_manifest.cache_clear()
638 _add_ota_package(
639 platform=OTAPlatform.ios,
640 fingerprint="shared-fingerprint",
641 version="v1.3.1.ios",
642 created_at=datetime(2026, 5, 31, tzinfo=UTC),
643 )
644 _add_ota_package(
645 platform=OTAPlatform.android,
646 fingerprint="shared-fingerprint",
647 version="v1.3.1.android",
648 created_at=datetime(2026, 5, 31, tzinfo=UTC),
649 )
650 with _patch_cdn():
651 with real_bugs_session() as (bugs, _metadata_interceptor):
652 res = bugs.GetNativeUpdateManifest(
653 httpbody_pb2.HttpBody(),
654 metadata=(
655 ("eas-client-id", str(EAS_CLIENT_ID)),
656 ("expo-platform", "android"),
657 ("expo-runtime-version", "shared-fingerprint"),
658 ),
659 )
661 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.android/android/manifest"
664def test_native_update_manifest_serves_newest_by_created_at(db, feature_flags):
665 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
666 _fetch_signed_manifest.cache_clear()
667 # the newer createdAt wins regardless of insertion order
668 _add_ota_package(
669 platform=OTAPlatform.ios,
670 fingerprint="ios-fingerprint",
671 version="v1.3.2.newer",
672 created_at=datetime(2026, 5, 31, tzinfo=UTC),
673 )
674 _add_ota_package(
675 platform=OTAPlatform.ios,
676 fingerprint="ios-fingerprint",
677 version="v1.3.1.older",
678 created_at=datetime(2026, 5, 30, tzinfo=UTC),
679 )
680 with _patch_cdn():
681 with real_bugs_session() as (bugs, _metadata_interceptor):
682 res = bugs.GetNativeUpdateManifest(
683 httpbody_pb2.HttpBody(),
684 metadata=(
685 ("eas-client-id", str(EAS_CLIENT_ID)),
686 ("expo-platform", "ios"),
687 ("expo-runtime-version", "ios-fingerprint"),
688 ),
689 )
691 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.2.newer/ios/manifest"
694def test_native_update_manifest_banned_package_excluded(db, feature_flags):
695 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
696 _fetch_signed_manifest.cache_clear()
697 _add_ota_package(
698 platform=OTAPlatform.ios,
699 fingerprint="ios-fingerprint",
700 version="v1.3.1.good",
701 created_at=datetime(2026, 5, 30, tzinfo=UTC),
702 )
703 _add_ota_package(
704 platform=OTAPlatform.ios,
705 fingerprint="ios-fingerprint",
706 version="v1.3.2.bad",
707 created_at=datetime(2026, 5, 31, tzinfo=UTC),
708 banned=True,
709 )
710 with _patch_cdn():
711 with real_bugs_session() as (bugs, _metadata_interceptor):
712 res = bugs.GetNativeUpdateManifest(
713 httpbody_pb2.HttpBody(),
714 metadata=(
715 ("eas-client-id", str(EAS_CLIENT_ID)),
716 ("expo-platform", "ios"),
717 ("expo-runtime-version", "ios-fingerprint"),
718 ),
719 )
721 # the newest is banned, so new check-ins get the previous one (a re-stamp would supersede it)
722 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.good/ios/manifest"
725def test_native_update_manifest_runtime_mismatch_returns_directive(db):
726 _add_ota_package(
727 platform=OTAPlatform.ios,
728 fingerprint="ios-fingerprint",
729 version="v1.3.1.aaaa",
730 created_at=datetime(2026, 5, 31, tzinfo=UTC),
731 )
732 with _patch_cdn() as cdn_get:
733 with real_bugs_session() as (bugs, _metadata_interceptor):
734 res = bugs.GetNativeUpdateManifest(
735 httpbody_pb2.HttpBody(),
736 metadata=(
737 ("eas-client-id", str(EAS_CLIENT_ID)),
738 ("expo-platform", "ios"),
739 ("expo-runtime-version", "some-other-fingerprint"),
740 ),
741 )
743 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
744 # a mismatch must not even fetch — the manifest would be rejected on this build
745 cdn_get.assert_not_called()
748def test_native_update_manifest_only_banned_package_returns_directive(db):
749 _add_ota_package(
750 platform=OTAPlatform.ios,
751 fingerprint="ios-fingerprint",
752 version="v1.3.1.aaaa",
753 created_at=datetime(2026, 5, 31, tzinfo=UTC),
754 banned=True,
755 )
756 with real_bugs_session() as (bugs, _metadata_interceptor):
757 res = bugs.GetNativeUpdateManifest(
758 httpbody_pb2.HttpBody(),
759 metadata=(
760 ("eas-client-id", str(EAS_CLIENT_ID)),
761 ("expo-platform", "ios"),
762 ("expo-runtime-version", "ios-fingerprint"),
763 ),
764 )
766 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
769def test_native_update_manifest_without_runtime_version_returns_directive(db):
770 _add_ota_package(
771 platform=OTAPlatform.ios,
772 fingerprint="ios-fingerprint",
773 version="v1.3.1.aaaa",
774 created_at=datetime(2026, 5, 31, tzinfo=UTC),
775 )
776 with real_bugs_session() as (bugs, metadata_interceptor):
777 res = bugs.GetNativeUpdateManifest(
778 httpbody_pb2.HttpBody(),
779 metadata=(
780 ("eas-client-id", str(EAS_CLIENT_ID)),
781 ("expo-platform", "ios"),
782 ),
783 )
785 body = res.data.decode()
786 assert _multipart_part_json(body, "directive") == {"type": "noUpdateAvailable"}
787 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
790def test_native_update_manifest_no_package_returns_directive(db):
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 ("expo-runtime-version", "ios-fingerprint"),
798 ),
799 )
801 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
804def _ota_check_req(*, created_at, update_id=""):
805 ts = timestamp_pb2.Timestamp()
806 ts.FromDatetime(created_at)
807 return bugs_pb2.CheckNativeStatusReq(
808 eas_client_id=str(EAS_CLIENT_ID),
809 platform="ios",
810 runtime_version="ios-fingerprint",
811 launch_source="ota",
812 update_id=update_id,
813 created_at=ts,
814 )
817def test_check_native_status_ota_block_with_newer_bundle(db):
818 _add_ota_package(
819 platform=OTAPlatform.ios,
820 fingerprint="ios-fingerprint",
821 version="v1.3.2.newer",
822 created_at=datetime.now(UTC) - timedelta(days=1),
823 )
824 with bugs_session() as bugs:
825 res = bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
827 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA
828 assert res.update_info.required is True
829 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_AGE
832def test_check_native_status_ota_block_without_target_raises(db):
833 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
834 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
837def test_check_native_status_ota_block_only_older_target_raises(db):
838 _add_ota_package(
839 platform=OTAPlatform.ios,
840 fingerprint="ios-fingerprint",
841 version="v1.3.1.older",
842 created_at=datetime.now(UTC) - timedelta(days=50),
843 )
844 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
845 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
848def test_check_native_status_banned_ota_block_with_successor(db):
849 _add_ota_package(
850 platform=OTAPlatform.ios,
851 fingerprint="ios-fingerprint",
852 version="v1.bad",
853 created_at=datetime.now(UTC) - timedelta(days=5),
854 banned=True,
855 )
856 _add_ota_package(
857 platform=OTAPlatform.ios,
858 fingerprint="ios-fingerprint",
859 version="v1.good",
860 created_at=datetime.now(UTC) - timedelta(days=1),
861 )
862 with bugs_session() as bugs:
863 res = bugs.CheckNativeStatus(
864 _ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad")
865 )
867 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA
868 assert res.update_info.required is True
869 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_BANNED
872def test_check_native_status_banned_ota_block_no_successor_raises(db):
873 _add_ota_package(
874 platform=OTAPlatform.ios,
875 fingerprint="ios-fingerprint",
876 version="v1.bad",
877 created_at=datetime.now(UTC) - timedelta(days=5),
878 banned=True,
879 )
880 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
881 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad"))
884def test_log_experiment_exposure(db):
885 user, token = generate_user()
887 with bugs_session(token) as bugs:
888 bugs.LogExperimentExposure(
889 bugs_pb2.LogExperimentExposureReq(
890 experiment_key="my_experiment",
891 experiment_name="My Experiment",
892 variation_id=1,
893 variation_key="treatment",
894 variation_name="Treatment",
895 hash_attribute="id",
896 hash_value=str(user.id),
897 feature_id="my_feature",
898 in_experiment=True,
899 bucket=0.5,
900 hash_used=True,
901 sticky_bucket_used=False,
902 )
903 )
905 with session_scope() as session:
906 exposure = session.execute(select(ExperimentExposure)).scalar_one()
907 assert exposure.user_id == user.id
908 assert exposure.experiment_key == "my_experiment"
909 assert exposure.variation_id == 1
910 assert exposure.source == ExposureSource.client
911 assert exposure.data == {
912 "experiment_name": "My Experiment",
913 "variation_key": "treatment",
914 "variation_name": "Treatment",
915 "hash_attribute": "id",
916 "hash_value": str(user.id),
917 "bucket": 0.5,
918 "in_experiment": True,
919 "hash_used": True,
920 "sticky_bucket_used": False,
921 "feature_id": "my_feature",
922 }
925def test_log_experiment_exposure_deduped(db):
926 user, token = generate_user()
928 with bugs_session(token) as bugs:
929 for _ in range(3):
930 bugs.LogExperimentExposure(
931 bugs_pb2.LogExperimentExposureReq(
932 experiment_key="my_experiment",
933 variation_id=1,
934 variation_key="treatment",
935 hash_attribute="id",
936 hash_value=str(user.id),
937 )
938 )
940 with session_scope() as session:
941 exposure = session.execute(select(ExperimentExposure)).scalar_one()
942 # unset optional fields are stored as null, not a misleading 0/false
943 assert exposure.data["bucket"] is None
944 assert exposure.data["hash_used"] is None
945 assert exposure.data["sticky_bucket_used"] is None
948def test_log_experiment_exposure_anonymous_ignored(db):
949 with bugs_session() as bugs:
950 bugs.LogExperimentExposure(
951 bugs_pb2.LogExperimentExposureReq(
952 experiment_key="my_experiment",
953 variation_id=1,
954 variation_key="treatment",
955 hash_attribute="id",
956 hash_value="123",
957 )
958 )
960 with session_scope() as session:
961 count = session.execute(select(func.count()).select_from(ExperimentExposure)).scalar_one()
962 assert count == 0