feat(events): added events business logic

This commit is contained in:
ITQ
2026-02-22 21:29:08 +03:00
parent 385aae930f
commit 7ae94a7380
13 changed files with 1319 additions and 0 deletions
+29
View File
@@ -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)