mbuzz 0.1.1__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {mbuzz-0.1.1 → mbuzz-0.7.0}/PKG-INFO +1 -1
  2. {mbuzz-0.1.1 → mbuzz-0.7.0}/pyproject.toml +1 -1
  3. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/__init__.py +2 -8
  4. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/conversion.py +26 -5
  5. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/track.py +35 -19
  6. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/context.py +3 -1
  7. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/cookies.py +1 -3
  8. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/middleware/flask.py +11 -55
  9. {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_client.py +148 -57
  10. {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_context.py +50 -15
  11. {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_middleware.py +37 -87
  12. mbuzz-0.1.1/src/mbuzz/client/session.py +0 -38
  13. mbuzz-0.1.1/src/mbuzz/utils/session_id.py +0 -39
  14. mbuzz-0.1.1/tests/test_session_id.py +0 -146
  15. {mbuzz-0.1.1 → mbuzz-0.7.0}/.gitignore +0 -0
  16. {mbuzz-0.1.1 → mbuzz-0.7.0}/README.md +0 -0
  17. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/api.py +0 -0
  18. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/__init__.py +0 -0
  19. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/identify.py +0 -0
  20. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/config.py +0 -0
  21. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/middleware/__init__.py +0 -0
  22. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/utils/__init__.py +0 -0
  23. {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/utils/identifier.py +0 -0
  24. {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/__init__.py +0 -0
  25. {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_api.py +0 -0
  26. {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mbuzz
3
- Version: 0.1.1
3
+ Version: 0.7.0
4
4
  Summary: Multi-touch attribution SDK for Python
5
5
  Project-URL: Homepage, https://mbuzz.co
6
6
  Project-URL: Documentation, https://mbuzz.co/docs/getting-started
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mbuzz"
7
- version = "0.1.1"
7
+ version = "0.7.0"
8
8
  description = "Multi-touch attribution SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,4 +1,5 @@
1
1
  """Mbuzz - Multi-touch attribution SDK for Python."""
2
+ # NOTE: Session ID removed in 0.7.0 - server handles session resolution
2
3
 
3
4
  from typing import Any, Dict, Optional, Union
4
5
 
@@ -8,7 +9,7 @@ from .client.track import track, TrackResult
8
9
  from .client.identify import identify
9
10
  from .client.conversion import conversion, ConversionResult
10
11
 
11
- __version__ = "0.1.0"
12
+ __version__ = "0.7.0"
12
13
 
13
14
 
14
15
  def init(
@@ -61,12 +62,6 @@ def visitor_id() -> Optional[str]:
61
62
  return ctx.visitor_id if ctx else None
62
63
 
63
64
 
64
- def session_id() -> Optional[str]:
65
- """Get current session ID from context."""
66
- ctx = get_context()
67
- return ctx.session_id if ctx else None
68
-
69
-
70
65
  def user_id() -> Optional[str]:
71
66
  """Get current user ID from context."""
72
67
  ctx = get_context()
@@ -79,7 +74,6 @@ __all__ = [
79
74
  "conversion",
80
75
  "identify",
81
76
  "visitor_id",
82
- "session_id",
83
77
  "user_id",
84
78
  "TrackResult",
85
79
  "ConversionResult",
@@ -1,4 +1,5 @@
1
1
  """Conversion request for tracking conversions."""
2
+ # NOTE: Session ID removed in 0.7.0 - server handles session resolution
2
3
 
3
4
  from dataclasses import dataclass
4
5
  from typing import Any, Dict, Optional, Union
@@ -26,6 +27,9 @@ def conversion(
26
27
  is_acquisition: bool = False,
27
28
  inherit_acquisition: bool = False,
28
29
  properties: Optional[Dict[str, Any]] = None,
30
+ ip: Optional[str] = None,
31
+ user_agent: Optional[str] = None,
32
+ identifier: Optional[Dict[str, str]] = None,
29
33
  ) -> ConversionResult:
30
34
  """Track a conversion.
31
35
 
@@ -39,6 +43,9 @@ def conversion(
39
43
  is_acquisition: Whether this is a customer acquisition
40
44
  inherit_acquisition: Whether to inherit acquisition from previous conversion
41
45
  properties: Additional conversion properties
46
+ ip: Client IP address for server-side session resolution
47
+ user_agent: Client user agent for server-side session resolution
48
+ identifier: Cross-device identifier (email, user_id, etc.)
42
49
 
43
50
  Returns:
44
51
  ConversionResult with success status, conversion ID, and attribution data
@@ -47,22 +54,36 @@ def conversion(
47
54
 
48
55
  visitor_id = visitor_id or (ctx.visitor_id if ctx else None)
49
56
  user_id = user_id or (ctx.user_id if ctx else None)
57
+ ip = ip or (ctx.ip if ctx else None)
58
+ user_agent = user_agent or (ctx.user_agent if ctx else None)
50
59
 
51
60
  if not visitor_id and not user_id:
52
61
  return ConversionResult(success=False)
53
62
 
54
- payload = {
63
+ payload: Dict[str, Any] = {
55
64
  "conversion_type": conversion_type,
56
- "visitor_id": visitor_id,
57
- "user_id": str(user_id) if user_id else None,
58
- "event_id": event_id,
59
- "revenue": revenue,
60
65
  "currency": currency,
61
66
  "is_acquisition": is_acquisition,
62
67
  "inherit_acquisition": inherit_acquisition,
63
68
  "properties": properties or {},
64
69
  }
65
70
 
71
+ # Only include non-None values
72
+ if visitor_id:
73
+ payload["visitor_id"] = visitor_id
74
+ if user_id:
75
+ payload["user_id"] = str(user_id)
76
+ if event_id:
77
+ payload["event_id"] = event_id
78
+ if revenue is not None:
79
+ payload["revenue"] = revenue
80
+ if ip:
81
+ payload["ip"] = ip
82
+ if user_agent:
83
+ payload["user_agent"] = user_agent
84
+ if identifier:
85
+ payload["identifier"] = identifier
86
+
66
87
  response = post_with_response("/conversions", payload)
67
88
  if not response:
68
89
  return ConversionResult(success=False)
@@ -1,4 +1,5 @@
1
1
  """Track request for event tracking."""
2
+ # NOTE: Session ID removed in 0.7.0 - server handles session resolution
2
3
 
3
4
  from dataclasses import dataclass
4
5
  from datetime import datetime, timezone
@@ -16,7 +17,6 @@ class TrackResult:
16
17
  event_id: Optional[str] = None
17
18
  event_type: Optional[str] = None
18
19
  visitor_id: Optional[str] = None
19
- session_id: Optional[str] = None
20
20
 
21
21
 
22
22
  @dataclass
@@ -25,13 +25,15 @@ class TrackOptions:
25
25
 
26
26
  event_type: str
27
27
  visitor_id: Optional[str] = None
28
- session_id: Optional[str] = None
29
28
  user_id: Optional[str] = None
30
29
  properties: Optional[Dict[str, Any]] = None
30
+ ip: Optional[str] = None
31
+ user_agent: Optional[str] = None
32
+ identifier: Optional[Dict[str, str]] = None
31
33
 
32
34
 
33
35
  def _resolve_ids(options: TrackOptions) -> TrackOptions:
34
- """Resolve visitor/session/user IDs from context if not provided."""
36
+ """Resolve visitor/user IDs and ip/user_agent from context if not provided."""
35
37
  ctx = get_context()
36
38
  if not ctx:
37
39
  return options
@@ -39,9 +41,11 @@ def _resolve_ids(options: TrackOptions) -> TrackOptions:
39
41
  return TrackOptions(
40
42
  event_type=options.event_type,
41
43
  visitor_id=options.visitor_id or ctx.visitor_id,
42
- session_id=options.session_id or ctx.session_id,
43
44
  user_id=options.user_id or ctx.user_id,
44
45
  properties=options.properties,
46
+ ip=options.ip or ctx.ip,
47
+ user_agent=options.user_agent or ctx.user_agent,
48
+ identifier=options.identifier,
45
49
  )
46
50
 
47
51
 
@@ -62,19 +66,26 @@ def _validate(options: TrackOptions) -> bool:
62
66
 
63
67
  def _build_payload(options: TrackOptions, properties: Dict[str, Any]) -> Dict[str, Any]:
64
68
  """Build API payload from options."""
65
- return {
66
- "events": [
67
- {
68
- "event_type": options.event_type,
69
- "visitor_id": options.visitor_id,
70
- "session_id": options.session_id,
71
- "user_id": options.user_id,
72
- "properties": properties,
73
- "timestamp": datetime.now(timezone.utc).isoformat(),
74
- }
75
- ]
69
+ event: Dict[str, Any] = {
70
+ "event_type": options.event_type,
71
+ "properties": properties,
72
+ "timestamp": datetime.now(timezone.utc).isoformat(),
76
73
  }
77
74
 
75
+ # Only include non-None values
76
+ if options.visitor_id:
77
+ event["visitor_id"] = options.visitor_id
78
+ if options.user_id:
79
+ event["user_id"] = options.user_id
80
+ if options.ip:
81
+ event["ip"] = options.ip
82
+ if options.user_agent:
83
+ event["user_agent"] = options.user_agent
84
+ if options.identifier:
85
+ event["identifier"] = options.identifier
86
+
87
+ return {"events": [event]}
88
+
78
89
 
79
90
  def _parse_response(response: Optional[Dict[str, Any]], options: TrackOptions) -> TrackResult:
80
91
  """Parse API response into TrackResult."""
@@ -87,25 +98,28 @@ def _parse_response(response: Optional[Dict[str, Any]], options: TrackOptions) -
87
98
  event_id=event.get("id"),
88
99
  event_type=options.event_type,
89
100
  visitor_id=options.visitor_id,
90
- session_id=options.session_id,
91
101
  )
92
102
 
93
103
 
94
104
  def track(
95
105
  event_type: str,
96
106
  visitor_id: Optional[str] = None,
97
- session_id: Optional[str] = None,
98
107
  user_id: Optional[str] = None,
99
108
  properties: Optional[Dict[str, Any]] = None,
109
+ ip: Optional[str] = None,
110
+ user_agent: Optional[str] = None,
111
+ identifier: Optional[Dict[str, str]] = None,
100
112
  ) -> TrackResult:
101
113
  """Track an event.
102
114
 
103
115
  Args:
104
116
  event_type: Type of event (e.g., "page_view", "button_click")
105
117
  visitor_id: Visitor ID (uses context if not provided)
106
- session_id: Session ID (uses context if not provided)
107
118
  user_id: User ID (uses context if not provided)
108
119
  properties: Additional event properties
120
+ ip: Client IP address for server-side session resolution
121
+ user_agent: Client user agent for server-side session resolution
122
+ identifier: Cross-device identifier (email, user_id, etc.)
109
123
 
110
124
  Returns:
111
125
  TrackResult with success status and event details
@@ -113,9 +127,11 @@ def track(
113
127
  options = TrackOptions(
114
128
  event_type=event_type,
115
129
  visitor_id=visitor_id,
116
- session_id=session_id,
117
130
  user_id=user_id,
118
131
  properties=properties,
132
+ ip=ip,
133
+ user_agent=user_agent,
134
+ identifier=identifier,
119
135
  )
120
136
 
121
137
  options = _resolve_ids(options)
@@ -1,4 +1,5 @@
1
1
  """Request context management using contextvars."""
2
+ # NOTE: Session ID removed in 0.7.0 - server handles session resolution
2
3
 
3
4
  from contextvars import ContextVar
4
5
  from dataclasses import dataclass
@@ -10,7 +11,8 @@ class RequestContext:
10
11
  """Holds request-scoped data for tracking."""
11
12
 
12
13
  visitor_id: str
13
- session_id: str
14
+ ip: str
15
+ user_agent: str
14
16
  user_id: Optional[str] = None
15
17
  url: Optional[str] = None
16
18
  referrer: Optional[str] = None
@@ -1,7 +1,5 @@
1
1
  """Cookie constants for mbuzz SDK."""
2
+ # NOTE: Session cookie removed in 0.7.0 - server handles session resolution
2
3
 
3
4
  VISITOR_COOKIE = "_mbuzz_vid"
4
- SESSION_COOKIE = "_mbuzz_sid"
5
-
6
5
  VISITOR_MAX_AGE = 63072000 # 2 years in seconds
7
- SESSION_MAX_AGE = 1800 # 30 minutes in seconds
@@ -1,15 +1,12 @@
1
1
  """Flask middleware for mbuzz tracking."""
2
+ # NOTE: Session cookie removed in 0.7.0 - server handles session resolution
2
3
 
3
- import threading
4
4
  from flask import Flask, request, g, Response
5
- from typing import Optional
6
5
 
7
6
  from ..config import config
8
7
  from ..context import RequestContext, set_context, clear_context
9
- from ..cookies import VISITOR_COOKIE, SESSION_COOKIE, VISITOR_MAX_AGE, SESSION_MAX_AGE
8
+ from ..cookies import VISITOR_COOKIE, VISITOR_MAX_AGE
10
9
  from ..utils.identifier import generate_id
11
- from ..utils.session_id import generate_deterministic, generate_from_fingerprint
12
- from ..client.session import create_session
13
10
 
14
11
 
15
12
  def init_app(app: Flask) -> None:
@@ -21,12 +18,11 @@ def init_app(app: Flask) -> None:
21
18
  return
22
19
 
23
20
  visitor_id = _get_or_create_visitor_id()
24
- session_id = _get_or_create_session_id()
25
- is_new_session = SESSION_COOKIE not in request.cookies
21
+ ip = _get_client_ip()
22
+ user_agent = _get_user_agent()
26
23
 
27
- _set_request_context(visitor_id, session_id)
28
- _store_in_g(visitor_id, session_id, is_new_session)
29
- _create_session_if_new(visitor_id, session_id, is_new_session)
24
+ _set_request_context(visitor_id, ip, user_agent)
25
+ _store_in_g(visitor_id)
30
26
 
31
27
  @app.after_request
32
28
  def after_request(response: Response) -> Response:
@@ -55,19 +51,6 @@ def _get_or_create_visitor_id() -> str:
55
51
  return request.cookies.get(VISITOR_COOKIE) or generate_id()
56
52
 
57
53
 
58
- def _get_or_create_session_id() -> str:
59
- """Get session ID from cookie or generate deterministic one."""
60
- existing = request.cookies.get(SESSION_COOKIE)
61
- if existing:
62
- return existing
63
-
64
- existing_visitor_id = request.cookies.get(VISITOR_COOKIE)
65
- if existing_visitor_id:
66
- return generate_deterministic(existing_visitor_id)
67
- else:
68
- return generate_from_fingerprint(_get_client_ip(), _get_user_agent())
69
-
70
-
71
54
  def _get_client_ip() -> str:
72
55
  """Get client IP from request headers."""
73
56
  forwarded = request.headers.get("X-Forwarded-For", "")
@@ -81,11 +64,12 @@ def _get_user_agent() -> str:
81
64
  return request.headers.get("User-Agent", "unknown")
82
65
 
83
66
 
84
- def _set_request_context(visitor_id: str, session_id: str) -> None:
67
+ def _set_request_context(visitor_id: str, ip: str, user_agent: str) -> None:
85
68
  """Set request context for tracking calls."""
86
69
  ctx = RequestContext(
87
70
  visitor_id=visitor_id,
88
- session_id=session_id,
71
+ ip=ip,
72
+ user_agent=user_agent,
89
73
  user_id=None,
90
74
  url=request.url,
91
75
  referrer=request.referrer,
@@ -93,34 +77,14 @@ def _set_request_context(visitor_id: str, session_id: str) -> None:
93
77
  set_context(ctx)
94
78
 
95
79
 
96
- def _store_in_g(visitor_id: str, session_id: str, is_new_session: bool) -> None:
80
+ def _store_in_g(visitor_id: str) -> None:
97
81
  """Store tracking IDs in Flask g object for after_request."""
98
82
  g.mbuzz_visitor_id = visitor_id
99
- g.mbuzz_session_id = session_id
100
83
  g.mbuzz_is_new_visitor = VISITOR_COOKIE not in request.cookies
101
- g.mbuzz_is_new_session = is_new_session
102
-
103
-
104
- def _create_session_if_new(visitor_id: str, session_id: str, is_new_session: bool) -> None:
105
- """Create session asynchronously if new session."""
106
- if not is_new_session:
107
- return
108
-
109
- ctx = RequestContext(
110
- visitor_id=visitor_id,
111
- session_id=session_id,
112
- url=request.url,
113
- referrer=request.referrer,
114
- )
115
- threading.Thread(
116
- target=create_session,
117
- args=(visitor_id, session_id, ctx.url, ctx.referrer),
118
- daemon=True
119
- ).start()
120
84
 
121
85
 
122
86
  def _set_cookies(response: Response) -> None:
123
- """Set visitor and session cookies on response."""
87
+ """Set visitor cookie on response."""
124
88
  secure = request.is_secure
125
89
 
126
90
  response.set_cookie(
@@ -131,11 +95,3 @@ def _set_cookies(response: Response) -> None:
131
95
  samesite="Lax",
132
96
  secure=secure,
133
97
  )
134
- response.set_cookie(
135
- SESSION_COOKIE,
136
- g.mbuzz_session_id,
137
- max_age=SESSION_MAX_AGE,
138
- httponly=True,
139
- samesite="Lax",
140
- secure=secure,
141
- )
@@ -7,7 +7,6 @@ from datetime import datetime, timezone
7
7
  from mbuzz.client.track import track, TrackResult
8
8
  from mbuzz.client.identify import identify
9
9
  from mbuzz.client.conversion import conversion, ConversionResult
10
- from mbuzz.client.session import create_session
11
10
  from mbuzz.context import RequestContext, set_context, clear_context
12
11
  from mbuzz.config import config
13
12
 
@@ -52,7 +51,11 @@ class TestTrack:
52
51
  def test_uses_context_visitor_id(self, mock_post):
53
52
  """Should use visitor_id from context."""
54
53
  mock_post.return_value = {"events": [{"id": "evt_123"}]}
55
- set_context(RequestContext(visitor_id="ctx_vid", session_id="ctx_sid"))
54
+ set_context(RequestContext(
55
+ visitor_id="ctx_vid",
56
+ ip="192.168.1.1",
57
+ user_agent="Mozilla/5.0",
58
+ ))
56
59
 
57
60
  result = track(event_type="page_view")
58
61
  assert result.success is True
@@ -67,7 +70,8 @@ class TestTrack:
67
70
  mock_post.return_value = {"events": [{"id": "evt_123"}]}
68
71
  set_context(RequestContext(
69
72
  visitor_id="vid_123",
70
- session_id="sid_456",
73
+ ip="192.168.1.1",
74
+ user_agent="Mozilla/5.0",
71
75
  url="https://example.com/page",
72
76
  referrer="https://google.com",
73
77
  ))
@@ -89,7 +93,6 @@ class TestTrack:
89
93
  track(
90
94
  event_type="button_click",
91
95
  visitor_id="vid_123",
92
- session_id="sid_456",
93
96
  properties={"button": "signup"},
94
97
  )
95
98
 
@@ -100,7 +103,6 @@ class TestTrack:
100
103
  event = payload["events"][0]
101
104
  assert event["event_type"] == "button_click"
102
105
  assert event["visitor_id"] == "vid_123"
103
- assert event["session_id"] == "sid_456"
104
106
  assert event["properties"]["button"] == "signup"
105
107
  assert "timestamp" in event
106
108
 
@@ -112,6 +114,112 @@ class TestTrack:
112
114
  result = track(event_type="page_view", visitor_id="vid_123")
113
115
  assert result.success is False
114
116
 
117
+ @patch("mbuzz.client.track.post_with_response")
118
+ def test_accepts_ip_parameter(self, mock_post):
119
+ """Should accept and forward ip parameter."""
120
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
121
+
122
+ result = track(
123
+ event_type="page_view",
124
+ visitor_id="vid_123",
125
+ ip="192.168.1.100"
126
+ )
127
+ assert result.success is True
128
+
129
+ call_args = mock_post.call_args[0]
130
+ payload = call_args[1]
131
+ assert payload["events"][0]["ip"] == "192.168.1.100"
132
+
133
+ @patch("mbuzz.client.track.post_with_response")
134
+ def test_accepts_user_agent_parameter(self, mock_post):
135
+ """Should accept and forward user_agent parameter."""
136
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
137
+
138
+ result = track(
139
+ event_type="page_view",
140
+ visitor_id="vid_123",
141
+ user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
142
+ )
143
+ assert result.success is True
144
+
145
+ call_args = mock_post.call_args[0]
146
+ payload = call_args[1]
147
+ assert payload["events"][0]["user_agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
148
+
149
+ @patch("mbuzz.client.track.post_with_response")
150
+ def test_accepts_both_ip_and_user_agent(self, mock_post):
151
+ """Should accept both ip and user_agent parameters."""
152
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
153
+
154
+ result = track(
155
+ event_type="page_view",
156
+ visitor_id="vid_123",
157
+ ip="10.0.0.1",
158
+ user_agent="Chrome/120"
159
+ )
160
+ assert result.success is True
161
+
162
+ call_args = mock_post.call_args[0]
163
+ payload = call_args[1]
164
+ assert payload["events"][0]["ip"] == "10.0.0.1"
165
+ assert payload["events"][0]["user_agent"] == "Chrome/120"
166
+
167
+ @patch("mbuzz.client.track.post_with_response")
168
+ def test_uses_context_ip_and_user_agent(self, mock_post):
169
+ """Should use ip and user_agent from context if not explicitly provided."""
170
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
171
+ set_context(RequestContext(
172
+ visitor_id="ctx_vid",
173
+ ip="203.0.113.50",
174
+ user_agent="Safari/17.0",
175
+ ))
176
+
177
+ result = track(event_type="page_view")
178
+ assert result.success is True
179
+
180
+ call_args = mock_post.call_args[0]
181
+ payload = call_args[1]
182
+ assert payload["events"][0]["ip"] == "203.0.113.50"
183
+ assert payload["events"][0]["user_agent"] == "Safari/17.0"
184
+
185
+ @patch("mbuzz.client.track.post_with_response")
186
+ def test_explicit_ip_overrides_context(self, mock_post):
187
+ """Should use explicit ip/user_agent over context."""
188
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
189
+ set_context(RequestContext(
190
+ visitor_id="ctx_vid",
191
+ ip="context_ip",
192
+ user_agent="context_ua",
193
+ ))
194
+
195
+ result = track(
196
+ event_type="page_view",
197
+ ip="explicit_ip",
198
+ user_agent="explicit_ua"
199
+ )
200
+ assert result.success is True
201
+
202
+ call_args = mock_post.call_args[0]
203
+ payload = call_args[1]
204
+ assert payload["events"][0]["ip"] == "explicit_ip"
205
+ assert payload["events"][0]["user_agent"] == "explicit_ua"
206
+
207
+ @patch("mbuzz.client.track.post_with_response")
208
+ def test_accepts_identifier_parameter(self, mock_post):
209
+ """Should accept and forward identifier parameter."""
210
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
211
+
212
+ result = track(
213
+ event_type="page_view",
214
+ visitor_id="vid_123",
215
+ identifier={"email": "test@example.com"}
216
+ )
217
+ assert result.success is True
218
+
219
+ call_args = mock_post.call_args[0]
220
+ payload = call_args[1]
221
+ assert payload["events"][0]["identifier"] == {"email": "test@example.com"}
222
+
115
223
 
116
224
  class TestIdentify:
117
225
  """Test identify function."""
@@ -147,7 +255,11 @@ class TestIdentify:
147
255
  def test_uses_context_visitor_id(self, mock_post):
148
256
  """Should use visitor_id from context."""
149
257
  mock_post.return_value = True
150
- set_context(RequestContext(visitor_id="ctx_vid", session_id="ctx_sid"))
258
+ set_context(RequestContext(
259
+ visitor_id="ctx_vid",
260
+ ip="192.168.1.1",
261
+ user_agent="Mozilla/5.0",
262
+ ))
151
263
 
152
264
  identify(user_id="user_123")
153
265
 
@@ -218,7 +330,11 @@ class TestConversion:
218
330
  def test_uses_context_visitor_id(self, mock_post):
219
331
  """Should use visitor_id from context."""
220
332
  mock_post.return_value = {"conversion": {"id": "conv_123"}}
221
- set_context(RequestContext(visitor_id="ctx_vid", session_id="ctx_sid"))
333
+ set_context(RequestContext(
334
+ visitor_id="ctx_vid",
335
+ ip="192.168.1.1",
336
+ user_agent="Mozilla/5.0",
337
+ ))
222
338
 
223
339
  result = conversion(conversion_type="purchase")
224
340
  assert result.success is True
@@ -262,61 +378,36 @@ class TestConversion:
262
378
  result = conversion(conversion_type="purchase", visitor_id="vid_123")
263
379
  assert result.attribution == {"model": "linear", "sessions": []}
264
380
 
381
+ @patch("mbuzz.client.conversion.post_with_response")
382
+ def test_accepts_ip_and_user_agent(self, mock_post):
383
+ """Should accept and forward ip and user_agent parameters."""
384
+ mock_post.return_value = {"conversion": {"id": "conv_123"}}
265
385
 
266
- class TestCreateSession:
267
- """Test create_session function."""
268
-
269
- def setup_method(self):
270
- """Set up before each test."""
271
- config.reset()
272
- config.init(api_key="sk_test_123", api_url="http://localhost:3000/api/v1")
273
-
274
- def teardown_method(self):
275
- """Clean up after each test."""
276
- config.reset()
277
-
278
- @patch("mbuzz.client.session.post")
279
- def test_returns_true_on_success(self, mock_post):
280
- """Should return True on success."""
281
- mock_post.return_value = True
282
-
283
- result = create_session(
386
+ result = conversion(
387
+ conversion_type="purchase",
284
388
  visitor_id="vid_123",
285
- session_id="sid_456",
286
- url="https://example.com",
389
+ ip="192.168.1.100",
390
+ user_agent="Mozilla/5.0",
287
391
  )
288
- assert result is True
392
+ assert result.success is True
289
393
 
290
- @patch("mbuzz.client.session.post")
291
- def test_sends_correct_payload(self, mock_post):
292
- """Should send correctly structured payload."""
293
- mock_post.return_value = True
394
+ call_args = mock_post.call_args[0]
395
+ payload = call_args[1]
396
+ assert payload["ip"] == "192.168.1.100"
397
+ assert payload["user_agent"] == "Mozilla/5.0"
294
398
 
295
- create_session(
296
- visitor_id="vid_123",
297
- session_id="sid_456",
298
- url="https://example.com/page",
299
- referrer="https://google.com",
300
- )
399
+ @patch("mbuzz.client.conversion.post_with_response")
400
+ def test_accepts_identifier_parameter(self, mock_post):
401
+ """Should accept and forward identifier parameter."""
402
+ mock_post.return_value = {"conversion": {"id": "conv_123"}}
301
403
 
302
- call_args = mock_post.call_args
303
- assert call_args[0][0] == "/sessions"
304
- payload = call_args[0][1]
305
- session = payload["session"]
306
- assert session["visitor_id"] == "vid_123"
307
- assert session["session_id"] == "sid_456"
308
- assert session["url"] == "https://example.com/page"
309
- assert session["referrer"] == "https://google.com"
310
- assert "started_at" in session
311
-
312
- @patch("mbuzz.client.session.post")
313
- def test_returns_false_on_api_error(self, mock_post):
314
- """Should return False on API error."""
315
- mock_post.return_value = False
316
-
317
- result = create_session(
404
+ result = conversion(
405
+ conversion_type="purchase",
318
406
  visitor_id="vid_123",
319
- session_id="sid_456",
320
- url="https://example.com",
407
+ identifier={"email": "test@example.com"}
321
408
  )
322
- assert result is False
409
+ assert result.success is True
410
+
411
+ call_args = mock_post.call_args[0]
412
+ payload = call_args[1]
413
+ assert payload["identifier"] == {"email": "test@example.com"}
@@ -8,14 +8,23 @@ class TestRequestContext:
8
8
  """Test RequestContext dataclass."""
9
9
 
10
10
  def test_creates_with_required_fields(self):
11
- """Should create context with visitor and session IDs."""
12
- ctx = RequestContext(visitor_id="vid_123", session_id="sid_456")
11
+ """Should create context with visitor_id, ip, and user_agent."""
12
+ ctx = RequestContext(
13
+ visitor_id="vid_123",
14
+ ip="192.168.1.1",
15
+ user_agent="Mozilla/5.0",
16
+ )
13
17
  assert ctx.visitor_id == "vid_123"
14
- assert ctx.session_id == "sid_456"
18
+ assert ctx.ip == "192.168.1.1"
19
+ assert ctx.user_agent == "Mozilla/5.0"
15
20
 
16
21
  def test_optional_fields_default_to_none(self):
17
22
  """Should default optional fields to None."""
18
- ctx = RequestContext(visitor_id="vid_123", session_id="sid_456")
23
+ ctx = RequestContext(
24
+ visitor_id="vid_123",
25
+ ip="192.168.1.1",
26
+ user_agent="Mozilla/5.0",
27
+ )
19
28
  assert ctx.user_id is None
20
29
  assert ctx.url is None
21
30
  assert ctx.referrer is None
@@ -24,13 +33,15 @@ class TestRequestContext:
24
33
  """Should accept all fields."""
25
34
  ctx = RequestContext(
26
35
  visitor_id="vid_123",
27
- session_id="sid_456",
36
+ ip="192.168.1.1",
37
+ user_agent="Mozilla/5.0",
28
38
  user_id="user_789",
29
39
  url="https://example.com/page",
30
40
  referrer="https://google.com",
31
41
  )
32
42
  assert ctx.visitor_id == "vid_123"
33
- assert ctx.session_id == "sid_456"
43
+ assert ctx.ip == "192.168.1.1"
44
+ assert ctx.user_agent == "Mozilla/5.0"
34
45
  assert ctx.user_id == "user_789"
35
46
  assert ctx.url == "https://example.com/page"
36
47
  assert ctx.referrer == "https://google.com"
@@ -43,7 +54,8 @@ class TestEnrichProperties:
43
54
  """Should add url to properties."""
44
55
  ctx = RequestContext(
45
56
  visitor_id="vid_123",
46
- session_id="sid_456",
57
+ ip="192.168.1.1",
58
+ user_agent="Mozilla/5.0",
47
59
  url="https://example.com/page",
48
60
  )
49
61
  result = ctx.enrich_properties({})
@@ -53,7 +65,8 @@ class TestEnrichProperties:
53
65
  """Should add referrer to properties."""
54
66
  ctx = RequestContext(
55
67
  visitor_id="vid_123",
56
- session_id="sid_456",
68
+ ip="192.168.1.1",
69
+ user_agent="Mozilla/5.0",
57
70
  referrer="https://google.com",
58
71
  )
59
72
  result = ctx.enrich_properties({})
@@ -63,7 +76,8 @@ class TestEnrichProperties:
63
76
  """Custom properties should override context values."""
64
77
  ctx = RequestContext(
65
78
  visitor_id="vid_123",
66
- session_id="sid_456",
79
+ ip="192.168.1.1",
80
+ user_agent="Mozilla/5.0",
67
81
  url="https://example.com/page",
68
82
  )
69
83
  result = ctx.enrich_properties({"url": "custom_url"})
@@ -73,7 +87,8 @@ class TestEnrichProperties:
73
87
  """Should preserve custom properties."""
74
88
  ctx = RequestContext(
75
89
  visitor_id="vid_123",
76
- session_id="sid_456",
90
+ ip="192.168.1.1",
91
+ user_agent="Mozilla/5.0",
77
92
  url="https://example.com/page",
78
93
  )
79
94
  result = ctx.enrich_properties({"custom": "value", "count": 42})
@@ -83,7 +98,11 @@ class TestEnrichProperties:
83
98
 
84
99
  def test_handles_empty_context(self):
85
100
  """Should work with no url or referrer in context."""
86
- ctx = RequestContext(visitor_id="vid_123", session_id="sid_456")
101
+ ctx = RequestContext(
102
+ visitor_id="vid_123",
103
+ ip="192.168.1.1",
104
+ user_agent="Mozilla/5.0",
105
+ )
87
106
  result = ctx.enrich_properties({"custom": "value"})
88
107
  assert result == {"custom": "value"}
89
108
  assert "url" not in result
@@ -107,21 +126,37 @@ class TestContextVar:
107
126
 
108
127
  def test_set_context_stores_context(self):
109
128
  """Should store context."""
110
- ctx = RequestContext(visitor_id="vid_123", session_id="sid_456")
129
+ ctx = RequestContext(
130
+ visitor_id="vid_123",
131
+ ip="192.168.1.1",
132
+ user_agent="Mozilla/5.0",
133
+ )
111
134
  set_context(ctx)
112
135
  assert get_context() is ctx
113
136
 
114
137
  def test_clear_context_removes_context(self):
115
138
  """Should clear stored context."""
116
- ctx = RequestContext(visitor_id="vid_123", session_id="sid_456")
139
+ ctx = RequestContext(
140
+ visitor_id="vid_123",
141
+ ip="192.168.1.1",
142
+ user_agent="Mozilla/5.0",
143
+ )
117
144
  set_context(ctx)
118
145
  clear_context()
119
146
  assert get_context() is None
120
147
 
121
148
  def test_context_is_isolated_per_context(self):
122
149
  """Context should be isolated (uses contextvars)."""
123
- ctx1 = RequestContext(visitor_id="vid_1", session_id="sid_1")
124
- ctx2 = RequestContext(visitor_id="vid_2", session_id="sid_2")
150
+ ctx1 = RequestContext(
151
+ visitor_id="vid_1",
152
+ ip="192.168.1.1",
153
+ user_agent="Mozilla/5.0",
154
+ )
155
+ ctx2 = RequestContext(
156
+ visitor_id="vid_2",
157
+ ip="192.168.1.2",
158
+ user_agent="Chrome/120",
159
+ )
125
160
 
126
161
  set_context(ctx1)
127
162
  assert get_context().visitor_id == "vid_1"
@@ -7,7 +7,7 @@ from flask import Flask, g
7
7
  from mbuzz.middleware.flask import init_app
8
8
  from mbuzz.config import config
9
9
  from mbuzz.context import get_context, clear_context
10
- from mbuzz.cookies import VISITOR_COOKIE, SESSION_COOKIE, VISITOR_MAX_AGE, SESSION_MAX_AGE
10
+ from mbuzz.cookies import VISITOR_COOKIE, VISITOR_MAX_AGE
11
11
 
12
12
 
13
13
  class TestFlaskMiddleware:
@@ -100,8 +100,8 @@ class TestFlaskMiddleware:
100
100
  assert visitor_cookie is not None
101
101
  assert VISITOR_COOKIE in visitor_cookie
102
102
 
103
- def test_creates_session_id_for_new_visitor(self):
104
- """Should create session ID for new visitor."""
103
+ def test_only_sets_visitor_cookie(self):
104
+ """Should only set visitor cookie (no session cookie)."""
105
105
  config.init(api_key="sk_test_123")
106
106
  init_app(self.app)
107
107
 
@@ -109,12 +109,10 @@ class TestFlaskMiddleware:
109
109
  response = client.get("/")
110
110
 
111
111
  cookies = response.headers.getlist("Set-Cookie")
112
- session_cookie = next(
113
- (c for c in cookies if SESSION_COOKIE in c), None
114
- )
115
-
116
- assert session_cookie is not None
117
- assert SESSION_COOKIE in session_cookie
112
+ # Should only have one cookie (visitor)
113
+ mbuzz_cookies = [c for c in cookies if "_mbuzz_" in c]
114
+ assert len(mbuzz_cookies) == 1
115
+ assert VISITOR_COOKIE in mbuzz_cookies[0]
118
116
 
119
117
  def test_reuses_visitor_id_from_cookie(self):
120
118
  """Should reuse existing visitor ID from cookie."""
@@ -134,24 +132,6 @@ class TestFlaskMiddleware:
134
132
 
135
133
  assert existing_vid in visitor_cookie
136
134
 
137
- def test_reuses_session_id_from_cookie(self):
138
- """Should reuse existing session ID from cookie."""
139
- config.init(api_key="sk_test_123")
140
- init_app(self.app)
141
-
142
- existing_sid = "xyz789" * 10 + "xyzw"
143
-
144
- with self.app.test_client() as client:
145
- client.set_cookie(SESSION_COOKIE, existing_sid)
146
- response = client.get("/")
147
-
148
- cookies = response.headers.getlist("Set-Cookie")
149
- session_cookie = next(
150
- (c for c in cookies if SESSION_COOKIE in c), None
151
- )
152
-
153
- assert existing_sid in session_cookie
154
-
155
135
  def test_sets_context_during_request(self):
156
136
  """Should set request context during request handling."""
157
137
  config.init(api_key="sk_test_123")
@@ -164,7 +144,8 @@ class TestFlaskMiddleware:
164
144
  ctx = get_context()
165
145
  if ctx:
166
146
  captured_context["visitor_id"] = ctx.visitor_id
167
- captured_context["session_id"] = ctx.session_id
147
+ captured_context["ip"] = ctx.ip
148
+ captured_context["user_agent"] = ctx.user_agent
168
149
  captured_context["url"] = ctx.url
169
150
  return "captured"
170
151
 
@@ -172,9 +153,9 @@ class TestFlaskMiddleware:
172
153
  client.get("/capture")
173
154
 
174
155
  assert "visitor_id" in captured_context
175
- assert "session_id" in captured_context
156
+ assert "ip" in captured_context
157
+ assert "user_agent" in captured_context
176
158
  assert len(captured_context["visitor_id"]) == 64
177
- assert len(captured_context["session_id"]) == 64
178
159
 
179
160
  def test_clears_context_after_request(self):
180
161
  """Should clear context after request completes."""
@@ -225,33 +206,6 @@ class TestFlaskMiddleware:
225
206
 
226
207
  assert captured_referrer.get("referrer") == "https://google.com"
227
208
 
228
- @patch("mbuzz.middleware.flask.create_session")
229
- def test_calls_create_session_for_new_session(self, mock_create_session):
230
- """Should call create_session for new sessions."""
231
- config.init(api_key="sk_test_123")
232
- init_app(self.app)
233
-
234
- with self.app.test_client() as client:
235
- client.get("/")
236
-
237
- # Give thread time to start
238
- import time
239
- time.sleep(0.1)
240
-
241
- assert mock_create_session.called
242
-
243
- @patch("mbuzz.middleware.flask.create_session")
244
- def test_does_not_call_create_session_for_existing_session(self, mock_create_session):
245
- """Should not call create_session for existing sessions."""
246
- config.init(api_key="sk_test_123")
247
- init_app(self.app)
248
-
249
- with self.app.test_client() as client:
250
- client.set_cookie(SESSION_COOKIE, "existing_session_id_abc123")
251
- client.get("/")
252
-
253
- assert not mock_create_session.called
254
-
255
209
  def test_sets_visitor_cookie_max_age(self):
256
210
  """Should set visitor cookie with correct max age."""
257
211
  config.init(api_key="sk_test_123")
@@ -267,21 +221,6 @@ class TestFlaskMiddleware:
267
221
 
268
222
  assert f"Max-Age={VISITOR_MAX_AGE}" in visitor_cookie
269
223
 
270
- def test_sets_session_cookie_max_age(self):
271
- """Should set session cookie with correct max age."""
272
- config.init(api_key="sk_test_123")
273
- init_app(self.app)
274
-
275
- with self.app.test_client() as client:
276
- response = client.get("/")
277
-
278
- cookies = response.headers.getlist("Set-Cookie")
279
- session_cookie = next(
280
- (c for c in cookies if SESSION_COOKIE in c), None
281
- )
282
-
283
- assert f"Max-Age={SESSION_MAX_AGE}" in session_cookie
284
-
285
224
  def test_sets_httponly_on_cookies(self):
286
225
  """Should set HttpOnly flag on cookies."""
287
226
  config.init(api_key="sk_test_123")
@@ -294,12 +233,8 @@ class TestFlaskMiddleware:
294
233
  visitor_cookie = next(
295
234
  (c for c in cookies if VISITOR_COOKIE in c), None
296
235
  )
297
- session_cookie = next(
298
- (c for c in cookies if SESSION_COOKIE in c), None
299
- )
300
236
 
301
237
  assert "HttpOnly" in visitor_cookie
302
- assert "HttpOnly" in session_cookie
303
238
 
304
239
  def test_sets_samesite_lax_on_cookies(self):
305
240
  """Should set SameSite=Lax on cookies."""
@@ -313,12 +248,8 @@ class TestFlaskMiddleware:
313
248
  visitor_cookie = next(
314
249
  (c for c in cookies if VISITOR_COOKIE in c), None
315
250
  )
316
- session_cookie = next(
317
- (c for c in cookies if SESSION_COOKIE in c), None
318
- )
319
251
 
320
252
  assert "SameSite=Lax" in visitor_cookie
321
- assert "SameSite=Lax" in session_cookie
322
253
 
323
254
  def test_generates_64_char_visitor_id(self):
324
255
  """Should generate 64-character visitor ID."""
@@ -339,24 +270,43 @@ class TestFlaskMiddleware:
339
270
 
340
271
  assert len(captured.get("visitor_id", "")) == 64
341
272
 
342
- def test_generates_64_char_session_id(self):
343
- """Should generate 64-character session ID."""
273
+ def test_captures_ip_from_x_forwarded_for(self):
274
+ """Should capture client IP from X-Forwarded-For header."""
344
275
  config.init(api_key="sk_test_123")
345
276
  init_app(self.app)
346
277
 
347
278
  captured = {}
348
279
 
349
- @self.app.route("/check")
350
- def check():
280
+ @self.app.route("/check-ip")
281
+ def check_ip():
351
282
  ctx = get_context()
352
283
  if ctx:
353
- captured["session_id"] = ctx.session_id
284
+ captured["ip"] = ctx.ip
354
285
  return "ok"
355
286
 
356
287
  with self.app.test_client() as client:
357
- client.get("/check")
288
+ client.get("/check-ip", headers={"X-Forwarded-For": "203.0.113.50, 198.51.100.1"})
289
+
290
+ assert captured.get("ip") == "203.0.113.50"
291
+
292
+ def test_captures_user_agent(self):
293
+ """Should capture user agent from request."""
294
+ config.init(api_key="sk_test_123")
295
+ init_app(self.app)
296
+
297
+ captured = {}
298
+
299
+ @self.app.route("/check-ua")
300
+ def check_ua():
301
+ ctx = get_context()
302
+ if ctx:
303
+ captured["user_agent"] = ctx.user_agent
304
+ return "ok"
305
+
306
+ with self.app.test_client() as client:
307
+ client.get("/check-ua", headers={"User-Agent": "Mozilla/5.0 Test"})
358
308
 
359
- assert len(captured.get("session_id", "")) == 64
309
+ assert captured.get("user_agent") == "Mozilla/5.0 Test"
360
310
 
361
311
  def test_skips_custom_paths(self):
362
312
  """Should skip custom skip paths."""
@@ -1,38 +0,0 @@
1
- """Session request for creating sessions."""
2
-
3
- from datetime import datetime, timezone
4
- from typing import Optional
5
-
6
- from ..api import post
7
-
8
-
9
- def create_session(
10
- visitor_id: str,
11
- session_id: str,
12
- url: str,
13
- referrer: Optional[str] = None,
14
- ) -> bool:
15
- """Create a new session.
16
-
17
- Called async from middleware on first request.
18
-
19
- Args:
20
- visitor_id: Visitor ID
21
- session_id: Session ID
22
- url: Current page URL
23
- referrer: Referring URL
24
-
25
- Returns:
26
- True on success, False on failure
27
- """
28
- payload = {
29
- "session": {
30
- "visitor_id": visitor_id,
31
- "session_id": session_id,
32
- "url": url,
33
- "referrer": referrer,
34
- "started_at": datetime.now(timezone.utc).isoformat(),
35
- }
36
- }
37
-
38
- return post("/sessions", payload)
@@ -1,39 +0,0 @@
1
- """Deterministic session ID generation."""
2
-
3
- import hashlib
4
- import secrets
5
- import time
6
-
7
- SESSION_TIMEOUT_SECONDS = 1800
8
- SESSION_ID_LENGTH = 64
9
- FINGERPRINT_LENGTH = 32
10
-
11
-
12
- def generate_deterministic(visitor_id: str, timestamp: int | None = None) -> str:
13
- """Generate session ID for returning visitors."""
14
- if timestamp is None:
15
- timestamp = int(time.time())
16
- time_bucket = timestamp // SESSION_TIMEOUT_SECONDS
17
- raw = f"{visitor_id}_{time_bucket}"
18
- return hashlib.sha256(raw.encode()).hexdigest()[:SESSION_ID_LENGTH]
19
-
20
-
21
- def generate_from_fingerprint(
22
- client_ip: str,
23
- user_agent: str,
24
- timestamp: int | None = None
25
- ) -> str:
26
- """Generate session ID for new visitors using IP+UA fingerprint."""
27
- if timestamp is None:
28
- timestamp = int(time.time())
29
- fingerprint = hashlib.sha256(
30
- f"{client_ip}|{user_agent}".encode()
31
- ).hexdigest()[:FINGERPRINT_LENGTH]
32
- time_bucket = timestamp // SESSION_TIMEOUT_SECONDS
33
- raw = f"{fingerprint}_{time_bucket}"
34
- return hashlib.sha256(raw.encode()).hexdigest()[:SESSION_ID_LENGTH]
35
-
36
-
37
- def generate_random() -> str:
38
- """Generate random session ID (fallback)."""
39
- return secrets.token_hex(32)
@@ -1,146 +0,0 @@
1
- """Tests for deterministic session ID generation."""
2
-
3
- import pytest
4
- from mbuzz.utils.session_id import (
5
- generate_deterministic,
6
- generate_from_fingerprint,
7
- generate_random,
8
- )
9
-
10
-
11
- class TestGenerateDeterministic:
12
- sample_visitor_id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
13
- sample_timestamp = 1735500000
14
-
15
- def test_returns_64_char_hex_string(self):
16
- result = generate_deterministic(self.sample_visitor_id, self.sample_timestamp)
17
- assert len(result) == 64
18
- assert all(c in "0123456789abcdef" for c in result)
19
-
20
- def test_is_consistent(self):
21
- result1 = generate_deterministic(self.sample_visitor_id, self.sample_timestamp)
22
- result2 = generate_deterministic(self.sample_visitor_id, self.sample_timestamp)
23
- assert result1 == result2
24
-
25
- def test_same_within_time_bucket(self):
26
- # bucket = timestamp / 1800
27
- # 1735500000 / 1800 = 964166
28
- # 1735500599 / 1800 = 964166 (last second of bucket)
29
- timestamp1 = 1735500000
30
- timestamp2 = 1735500001
31
- timestamp3 = 1735500599
32
-
33
- result1 = generate_deterministic(self.sample_visitor_id, timestamp1)
34
- result2 = generate_deterministic(self.sample_visitor_id, timestamp2)
35
- result3 = generate_deterministic(self.sample_visitor_id, timestamp3)
36
-
37
- assert result1 == result2
38
- assert result1 == result3
39
-
40
- def test_different_across_time_buckets(self):
41
- timestamp1 = 1735500000
42
- timestamp2 = 1735501800 # Next bucket
43
-
44
- result1 = generate_deterministic(self.sample_visitor_id, timestamp1)
45
- result2 = generate_deterministic(self.sample_visitor_id, timestamp2)
46
-
47
- assert result1 != result2
48
-
49
- def test_different_for_different_visitors(self):
50
- result1 = generate_deterministic("visitor_a", self.sample_timestamp)
51
- result2 = generate_deterministic("visitor_b", self.sample_timestamp)
52
-
53
- assert result1 != result2
54
-
55
-
56
- class TestGenerateFromFingerprint:
57
- sample_ip = "203.0.113.42"
58
- sample_user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
59
- sample_timestamp = 1735500000
60
-
61
- def test_returns_64_char_hex_string(self):
62
- result = generate_from_fingerprint(
63
- self.sample_ip, self.sample_user_agent, self.sample_timestamp
64
- )
65
- assert len(result) == 64
66
- assert all(c in "0123456789abcdef" for c in result)
67
-
68
- def test_is_consistent(self):
69
- result1 = generate_from_fingerprint(
70
- self.sample_ip, self.sample_user_agent, self.sample_timestamp
71
- )
72
- result2 = generate_from_fingerprint(
73
- self.sample_ip, self.sample_user_agent, self.sample_timestamp
74
- )
75
- assert result1 == result2
76
-
77
- def test_same_within_time_bucket(self):
78
- timestamp1 = 1735500000
79
- timestamp2 = 1735500001
80
-
81
- result1 = generate_from_fingerprint(
82
- self.sample_ip, self.sample_user_agent, timestamp1
83
- )
84
- result2 = generate_from_fingerprint(
85
- self.sample_ip, self.sample_user_agent, timestamp2
86
- )
87
-
88
- assert result1 == result2
89
-
90
- def test_different_across_time_buckets(self):
91
- timestamp1 = 1735500000
92
- timestamp2 = 1735501800
93
-
94
- result1 = generate_from_fingerprint(
95
- self.sample_ip, self.sample_user_agent, timestamp1
96
- )
97
- result2 = generate_from_fingerprint(
98
- self.sample_ip, self.sample_user_agent, timestamp2
99
- )
100
-
101
- assert result1 != result2
102
-
103
- def test_different_for_different_ips(self):
104
- result1 = generate_from_fingerprint(
105
- "192.168.1.1", self.sample_user_agent, self.sample_timestamp
106
- )
107
- result2 = generate_from_fingerprint(
108
- "192.168.1.2", self.sample_user_agent, self.sample_timestamp
109
- )
110
-
111
- assert result1 != result2
112
-
113
- def test_different_for_different_user_agents(self):
114
- result1 = generate_from_fingerprint(
115
- self.sample_ip, "Mozilla/5.0 Chrome", self.sample_timestamp
116
- )
117
- result2 = generate_from_fingerprint(
118
- self.sample_ip, "Mozilla/5.0 Safari", self.sample_timestamp
119
- )
120
-
121
- assert result1 != result2
122
-
123
-
124
- class TestGenerateRandom:
125
- def test_returns_64_char_hex_string(self):
126
- result = generate_random()
127
- assert len(result) == 64
128
- assert all(c in "0123456789abcdef" for c in result)
129
-
130
- def test_returns_unique_ids(self):
131
- result1 = generate_random()
132
- result2 = generate_random()
133
- assert result1 != result2
134
-
135
-
136
- class TestCrossMethod:
137
- def test_deterministic_and_fingerprint_produce_different_ids(self):
138
- visitor_id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
139
- ip = "203.0.113.42"
140
- user_agent = "Mozilla/5.0"
141
- timestamp = 1735500000
142
-
143
- deterministic = generate_deterministic(visitor_id, timestamp)
144
- fingerprint = generate_from_fingerprint(ip, user_agent, timestamp)
145
-
146
- assert deterministic != fingerprint
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes