mbuzz 0.2.0__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.
- {mbuzz-0.2.0 → mbuzz-0.7.0}/PKG-INFO +1 -1
- {mbuzz-0.2.0 → mbuzz-0.7.0}/pyproject.toml +1 -1
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/__init__.py +2 -8
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/client/conversion.py +26 -5
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/client/track.py +16 -13
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/context.py +3 -3
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/cookies.py +1 -3
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/middleware/flask.py +11 -55
- {mbuzz-0.2.0 → mbuzz-0.7.0}/tests/test_client.py +60 -63
- {mbuzz-0.2.0 → mbuzz-0.7.0}/tests/test_context.py +50 -15
- {mbuzz-0.2.0 → mbuzz-0.7.0}/tests/test_middleware.py +37 -87
- mbuzz-0.2.0/src/mbuzz/client/session.py +0 -38
- mbuzz-0.2.0/src/mbuzz/utils/session_id.py +0 -39
- mbuzz-0.2.0/tests/test_session_id.py +0 -146
- {mbuzz-0.2.0 → mbuzz-0.7.0}/.gitignore +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/README.md +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/api.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/client/__init__.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/client/identify.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/config.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/middleware/__init__.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/utils/__init__.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/src/mbuzz/utils/identifier.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/tests/__init__.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/tests/test_api.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.0}/tests/test_config.py +0 -0
|
@@ -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.
|
|
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,15 +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
|
|
31
30
|
ip: Optional[str] = None
|
|
32
31
|
user_agent: Optional[str] = None
|
|
32
|
+
identifier: Optional[Dict[str, str]] = None
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def _resolve_ids(options: TrackOptions) -> TrackOptions:
|
|
36
|
-
"""Resolve visitor/
|
|
36
|
+
"""Resolve visitor/user IDs and ip/user_agent from context if not provided."""
|
|
37
37
|
ctx = get_context()
|
|
38
38
|
if not ctx:
|
|
39
39
|
return options
|
|
@@ -41,11 +41,11 @@ def _resolve_ids(options: TrackOptions) -> TrackOptions:
|
|
|
41
41
|
return TrackOptions(
|
|
42
42
|
event_type=options.event_type,
|
|
43
43
|
visitor_id=options.visitor_id or ctx.visitor_id,
|
|
44
|
-
session_id=options.session_id or ctx.session_id,
|
|
45
44
|
user_id=options.user_id or ctx.user_id,
|
|
46
45
|
properties=options.properties,
|
|
47
46
|
ip=options.ip or ctx.ip,
|
|
48
47
|
user_agent=options.user_agent or ctx.user_agent,
|
|
48
|
+
identifier=options.identifier,
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
|
|
@@ -66,19 +66,23 @@ def _validate(options: TrackOptions) -> bool:
|
|
|
66
66
|
|
|
67
67
|
def _build_payload(options: TrackOptions, properties: Dict[str, Any]) -> Dict[str, Any]:
|
|
68
68
|
"""Build API payload from options."""
|
|
69
|
-
event = {
|
|
69
|
+
event: Dict[str, Any] = {
|
|
70
70
|
"event_type": options.event_type,
|
|
71
|
-
"visitor_id": options.visitor_id,
|
|
72
|
-
"session_id": options.session_id,
|
|
73
|
-
"user_id": options.user_id,
|
|
74
71
|
"properties": properties,
|
|
75
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
|
|
78
80
|
if options.ip:
|
|
79
81
|
event["ip"] = options.ip
|
|
80
82
|
if options.user_agent:
|
|
81
83
|
event["user_agent"] = options.user_agent
|
|
84
|
+
if options.identifier:
|
|
85
|
+
event["identifier"] = options.identifier
|
|
82
86
|
|
|
83
87
|
return {"events": [event]}
|
|
84
88
|
|
|
@@ -94,29 +98,28 @@ def _parse_response(response: Optional[Dict[str, Any]], options: TrackOptions) -
|
|
|
94
98
|
event_id=event.get("id"),
|
|
95
99
|
event_type=options.event_type,
|
|
96
100
|
visitor_id=options.visitor_id,
|
|
97
|
-
session_id=options.session_id,
|
|
98
101
|
)
|
|
99
102
|
|
|
100
103
|
|
|
101
104
|
def track(
|
|
102
105
|
event_type: str,
|
|
103
106
|
visitor_id: Optional[str] = None,
|
|
104
|
-
session_id: Optional[str] = None,
|
|
105
107
|
user_id: Optional[str] = None,
|
|
106
108
|
properties: Optional[Dict[str, Any]] = None,
|
|
107
109
|
ip: Optional[str] = None,
|
|
108
110
|
user_agent: Optional[str] = None,
|
|
111
|
+
identifier: Optional[Dict[str, str]] = None,
|
|
109
112
|
) -> TrackResult:
|
|
110
113
|
"""Track an event.
|
|
111
114
|
|
|
112
115
|
Args:
|
|
113
116
|
event_type: Type of event (e.g., "page_view", "button_click")
|
|
114
117
|
visitor_id: Visitor ID (uses context if not provided)
|
|
115
|
-
session_id: Session ID (uses context if not provided)
|
|
116
118
|
user_id: User ID (uses context if not provided)
|
|
117
119
|
properties: Additional event properties
|
|
118
120
|
ip: Client IP address for server-side session resolution
|
|
119
121
|
user_agent: Client user agent for server-side session resolution
|
|
122
|
+
identifier: Cross-device identifier (email, user_id, etc.)
|
|
120
123
|
|
|
121
124
|
Returns:
|
|
122
125
|
TrackResult with success status and event details
|
|
@@ -124,11 +127,11 @@ def track(
|
|
|
124
127
|
options = TrackOptions(
|
|
125
128
|
event_type=event_type,
|
|
126
129
|
visitor_id=visitor_id,
|
|
127
|
-
session_id=session_id,
|
|
128
130
|
user_id=user_id,
|
|
129
131
|
properties=properties,
|
|
130
132
|
ip=ip,
|
|
131
133
|
user_agent=user_agent,
|
|
134
|
+
identifier=identifier,
|
|
132
135
|
)
|
|
133
136
|
|
|
134
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,12 +11,11 @@ class RequestContext:
|
|
|
10
11
|
"""Holds request-scoped data for tracking."""
|
|
11
12
|
|
|
12
13
|
visitor_id: str
|
|
13
|
-
|
|
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
|
|
17
|
-
ip: Optional[str] = None
|
|
18
|
-
user_agent: Optional[str] = None
|
|
19
19
|
|
|
20
20
|
def enrich_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]:
|
|
21
21
|
"""Add url and referrer to properties if not already present."""
|
|
@@ -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,
|
|
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
|
-
|
|
25
|
-
|
|
21
|
+
ip = _get_client_ip()
|
|
22
|
+
user_agent = _get_user_agent()
|
|
26
23
|
|
|
27
|
-
_set_request_context(visitor_id,
|
|
28
|
-
_store_in_g(visitor_id
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,8 +114,6 @@ class TestTrack:
|
|
|
112
114
|
result = track(event_type="page_view", visitor_id="vid_123")
|
|
113
115
|
assert result.success is False
|
|
114
116
|
|
|
115
|
-
# Server-side session resolution tests (v0.2.0+)
|
|
116
|
-
|
|
117
117
|
@patch("mbuzz.client.track.post_with_response")
|
|
118
118
|
def test_accepts_ip_parameter(self, mock_post):
|
|
119
119
|
"""Should accept and forward ip parameter."""
|
|
@@ -170,9 +170,8 @@ class TestTrack:
|
|
|
170
170
|
mock_post.return_value = {"events": [{"id": "evt_123"}]}
|
|
171
171
|
set_context(RequestContext(
|
|
172
172
|
visitor_id="ctx_vid",
|
|
173
|
-
session_id="ctx_sid",
|
|
174
173
|
ip="203.0.113.50",
|
|
175
|
-
user_agent="Safari/17.0"
|
|
174
|
+
user_agent="Safari/17.0",
|
|
176
175
|
))
|
|
177
176
|
|
|
178
177
|
result = track(event_type="page_view")
|
|
@@ -189,9 +188,8 @@ class TestTrack:
|
|
|
189
188
|
mock_post.return_value = {"events": [{"id": "evt_123"}]}
|
|
190
189
|
set_context(RequestContext(
|
|
191
190
|
visitor_id="ctx_vid",
|
|
192
|
-
session_id="ctx_sid",
|
|
193
191
|
ip="context_ip",
|
|
194
|
-
user_agent="context_ua"
|
|
192
|
+
user_agent="context_ua",
|
|
195
193
|
))
|
|
196
194
|
|
|
197
195
|
result = track(
|
|
@@ -206,6 +204,22 @@ class TestTrack:
|
|
|
206
204
|
assert payload["events"][0]["ip"] == "explicit_ip"
|
|
207
205
|
assert payload["events"][0]["user_agent"] == "explicit_ua"
|
|
208
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
|
+
|
|
209
223
|
|
|
210
224
|
class TestIdentify:
|
|
211
225
|
"""Test identify function."""
|
|
@@ -241,7 +255,11 @@ class TestIdentify:
|
|
|
241
255
|
def test_uses_context_visitor_id(self, mock_post):
|
|
242
256
|
"""Should use visitor_id from context."""
|
|
243
257
|
mock_post.return_value = True
|
|
244
|
-
set_context(RequestContext(
|
|
258
|
+
set_context(RequestContext(
|
|
259
|
+
visitor_id="ctx_vid",
|
|
260
|
+
ip="192.168.1.1",
|
|
261
|
+
user_agent="Mozilla/5.0",
|
|
262
|
+
))
|
|
245
263
|
|
|
246
264
|
identify(user_id="user_123")
|
|
247
265
|
|
|
@@ -312,7 +330,11 @@ class TestConversion:
|
|
|
312
330
|
def test_uses_context_visitor_id(self, mock_post):
|
|
313
331
|
"""Should use visitor_id from context."""
|
|
314
332
|
mock_post.return_value = {"conversion": {"id": "conv_123"}}
|
|
315
|
-
set_context(RequestContext(
|
|
333
|
+
set_context(RequestContext(
|
|
334
|
+
visitor_id="ctx_vid",
|
|
335
|
+
ip="192.168.1.1",
|
|
336
|
+
user_agent="Mozilla/5.0",
|
|
337
|
+
))
|
|
316
338
|
|
|
317
339
|
result = conversion(conversion_type="purchase")
|
|
318
340
|
assert result.success is True
|
|
@@ -356,61 +378,36 @@ class TestConversion:
|
|
|
356
378
|
result = conversion(conversion_type="purchase", visitor_id="vid_123")
|
|
357
379
|
assert result.attribution == {"model": "linear", "sessions": []}
|
|
358
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"}}
|
|
359
385
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
def setup_method(self):
|
|
364
|
-
"""Set up before each test."""
|
|
365
|
-
config.reset()
|
|
366
|
-
config.init(api_key="sk_test_123", api_url="http://localhost:3000/api/v1")
|
|
367
|
-
|
|
368
|
-
def teardown_method(self):
|
|
369
|
-
"""Clean up after each test."""
|
|
370
|
-
config.reset()
|
|
371
|
-
|
|
372
|
-
@patch("mbuzz.client.session.post")
|
|
373
|
-
def test_returns_true_on_success(self, mock_post):
|
|
374
|
-
"""Should return True on success."""
|
|
375
|
-
mock_post.return_value = True
|
|
376
|
-
|
|
377
|
-
result = create_session(
|
|
386
|
+
result = conversion(
|
|
387
|
+
conversion_type="purchase",
|
|
378
388
|
visitor_id="vid_123",
|
|
379
|
-
|
|
380
|
-
|
|
389
|
+
ip="192.168.1.100",
|
|
390
|
+
user_agent="Mozilla/5.0",
|
|
381
391
|
)
|
|
382
|
-
assert result is True
|
|
392
|
+
assert result.success is True
|
|
383
393
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
""
|
|
387
|
-
|
|
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"
|
|
388
398
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
referrer="https://google.com",
|
|
394
|
-
)
|
|
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"}}
|
|
395
403
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
payload = call_args[0][1]
|
|
399
|
-
session = payload["session"]
|
|
400
|
-
assert session["visitor_id"] == "vid_123"
|
|
401
|
-
assert session["session_id"] == "sid_456"
|
|
402
|
-
assert session["url"] == "https://example.com/page"
|
|
403
|
-
assert session["referrer"] == "https://google.com"
|
|
404
|
-
assert "started_at" in session
|
|
405
|
-
|
|
406
|
-
@patch("mbuzz.client.session.post")
|
|
407
|
-
def test_returns_false_on_api_error(self, mock_post):
|
|
408
|
-
"""Should return False on API error."""
|
|
409
|
-
mock_post.return_value = False
|
|
410
|
-
|
|
411
|
-
result = create_session(
|
|
404
|
+
result = conversion(
|
|
405
|
+
conversion_type="purchase",
|
|
412
406
|
visitor_id="vid_123",
|
|
413
|
-
|
|
414
|
-
url="https://example.com",
|
|
407
|
+
identifier={"email": "test@example.com"}
|
|
415
408
|
)
|
|
416
|
-
assert result is
|
|
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
|
|
12
|
-
ctx = RequestContext(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
124
|
-
|
|
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,
|
|
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
|
|
104
|
-
"""Should
|
|
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
|
-
|
|
113
|
-
|
|
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["
|
|
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 "
|
|
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
|
|
343
|
-
"""Should
|
|
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
|
|
280
|
+
@self.app.route("/check-ip")
|
|
281
|
+
def check_ip():
|
|
351
282
|
ctx = get_context()
|
|
352
283
|
if ctx:
|
|
353
|
-
captured["
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|