-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_tracker.py
More file actions
196 lines (160 loc) · 7.24 KB
/
Copy pathtest_tracker.py
File metadata and controls
196 lines (160 loc) · 7.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""Story 2.3 — tracker orchestration unit tests (BE-3).
Proves the ``tracking/tracker.py`` seam between Context, dedup, queue, and the
transport boundary, using a fake transport (unit layer — RESPX integration
coverage lives in ``tests/integration/`` per qs-06).
* Flush serializes batched per-visitor events through the Story 2.2
``tracking/payloads.py`` builder (NO second serializer).
* Flush delivers through the ``Transport`` port and clears the queue on success.
* Empty-queue flush is a safe no-op (no transport call, no error).
* Batch-size release funnels through the same single release path as explicit
flush.
"""
from typing import Any, Dict, List
from convert_sdk import InMemoryDataStore
from convert_sdk.adapters.events.in_process import InProcessEventBus
from convert_sdk.config import SDKConfig
from convert_sdk.config_loader import load_snapshot
from convert_sdk.domain.results import ConversionStatus
from convert_sdk.events import LifecycleEvent, QueueReleasedPayload
from convert_sdk.tracking.tracker import Tracker
CONFIG = {
"account_id": "100123",
"project": {"id": "200456", "key": "proj-key"},
"experiences": [],
"features": [],
"goals": [
{"id": "g1", "key": "purchase_completed"},
{"id": "g2", "key": "signup"},
],
"audiences": [],
"segments": [],
}
class FakeTransport:
"""Captures tracking POSTs without touching the network."""
def __init__(self) -> None:
self.calls: List[Dict[str, Any]] = []
self.fail = False
def fetch_config(self, config): # pragma: no cover - unused here
return {}
def send_tracking(self, payload: Dict[str, Any], *, sdk_key: str) -> int:
if self.fail:
from convert_sdk.errors import TrackingDeliveryError
raise TrackingDeliveryError("boom")
self.calls.append({"payload": payload, "sdk_key": sdk_key})
return 200
def close(self) -> None: # pragma: no cover - unused here
pass
def _tracker(transport=None, batch_size=10, data_store=None):
# Story 3.1: Core resolves and injects the per-Core DataStore; in this unit
# fixture we inject one directly (the tracker no longer constructs the
# concrete store itself — that is the composition root's job).
snap = load_snapshot(CONFIG)
cfg = SDKConfig(sdk_key="my-sdk-key", batch_size=batch_size)
return Tracker(
snapshot=snap,
config=cfg,
transport=transport,
data_store=data_store if data_store is not None else InMemoryDataStore(),
)
def test_flush_empty_queue_is_noop():
transport = FakeTransport()
tracker = _tracker(transport)
tracker.flush()
assert transport.calls == []
def test_flush_serializes_via_payloads_and_delivers_and_clears():
transport = FakeTransport()
tracker = _tracker(transport)
tracker.track(visitor_id="v1", goal_key="purchase_completed", revenue=12.5)
tracker.flush()
assert len(transport.calls) == 1
payload = transport.calls[0]["payload"]
# Story 2.2 envelope shape (verbose JS-SDK wire names).
assert payload["accountId"] == "100123"
assert payload["projectId"] == "200456"
assert payload["source"] == "js-sdk"
assert payload["visitors"][0]["visitorId"] == "v1"
event = payload["visitors"][0]["events"][0]
assert event["eventType"] == "conversion"
assert event["data"]["goalId"] == "g1"
# revenue mapped to goalData amount entry by the Story 2.2 serializer.
assert {"key": "amount", "value": 12.5} in event["data"]["goalData"]
assert transport.calls[0]["sdk_key"] == "my-sdk-key"
# Queue cleared after a successful flush -> a second flush is a no-op.
tracker.flush()
assert len(transport.calls) == 1
def test_flush_groups_multiple_visitors_in_one_batch():
transport = FakeTransport()
tracker = _tracker(transport)
tracker.track(visitor_id="v1", goal_key="purchase_completed")
tracker.track(visitor_id="v2", goal_key="signup")
tracker.flush()
assert len(transport.calls) == 1
visitors = transport.calls[0]["payload"]["visitors"]
assert {v["visitorId"] for v in visitors} == {"v1", "v2"}
def test_batch_size_release_funnels_through_shared_path():
transport = FakeTransport()
tracker = _tracker(transport, batch_size=2)
tracker.track(visitor_id="v1", goal_key="purchase_completed")
# Second distinct goal reaches batch_size -> auto release via shared path.
tracker.track(visitor_id="v1", goal_key="signup")
assert len(transport.calls) == 1
# Queue cleared by the auto release.
tracker.flush()
assert len(transport.calls) == 1
def test_failed_delivery_does_not_raise_and_drops_events():
"""Story 2.4 (F-010) changed the failure contract from Story 2.3.
On a ``TrackingDeliveryError`` the shared release path now surfaces the
outcome via the lifecycle event + privacy-safe log, DROPS the drained events
(does NOT re-queue — intentional Python divergence from the JS catch branch),
and returns WITHOUT raising so ``flush()`` stays non-raising. A subsequent
flush therefore has nothing left to deliver.
"""
transport = FakeTransport()
transport.fail = True
tracker = _tracker(transport)
tracker.track(visitor_id="v1", goal_key="purchase_completed")
# flush() must not raise on a delivery failure (Critical Warning #3).
tracker.flush()
assert transport.calls == [] # failed delivery captured nothing
# Events were dropped, not re-queued: a later (now-succeeding) flush is a
# no-op because the queue is empty.
transport.fail = False
tracker.flush()
assert len(transport.calls) == 0
def test_track_returns_dedup_outcome():
transport = FakeTransport()
tracker = _tracker(transport)
first = tracker.track(visitor_id="v1", goal_key="purchase_completed")
assert first.status is ConversionStatus.QUEUED
second = tracker.track(visitor_id="v1", goal_key="purchase_completed")
assert second.status is ConversionStatus.DEDUPLICATED
def test_successful_flush_emits_queue_released_with_numeric_status_code():
"""API_QUEUE_RELEASED lifecycle payload carries the real HTTP status code on success.
Regression guard: before the fix the success branch called
``_emit_queue_released()`` WITHOUT ``status_code``, so the payload always
carried ``status_code=None`` even on a clean 200 delivery. The demo and
verify_staging_transaction script both assert
``isinstance(status_code, int) and 200 <= status_code < 300``, so a ``None``
here produces a false "non-2xx" warning.
"""
transport = FakeTransport()
bus = InProcessEventBus()
snap = load_snapshot(CONFIG)
cfg = SDKConfig(sdk_key="my-sdk-key")
tracker = Tracker(
snapshot=snap,
config=cfg,
transport=transport,
data_store=InMemoryDataStore(),
event_bus=bus,
)
released: List[Any] = []
bus.on(LifecycleEvent.API_QUEUE_RELEASED, lambda p, error=None: released.append(p))
tracker.track(visitor_id="v1", goal_key="purchase_completed")
tracker.flush()
assert len(released) == 1
payload = released[0]
assert isinstance(payload, QueueReleasedPayload)
# The key assertion: status_code must be the numeric 200 returned by send_tracking,
# never None on a successful delivery.
assert payload.status_code == 200