feat(events): added events business logic
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
from apps.events.models import EventType
|
||||
from apps.events.services import event_type_create
|
||||
|
||||
|
||||
def make_event_type(
|
||||
name: str = "test_event",
|
||||
display_name: str = "Test Event",
|
||||
is_exposure: bool = False, # noqa: FBT001, FBT002
|
||||
requires_exposure: bool = False, # noqa: FBT001, FBT002
|
||||
required_fields: list[str] | None = None,
|
||||
**kwargs,
|
||||
) -> EventType:
|
||||
return event_type_create(
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
is_exposure=is_exposure,
|
||||
requires_exposure=requires_exposure,
|
||||
required_fields=required_fields or [],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def make_exposure_type(name: str = "exposure") -> EventType:
|
||||
return make_event_type(
|
||||
name=name,
|
||||
display_name="Exposure",
|
||||
is_exposure=True,
|
||||
requires_exposure=False,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.events.services import event_type_update
|
||||
from apps.events.tests.helpers import make_event_type
|
||||
|
||||
|
||||
class EventTypeModelTest(TestCase):
|
||||
def test_create_event_type(self) -> None:
|
||||
et = make_event_type(name="page_view", display_name="Page View")
|
||||
self.assertEqual(et.name, "page_view")
|
||||
self.assertEqual(et.display_name, "Page View")
|
||||
self.assertFalse(et.requires_exposure)
|
||||
self.assertTrue(et.is_active)
|
||||
|
||||
def test_create_event_type_with_required_fields(self) -> None:
|
||||
et = make_event_type(
|
||||
name="click",
|
||||
display_name="Click",
|
||||
required_fields=["screen", "element"],
|
||||
)
|
||||
self.assertEqual(et.required_fields, ["screen", "element"])
|
||||
|
||||
def test_unique_name_constraint(self) -> None:
|
||||
make_event_type(name="unique_evt")
|
||||
with self.assertRaises((ValidationError, Exception)):
|
||||
make_event_type(name="unique_evt")
|
||||
|
||||
def test_invalid_name_rejected(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
make_event_type(name="Invalid Name!")
|
||||
|
||||
def test_update_event_type(self) -> None:
|
||||
et = make_event_type(name="updatable")
|
||||
updated = event_type_update(
|
||||
event_type=et,
|
||||
display_name="Updated Name",
|
||||
requires_exposure=True,
|
||||
)
|
||||
self.assertEqual(updated.display_name, "Updated Name")
|
||||
self.assertTrue(updated.requires_exposure)
|
||||
|
||||
def test_update_disallowed_field(self) -> None:
|
||||
et = make_event_type(name="no_rename")
|
||||
with self.assertRaises(ValidationError):
|
||||
event_type_update(event_type=et, name="renamed")
|
||||
|
||||
def test_archive_event_type(self) -> None:
|
||||
et = make_event_type(name="archivable")
|
||||
updated = event_type_update(event_type=et, is_active=False)
|
||||
self.assertFalse(updated.is_active)
|
||||
@@ -0,0 +1,368 @@
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.events.models import Event, Exposure, PendingEvent
|
||||
from apps.events.services import (
|
||||
decision_create,
|
||||
process_events_batch,
|
||||
)
|
||||
from apps.events.tests.helpers import make_event_type, make_exposure_type
|
||||
|
||||
|
||||
class EventValidationTest(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.exposure_type = make_exposure_type()
|
||||
self.click_type = make_event_type(
|
||||
name="button_clicked",
|
||||
display_name="Button Clicked",
|
||||
requires_exposure=True,
|
||||
required_fields=["screen"],
|
||||
)
|
||||
|
||||
def test_reject_unknown_event_type(self) -> None:
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"event_type": "nonexistent_type",
|
||||
"decision_id": "d1",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.rejected, 1)
|
||||
self.assertEqual(result.accepted, 0)
|
||||
|
||||
def test_reject_missing_required_field(self) -> None:
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "e2",
|
||||
"event_type": "button_clicked",
|
||||
"decision_id": "d1",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.rejected, 1)
|
||||
self.assertIn("screen", result.errors[0]["error"])
|
||||
|
||||
def test_reject_missing_decision_id(self) -> None:
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "e3",
|
||||
"event_type": "button_clicked",
|
||||
"decision_id": "",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {"screen": "checkout"},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.rejected, 1)
|
||||
|
||||
def test_reject_invalid_event_type_field(self) -> None:
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "e4",
|
||||
"event_type": 12345,
|
||||
"decision_id": "d1",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.rejected, 1)
|
||||
|
||||
def test_reject_archived_event_type(self) -> None:
|
||||
archived = make_event_type(
|
||||
name="archived_evt",
|
||||
display_name="Archived",
|
||||
)
|
||||
archived.is_active = False
|
||||
archived.save()
|
||||
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "e5",
|
||||
"event_type": "archived_evt",
|
||||
"decision_id": "d1",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.rejected, 1)
|
||||
|
||||
|
||||
class EventDeduplicationTest(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.exposure_type = make_exposure_type()
|
||||
|
||||
def test_duplicate_event_counted_once(self) -> None:
|
||||
decision_create(
|
||||
decision_id="dec1",
|
||||
flag_key="flag",
|
||||
subject_id="u1",
|
||||
value="v",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
event_data = {
|
||||
"event_id": "dup_evt_1",
|
||||
"event_type": self.exposure_type.name,
|
||||
"decision_id": "dec1",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
r1 = process_events_batch([event_data])
|
||||
self.assertEqual(r1.accepted, 1)
|
||||
self.assertEqual(r1.duplicates, 0)
|
||||
|
||||
r2 = process_events_batch([event_data])
|
||||
self.assertEqual(r2.accepted, 0)
|
||||
self.assertEqual(r2.duplicates, 1)
|
||||
|
||||
def test_duplicate_in_same_batch(self) -> None:
|
||||
decision_create(
|
||||
decision_id="dec2",
|
||||
flag_key="flag",
|
||||
subject_id="u1",
|
||||
value="v",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
event_data = {
|
||||
"event_id": "batch_dup",
|
||||
"event_type": self.exposure_type.name,
|
||||
"decision_id": "dec2",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
result = process_events_batch([event_data, event_data])
|
||||
self.assertEqual(result.accepted + result.duplicates, 2)
|
||||
self.assertGreaterEqual(result.duplicates, 1)
|
||||
|
||||
|
||||
class ExposureAttributionTest(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.exposure_type = make_exposure_type()
|
||||
self.click_type = make_event_type(
|
||||
name="button_clicked",
|
||||
display_name="Button Clicked",
|
||||
requires_exposure=True,
|
||||
)
|
||||
self.exp_id = str(uuid.uuid4())
|
||||
self.var_id = str(uuid.uuid4())
|
||||
self.decision_id = "attr_dec_1"
|
||||
|
||||
decision_create(
|
||||
decision_id=self.decision_id,
|
||||
flag_key="button_color",
|
||||
subject_id="u42",
|
||||
experiment_id=self.exp_id,
|
||||
variant_id=self.var_id,
|
||||
value="blue",
|
||||
reason="experiment_assigned",
|
||||
)
|
||||
|
||||
def test_exposure_creates_exposure_record(self) -> None:
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "exp_evt_1",
|
||||
"event_type": self.exposure_type.name,
|
||||
"decision_id": self.decision_id,
|
||||
"subject_id": "u42",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.accepted, 1)
|
||||
self.assertTrue(
|
||||
Exposure.objects.filter(decision_id=self.decision_id).exists()
|
||||
)
|
||||
|
||||
def test_conversion_with_exposure_is_attributed(self) -> None:
|
||||
process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "exp_evt_2",
|
||||
"event_type": self.exposure_type.name,
|
||||
"decision_id": self.decision_id,
|
||||
"subject_id": "u42",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "click_evt_1",
|
||||
"event_type": "button_clicked",
|
||||
"decision_id": self.decision_id,
|
||||
"subject_id": "u42",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.accepted, 1)
|
||||
event = Event.objects.get(event_id="click_evt_1")
|
||||
self.assertTrue(event.is_attributed)
|
||||
|
||||
def test_conversion_without_exposure_goes_pending(self) -> None:
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "click_no_exp",
|
||||
"event_type": "button_clicked",
|
||||
"decision_id": self.decision_id,
|
||||
"subject_id": "u42",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.accepted, 1)
|
||||
self.assertFalse(
|
||||
Event.objects.filter(event_id="click_no_exp").exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
PendingEvent.objects.filter(event_id="click_no_exp").exists()
|
||||
)
|
||||
|
||||
def test_late_exposure_promotes_pending_events(self) -> None:
|
||||
process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "early_click",
|
||||
"event_type": "button_clicked",
|
||||
"decision_id": self.decision_id,
|
||||
"subject_id": "u42",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertTrue(
|
||||
PendingEvent.objects.filter(event_id="early_click").exists()
|
||||
)
|
||||
|
||||
process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "late_exposure",
|
||||
"event_type": self.exposure_type.name,
|
||||
"decision_id": self.decision_id,
|
||||
"subject_id": "u42",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertTrue(Event.objects.filter(event_id="early_click").exists())
|
||||
self.assertFalse(
|
||||
PendingEvent.objects.filter(event_id="early_click").exists()
|
||||
)
|
||||
|
||||
def test_event_not_requiring_exposure_always_attributed(self) -> None:
|
||||
make_event_type(
|
||||
name="technical_event",
|
||||
display_name="Technical",
|
||||
requires_exposure=False,
|
||||
)
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "tech_evt_1",
|
||||
"event_type": "technical_event",
|
||||
"decision_id": self.decision_id,
|
||||
"subject_id": "u42",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.accepted, 1)
|
||||
event = Event.objects.get(event_id="tech_evt_1")
|
||||
self.assertTrue(event.is_attributed)
|
||||
|
||||
|
||||
class BatchResponseTest(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.exposure_type = make_exposure_type()
|
||||
self.click_type = make_event_type(
|
||||
name="button_clicked",
|
||||
display_name="Button Clicked",
|
||||
requires_exposure=True,
|
||||
)
|
||||
decision_create(
|
||||
decision_id="batch_dec",
|
||||
flag_key="flag",
|
||||
subject_id="u1",
|
||||
value="v",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
def test_mixed_batch_response(self) -> None:
|
||||
process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "exp_for_batch",
|
||||
"event_type": self.exposure_type.name,
|
||||
"decision_id": "batch_dec",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = process_events_batch(
|
||||
[
|
||||
{
|
||||
"event_id": "valid_1",
|
||||
"event_type": "button_clicked",
|
||||
"decision_id": "batch_dec",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
},
|
||||
{
|
||||
"event_id": "invalid_1",
|
||||
"event_type": "unknown_type",
|
||||
"decision_id": "batch_dec",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
},
|
||||
{
|
||||
"event_id": "exp_for_batch",
|
||||
"event_type": self.exposure_type.name,
|
||||
"decision_id": "batch_dec",
|
||||
"subject_id": "u1",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"properties": {},
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.accepted, 1)
|
||||
self.assertEqual(result.rejected, 1)
|
||||
self.assertEqual(result.duplicates, 1)
|
||||
self.assertEqual(len(result.errors), 1)
|
||||
Reference in New Issue
Block a user