import json import uuid from decimal import Decimal from typing import override from django.core.cache import cache from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone from apps.events.tests.helpers import make_event_type, make_exposure_type from apps.experiments.services import ( experiment_approve, experiment_start, experiment_submit_for_review, ) from apps.experiments.tests.helpers import add_two_variants, make_experiment from apps.metrics.services import ( experiment_metric_add, metric_definition_create, ) from apps.reviews.services import review_settings_update from apps.reviews.tests.helpers import make_approver, make_experimenter from apps.users.tests.helpers import auth_header class APIContractFlowTest(TestCase): @override def setUp(self) -> None: cache.clear() self.client = Client() review_settings_update( default_min_approvals=1, allow_any_approver=True, ) owner = make_experimenter("_api") self.auth = auth_header(owner) approver = make_approver("_api") self.experiment = make_experiment( owner=owner, suffix="_api", traffic_allocation=Decimal("100.00"), ) add_two_variants(self.experiment) self.metric = metric_definition_create( key="ctr_api", name="CTR", metric_type="ratio", direction="higher_is_better", calculation_rule={ "numerator_event": "api_click", "denominator_event": "api_exposure", }, ) experiment_metric_add( experiment=self.experiment, metric=self.metric, is_primary=True, ) make_exposure_type(name="api_exposure") make_event_type( name="api_click", display_name="Click", requires_exposure=True, ) self.experiment = experiment_submit_for_review( experiment=self.experiment, user=owner ) self.experiment = experiment_approve( experiment=self.experiment, approver=approver ) self.experiment = experiment_start( experiment=self.experiment, user=owner ) def test_decide_to_events_to_report_via_http(self) -> None: decide_resp = self.client.post( reverse("api-1:decide"), data=json.dumps( { "subject_id": "api_user_1", "flags": [self.experiment.flag.key], "subject_attributes": {}, } ), content_type="application/json", ) self.assertEqual(decide_resp.status_code, 200) decide_data = decide_resp.json() self.assertEqual(len(decide_data["decisions"]), 1) decision = decide_data["decisions"][0] self.assertEqual(decision["reason"], "experiment_assigned") self.assertIsNotNone(decision["decision_id"]) self.assertIsNotNone(decision["variant_id"]) now = timezone.now().isoformat() events_resp = self.client.post( reverse("api-1:ingest_events"), data=json.dumps( { "events": [ { "event_id": "api_exp_1", "event_type": "api_exposure", "decision_id": decision["decision_id"], "subject_id": "api_user_1", "timestamp": now, "properties": {}, }, { "event_id": "api_click_1", "event_type": "api_click", "decision_id": decision["decision_id"], "subject_id": "api_user_1", "timestamp": now, "properties": {}, }, ] } ), content_type="application/json", ) self.assertEqual(events_resp.status_code, 200) events_data = events_resp.json() self.assertEqual(events_data["accepted"], 2) self.assertEqual(events_data["rejected"], 0) self.assertEqual(events_data["duplicates"], 0) report_resp = self.client.get( reverse( "api-1:get_experiment_report", args=[self.experiment.pk], ), HTTP_AUTHORIZATION=self.auth, ) self.assertEqual(report_resp.status_code, 200) report = report_resp.json() self.assertEqual(report["experiment_id"], str(self.experiment.pk)) total_exposures = sum(v["exposures"] for v in report["variants"]) self.assertEqual(total_exposures, 1) self.assertEqual(len(report["variants"]), 2) for variant in report["variants"]: if variant["exposures"] > 0: self.assertEqual(len(variant["metrics"]), 1) self.assertEqual( variant["metrics"][0]["metric_key"], "ctr_api" ) def test_decide_returns_all_requested_flags(self) -> None: resp = self.client.post( reverse("api-1:decide"), data=json.dumps( { "subject_id": "api_user_2", "flags": [ self.experiment.flag.key, "nonexistent_flag", ], } ), content_type="application/json", ) self.assertEqual(resp.status_code, 200) data = resp.json() self.assertEqual(len(data["decisions"]), 2) reasons = {d["flag"]: d["reason"] for d in data["decisions"]} self.assertEqual( reasons[self.experiment.flag.key], "experiment_assigned" ) self.assertEqual(reasons["nonexistent_flag"], "flag_not_found") def test_events_dedup_via_http(self) -> None: cache.clear() decide_resp = self.client.post( reverse("api-1:decide"), data=json.dumps( { "subject_id": "api_user_3", "flags": [self.experiment.flag.key], } ), content_type="application/json", ) decision = decide_resp.json()["decisions"][0] now = timezone.now().isoformat() event_payload = json.dumps( { "events": [ { "event_id": "api_ddup_1", "event_type": "api_exposure", "decision_id": decision["decision_id"], "subject_id": "api_user_3", "timestamp": now, "properties": {}, } ] } ) r1 = self.client.post( reverse("api-1:ingest_events"), data=event_payload, content_type="application/json", ) self.assertEqual(r1.json()["accepted"], 1) r2 = self.client.post( reverse("api-1:ingest_events"), data=event_payload, content_type="application/json", ) self.assertEqual(r2.json()["duplicates"], 1) self.assertEqual(r2.json()["accepted"], 0) def test_report_404_for_unknown_experiment(self) -> None: resp = self.client.get( reverse( "api-1:get_experiment_report", args=[uuid.uuid4()], ), HTTP_AUTHORIZATION=self.auth, ) self.assertEqual(resp.status_code, 404)