Coverage for app/backend/src/tests/test_bugs.py: 98%
350 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +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 tool", "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):
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`""".strip()
130 assert json == {
131 "title": "subject",
132 "body": expected_body,
133 "labels": ["bug tool", "bug: triage needed"],
134 }
136 class _PostReturn:
137 status_code = 201
139 def json(self):
140 return {"number": 11}
142 return _PostReturn()
144 new_config = config.copy()
145 new_config.BUG_TOOL_ENABLED = True
147 with patch("couchers.servicers.bugs.config", new_config):
148 with patch("couchers.servicers.bugs.requests.post", dud_post): 148 ↛ anywhereline 148 didn't jump anywhere: it always raised an exception.
149 res = bugs.ReportBug(
150 bugs_pb2.ReportBugReq(
151 subject="subject",
152 description="description",
153 results="results",
154 frontend_version="frontend_version",
155 user_agent="user_agent",
156 screen_resolution=bugs_pb2.ScreenResolution(width=390, height=844),
157 page="page",
158 )
159 )
161 assert res.bug_id == "#11"
162 assert res.bug_url == "https://github.com/org/repo/issues/11"
165def test_bugs_fails_on_network_error(db):
166 with bugs_session() as bugs:
168 def dud_post(url, auth, json):
169 class _PostReturn:
170 status_code = 400
172 return _PostReturn()
174 new_config = config.copy()
175 new_config.BUG_TOOL_ENABLED = True
177 with patch("couchers.servicers.bugs.config", new_config):
178 with patch("couchers.servicers.bugs.requests.post", dud_post): 178 ↛ anywhereline 178 didn't jump anywhere: it always raised an exception.
179 with pytest.raises(grpc.RpcError) as e:
180 res = bugs.ReportBug(
181 bugs_pb2.ReportBugReq(
182 subject="subject",
183 description="description",
184 results="results",
185 frontend_version="frontend_version",
186 user_agent="user_agent",
187 page="page",
188 )
189 )
190 assert e.value.code() == grpc.StatusCode.INTERNAL
193def test_version():
194 with bugs_session() as bugs:
195 res = bugs.Version(empty_pb2.Empty())
196 assert res.version == "testing_version"
199def test_status(db):
200 for _ in range(5):
201 generate_user()
203 with bugs_session() as bugs:
204 nonce = random_hex()
205 res = bugs.Status(bugs_pb2.StatusReq(nonce=nonce))
206 assert res.nonce == nonce
207 assert res.version == "testing_version"
208 assert res.coucher_count == 5
211def test_GetDescriptors():
212 with bugs_session() as bugs:
213 res = bugs.GetDescriptors(empty_pb2.Empty())
214 # test we got something roughly binary back
215 assert res.content_type == "application/octet-stream"
216 assert len(res.data) > 2**12
219def _get_events(session, event_type=None):
220 stmt = select(EventLog).order_by(EventLog.id)
221 if event_type: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 stmt = stmt.where(EventLog.event_type == event_type)
223 return session.execute(stmt).scalars().all()
226def test_report_diagnostics_anonymous(db):
227 with bugs_session() as bugs:
228 bugs.ReportDiagnostics(
229 bugs_pb2.ReportDiagnosticsReq(
230 frontend_version="1.2.3",
231 infos=[
232 bugs_pb2.DiagnosticInfo(
233 tag="page.viewed",
234 properties_json='{"path": "/"}',
235 value=1,
236 ),
237 bugs_pb2.DiagnosticInfo(
238 tag="session.started",
239 properties_json='{"referrer": "google.com"}',
240 value=1,
241 ),
242 ],
243 )
244 )
246 with session_scope() as session:
247 events = _get_events(session)
248 assert len(events) == 2
250 e0 = events[0]
251 assert e0.event_type == "page.viewed"
252 assert e0.properties == {"path": "/"}
253 assert e0.user_id is None
254 assert e0.source == EventSource.frontend
255 assert e0.value == 1
256 assert e0.version == "1.2.3"
258 e1 = events[1]
259 assert e1.event_type == "session.started"
260 assert e1.properties == {"referrer": "google.com"}
261 assert e1.source == EventSource.frontend
264def test_report_diagnostics_authenticated(db):
265 user, token = generate_user()
267 with bugs_session(token) as bugs:
268 bugs.ReportDiagnostics(
269 bugs_pb2.ReportDiagnosticsReq(
270 frontend_version="1.2.3",
271 infos=[
272 bugs_pb2.DiagnosticInfo(
273 tag="page.viewed",
274 properties_json='{"path": "/search"}',
275 value=1,
276 ),
277 ],
278 )
279 )
281 with session_scope() as session:
282 events = _get_events(session)
283 assert len(events) == 1
284 assert events[0].user_id == user.id
285 assert events[0].source == EventSource.frontend
288def test_report_diagnostics_with_value(db):
289 with bugs_session() as bugs:
290 bugs.ReportDiagnostics(
291 bugs_pb2.ReportDiagnosticsReq(
292 frontend_version="1.2.3",
293 infos=[
294 bugs_pb2.DiagnosticInfo(
295 tag="search.result_hovered",
296 properties_json='{"user_id": 5}',
297 value=1500.5,
298 ),
299 ],
300 )
301 )
303 with session_scope() as session:
304 events = _get_events(session)
305 assert len(events) == 1
306 assert events[0].value == pytest.approx(1500.5)
309def test_report_diagnostics_with_occurred(db):
310 ts = timestamp_pb2.Timestamp()
311 ts.FromDatetime(datetime(2026, 1, 15, 10, 30, 0, tzinfo=UTC))
313 with bugs_session() as bugs:
314 bugs.ReportDiagnostics(
315 bugs_pb2.ReportDiagnosticsReq(
316 frontend_version="1.2.3",
317 infos=[
318 bugs_pb2.DiagnosticInfo(
319 tag="page.viewed",
320 properties_json="{}",
321 value=1,
322 occurred=ts,
323 ),
324 ],
325 )
326 )
328 with session_scope() as session:
329 events = _get_events(session)
330 assert len(events) == 1
331 assert events[0].occurred.year == 2026
332 assert events[0].occurred.month == 1
333 assert events[0].occurred.day == 15
334 assert events[0].occurred.hour == 10
335 assert events[0].occurred.minute == 30
338def test_report_diagnostics_invalid_json(db):
339 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
340 bugs.ReportDiagnostics(
341 bugs_pb2.ReportDiagnosticsReq(
342 frontend_version="1.2.3",
343 infos=[
344 bugs_pb2.DiagnosticInfo(
345 tag="page.viewed",
346 properties_json="not valid json{{{",
347 value=1,
348 ),
349 ],
350 )
351 )
352 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
355def test_report_diagnostics_empty_batch(db):
356 with bugs_session() as bugs:
357 bugs.ReportDiagnostics(
358 bugs_pb2.ReportDiagnosticsReq(
359 frontend_version="1.2.3",
360 infos=[],
361 )
362 )
364 with session_scope() as session:
365 events = _get_events(session)
366 assert len(events) == 0
369def test_report_diagnostics_too_many(db):
370 infos = [bugs_pb2.DiagnosticInfo(tag=f"event.{i}", properties_json="{}", value=1) for i in range(101)]
372 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e:
373 bugs.ReportDiagnostics(
374 bugs_pb2.ReportDiagnosticsReq(
375 frontend_version="1.2.3",
376 infos=infos,
377 )
378 )
379 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
382def test_report_diagnostics_frontend_version(db):
383 with bugs_session() as bugs:
384 bugs.ReportDiagnostics(
385 bugs_pb2.ReportDiagnosticsReq(
386 frontend_version="abc-def-123",
387 infos=[
388 bugs_pb2.DiagnosticInfo(
389 tag="page.viewed",
390 properties_json="{}",
391 value=1,
392 ),
393 ],
394 )
395 )
397 with session_scope() as session:
398 events = _get_events(session)
399 assert len(events) == 1
400 assert events[0].version == "abc-def-123"
403def test_check_native_status_anonymous(db):
404 with bugs_session() as bugs:
405 res = bugs.CheckNativeStatus(
406 bugs_pb2.CheckNativeStatusReq(
407 eas_client_id=str(EAS_CLIENT_ID), app_version="1.1.20", platform="ios", user_state="logged_out"
408 )
409 )
411 # No build timestamps reported -> no clock runs -> no update asked for.
412 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE
413 assert res.update_info.required is False
416def test_check_native_status_authenticated(db):
417 _, token = generate_user()
419 with bugs_session(token) as bugs:
420 res = bugs.CheckNativeStatus(
421 bugs_pb2.CheckNativeStatusReq(
422 eas_client_id=str(EAS_CLIENT_ID), platform="android", user_state="authenticated"
423 )
424 )
426 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE
427 assert res.update_info.required is False
430def test_check_native_status_authenticated_records_mapping(db):
431 user, token = generate_user()
433 with bugs_session(token) as bugs:
434 bugs.CheckNativeStatus(
435 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="authenticated")
436 )
438 with session_scope() as session:
439 row = session.execute(
440 select(NativeClientUser).where(NativeClientUser.eas_client_id == EAS_CLIENT_ID)
441 ).scalar_one()
442 assert row.user_id == user.id
445def test_check_native_status_anonymous_does_not_record_mapping(db):
446 with bugs_session() as bugs:
447 bugs.CheckNativeStatus(
448 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="logged_out")
449 )
451 with session_scope() as session:
452 count = session.execute(select(func.count()).select_from(NativeClientUser)).scalar_one()
453 assert count == 0
456def test_check_native_status_append_only_log_of_sightings(db):
457 # A shared install — same eas-client-id, two users — produces two rows; newest is user_b.
458 user_a, token_a = generate_user()
459 user_b, token_b = generate_user()
461 with bugs_session(token_a) as bugs:
462 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios"))
463 with bugs_session(token_b) as bugs:
464 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios"))
466 with session_scope() as session:
467 rows = (
468 session.execute(
469 select(NativeClientUser)
470 .where(NativeClientUser.eas_client_id == EAS_CLIENT_ID)
471 .order_by(NativeClientUser.id)
472 )
473 .scalars()
474 .all()
475 )
476 assert [r.user_id for r in rows] == [user_a.id, user_b.id]
479def test_check_native_status_blocks_expired_binary(db):
480 # A native binary older than the (default 91-day) store window -> required store update, blocking.
481 embedded_created_at = timestamp_pb2.Timestamp()
482 embedded_created_at.FromDatetime(datetime.now(UTC) - timedelta(days=120))
483 with bugs_session() as bugs:
484 res = bugs.CheckNativeStatus(
485 bugs_pb2.CheckNativeStatusReq(
486 eas_client_id=str(EAS_CLIENT_ID), platform="ios", embedded_created_at=embedded_created_at
487 )
488 )
490 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_STORE
491 assert res.update_info.required is True
492 assert res.update_info.act_by.ToDatetime(tzinfo=UTC) <= datetime.now(UTC)
495def _multipart_part_json(body, name):
496 """Extract and parse the JSON body of a named part from a multipart/mixed body."""
497 marker = f'name="{name}"'
498 start = body.index("\r\n\r\n", body.index(marker)) + 4
499 end = body.index("\r\n--", start)
500 return json.loads(body[start:end])
503_OTA_CDN_ROOT = "https://cdn.testing.invalid/native/ota"
504_CDN_CONTENT_TYPE = "multipart/mixed; boundary=COUCHERS_OTA_BOUNDARY"
507class _FakeCDNResponse:
508 # Echoes the requested URL back as the body so tests can assert which version was fetched and that
509 # the bytes are served verbatim — standing in for the pre-signed manifest the CDN holds.
510 def __init__(self, url):
511 self.headers = {"content-type": _CDN_CONTENT_TYPE}
512 self.content = url.encode()
514 def raise_for_status(self):
515 pass
518def _patch_cdn():
519 return patch("couchers.servicers.bugs.requests.get", side_effect=lambda url, timeout=None: _FakeCDNResponse(url))
522def _add_ota_package(*, platform, fingerprint, version, created_at, banned=False):
523 with session_scope() as session:
524 creator, _ = generate_user()
525 package = OTAPackage(
526 creator_user_id=creator.id,
527 platform=platform,
528 fingerprint=fingerprint,
529 version=version,
530 manifest_created_at=created_at,
531 manifest_id=f"id-{version}",
532 banned_at=created_at if banned else None,
533 banned_by_user_id=creator.id if banned else None,
534 banned_reason="test ban" if banned else None,
535 )
536 session.add(package)
537 session.flush()
540def test_native_update_manifest_serves_matching_package(db, feature_flags):
541 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
542 _fetch_signed_manifest.cache_clear()
543 _add_ota_package(
544 platform=OTAPlatform.ios,
545 fingerprint="ios-fingerprint",
546 version="v1.3.1.aaaa",
547 created_at=datetime(2026, 5, 31, tzinfo=UTC),
548 )
549 with _patch_cdn():
550 with real_bugs_session() as (bugs, metadata_interceptor):
551 res = bugs.GetNativeUpdateManifest(
552 httpbody_pb2.HttpBody(),
553 metadata=(
554 ("eas-client-id", str(EAS_CLIENT_ID)),
555 ("expo-platform", "ios"),
556 ("expo-runtime-version", "ios-fingerprint"),
557 ),
558 )
560 # the signed bytes are fetched from the CDN under the package's version and served verbatim
561 assert res.content_type == _CDN_CONTENT_TYPE
562 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.aaaa/ios/manifest"
563 # the client requires these response headers or it rejects the manifest
564 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
565 assert metadata_interceptor.latest_headers["expo-sfv-version"] == "0"
568def test_native_update_manifest_resolves_per_platform(db, feature_flags):
569 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
570 _fetch_signed_manifest.cache_clear()
571 _add_ota_package(
572 platform=OTAPlatform.ios,
573 fingerprint="shared-fingerprint",
574 version="v1.3.1.ios",
575 created_at=datetime(2026, 5, 31, tzinfo=UTC),
576 )
577 _add_ota_package(
578 platform=OTAPlatform.android,
579 fingerprint="shared-fingerprint",
580 version="v1.3.1.android",
581 created_at=datetime(2026, 5, 31, tzinfo=UTC),
582 )
583 with _patch_cdn():
584 with real_bugs_session() as (bugs, _metadata_interceptor):
585 res = bugs.GetNativeUpdateManifest(
586 httpbody_pb2.HttpBody(),
587 metadata=(
588 ("eas-client-id", str(EAS_CLIENT_ID)),
589 ("expo-platform", "android"),
590 ("expo-runtime-version", "shared-fingerprint"),
591 ),
592 )
594 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.android/android/manifest"
597def test_native_update_manifest_serves_newest_by_created_at(db, feature_flags):
598 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
599 _fetch_signed_manifest.cache_clear()
600 # the newer createdAt wins regardless of insertion order
601 _add_ota_package(
602 platform=OTAPlatform.ios,
603 fingerprint="ios-fingerprint",
604 version="v1.3.2.newer",
605 created_at=datetime(2026, 5, 31, tzinfo=UTC),
606 )
607 _add_ota_package(
608 platform=OTAPlatform.ios,
609 fingerprint="ios-fingerprint",
610 version="v1.3.1.older",
611 created_at=datetime(2026, 5, 30, tzinfo=UTC),
612 )
613 with _patch_cdn():
614 with real_bugs_session() as (bugs, _metadata_interceptor):
615 res = bugs.GetNativeUpdateManifest(
616 httpbody_pb2.HttpBody(),
617 metadata=(
618 ("eas-client-id", str(EAS_CLIENT_ID)),
619 ("expo-platform", "ios"),
620 ("expo-runtime-version", "ios-fingerprint"),
621 ),
622 )
624 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.2.newer/ios/manifest"
627def test_native_update_manifest_banned_package_excluded(db, feature_flags):
628 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT)
629 _fetch_signed_manifest.cache_clear()
630 _add_ota_package(
631 platform=OTAPlatform.ios,
632 fingerprint="ios-fingerprint",
633 version="v1.3.1.good",
634 created_at=datetime(2026, 5, 30, tzinfo=UTC),
635 )
636 _add_ota_package(
637 platform=OTAPlatform.ios,
638 fingerprint="ios-fingerprint",
639 version="v1.3.2.bad",
640 created_at=datetime(2026, 5, 31, tzinfo=UTC),
641 banned=True,
642 )
643 with _patch_cdn():
644 with real_bugs_session() as (bugs, _metadata_interceptor):
645 res = bugs.GetNativeUpdateManifest(
646 httpbody_pb2.HttpBody(),
647 metadata=(
648 ("eas-client-id", str(EAS_CLIENT_ID)),
649 ("expo-platform", "ios"),
650 ("expo-runtime-version", "ios-fingerprint"),
651 ),
652 )
654 # the newest is banned, so new check-ins get the previous one (a re-stamp would supersede it)
655 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.good/ios/manifest"
658def test_native_update_manifest_runtime_mismatch_returns_directive(db):
659 _add_ota_package(
660 platform=OTAPlatform.ios,
661 fingerprint="ios-fingerprint",
662 version="v1.3.1.aaaa",
663 created_at=datetime(2026, 5, 31, tzinfo=UTC),
664 )
665 with _patch_cdn() as cdn_get:
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", "ios"),
672 ("expo-runtime-version", "some-other-fingerprint"),
673 ),
674 )
676 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
677 # a mismatch must not even fetch — the manifest would be rejected on this build
678 cdn_get.assert_not_called()
681def test_native_update_manifest_only_banned_package_returns_directive(db):
682 _add_ota_package(
683 platform=OTAPlatform.ios,
684 fingerprint="ios-fingerprint",
685 version="v1.3.1.aaaa",
686 created_at=datetime(2026, 5, 31, tzinfo=UTC),
687 banned=True,
688 )
689 with real_bugs_session() as (bugs, _metadata_interceptor):
690 res = bugs.GetNativeUpdateManifest(
691 httpbody_pb2.HttpBody(),
692 metadata=(
693 ("eas-client-id", str(EAS_CLIENT_ID)),
694 ("expo-platform", "ios"),
695 ("expo-runtime-version", "ios-fingerprint"),
696 ),
697 )
699 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
702def test_native_update_manifest_without_runtime_version_returns_directive(db):
703 _add_ota_package(
704 platform=OTAPlatform.ios,
705 fingerprint="ios-fingerprint",
706 version="v1.3.1.aaaa",
707 created_at=datetime(2026, 5, 31, tzinfo=UTC),
708 )
709 with real_bugs_session() as (bugs, metadata_interceptor):
710 res = bugs.GetNativeUpdateManifest(
711 httpbody_pb2.HttpBody(),
712 metadata=(
713 ("eas-client-id", str(EAS_CLIENT_ID)),
714 ("expo-platform", "ios"),
715 ),
716 )
718 body = res.data.decode()
719 assert _multipart_part_json(body, "directive") == {"type": "noUpdateAvailable"}
720 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
723def test_native_update_manifest_no_package_returns_directive(db):
724 with real_bugs_session() as (bugs, _metadata_interceptor):
725 res = bugs.GetNativeUpdateManifest(
726 httpbody_pb2.HttpBody(),
727 metadata=(
728 ("eas-client-id", str(EAS_CLIENT_ID)),
729 ("expo-platform", "ios"),
730 ("expo-runtime-version", "ios-fingerprint"),
731 ),
732 )
734 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
737def _ota_check_req(*, created_at, update_id=""):
738 ts = timestamp_pb2.Timestamp()
739 ts.FromDatetime(created_at)
740 return bugs_pb2.CheckNativeStatusReq(
741 eas_client_id=str(EAS_CLIENT_ID),
742 platform="ios",
743 runtime_version="ios-fingerprint",
744 launch_source="ota",
745 update_id=update_id,
746 created_at=ts,
747 )
750def test_check_native_status_ota_block_with_newer_bundle(db):
751 _add_ota_package(
752 platform=OTAPlatform.ios,
753 fingerprint="ios-fingerprint",
754 version="v1.3.2.newer",
755 created_at=datetime.now(UTC) - timedelta(days=1),
756 )
757 with bugs_session() as bugs:
758 res = bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
760 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA
761 assert res.update_info.required is True
762 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_AGE
765def test_check_native_status_ota_block_without_target_raises(db):
766 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
767 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
770def test_check_native_status_ota_block_only_older_target_raises(db):
771 _add_ota_package(
772 platform=OTAPlatform.ios,
773 fingerprint="ios-fingerprint",
774 version="v1.3.1.older",
775 created_at=datetime.now(UTC) - timedelta(days=50),
776 )
777 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
778 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40)))
781def test_check_native_status_banned_ota_block_with_successor(db):
782 _add_ota_package(
783 platform=OTAPlatform.ios,
784 fingerprint="ios-fingerprint",
785 version="v1.bad",
786 created_at=datetime.now(UTC) - timedelta(days=5),
787 banned=True,
788 )
789 _add_ota_package(
790 platform=OTAPlatform.ios,
791 fingerprint="ios-fingerprint",
792 version="v1.good",
793 created_at=datetime.now(UTC) - timedelta(days=1),
794 )
795 with bugs_session() as bugs:
796 res = bugs.CheckNativeStatus(
797 _ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad")
798 )
800 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA
801 assert res.update_info.required is True
802 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_BANNED
805def test_check_native_status_banned_ota_block_no_successor_raises(db):
806 _add_ota_package(
807 platform=OTAPlatform.ios,
808 fingerprint="ios-fingerprint",
809 version="v1.bad",
810 created_at=datetime.now(UTC) - timedelta(days=5),
811 banned=True,
812 )
813 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"):
814 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad"))
817def test_log_experiment_exposure(db):
818 user, token = generate_user()
820 with bugs_session(token) as bugs:
821 bugs.LogExperimentExposure(
822 bugs_pb2.LogExperimentExposureReq(
823 experiment_key="my_experiment",
824 experiment_name="My Experiment",
825 variation_id=1,
826 variation_key="treatment",
827 variation_name="Treatment",
828 hash_attribute="id",
829 hash_value=str(user.id),
830 feature_id="my_feature",
831 in_experiment=True,
832 bucket=0.5,
833 hash_used=True,
834 sticky_bucket_used=False,
835 )
836 )
838 with session_scope() as session:
839 exposure = session.execute(select(ExperimentExposure)).scalar_one()
840 assert exposure.user_id == user.id
841 assert exposure.experiment_key == "my_experiment"
842 assert exposure.variation_id == 1
843 assert exposure.source == ExposureSource.client
844 assert exposure.data == {
845 "experiment_name": "My Experiment",
846 "variation_key": "treatment",
847 "variation_name": "Treatment",
848 "hash_attribute": "id",
849 "hash_value": str(user.id),
850 "bucket": 0.5,
851 "in_experiment": True,
852 "hash_used": True,
853 "sticky_bucket_used": False,
854 "feature_id": "my_feature",
855 }
858def test_log_experiment_exposure_deduped(db):
859 user, token = generate_user()
861 with bugs_session(token) as bugs:
862 for _ in range(3):
863 bugs.LogExperimentExposure(
864 bugs_pb2.LogExperimentExposureReq(
865 experiment_key="my_experiment",
866 variation_id=1,
867 variation_key="treatment",
868 hash_attribute="id",
869 hash_value=str(user.id),
870 )
871 )
873 with session_scope() as session:
874 exposure = session.execute(select(ExperimentExposure)).scalar_one()
875 # unset optional fields are stored as null, not a misleading 0/false
876 assert exposure.data["bucket"] is None
877 assert exposure.data["hash_used"] is None
878 assert exposure.data["sticky_bucket_used"] is None
881def test_log_experiment_exposure_anonymous_ignored(db):
882 with bugs_session() as bugs:
883 bugs.LogExperimentExposure(
884 bugs_pb2.LogExperimentExposureReq(
885 experiment_key="my_experiment",
886 variation_id=1,
887 variation_key="treatment",
888 hash_attribute="id",
889 hash_value="123",
890 )
891 )
893 with session_scope() as session:
894 count = session.execute(select(func.count()).select_from(ExperimentExposure)).scalar_one()
895 assert count == 0