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.
- {mbuzz-0.1.1 → mbuzz-0.7.0}/PKG-INFO +1 -1
- {mbuzz-0.1.1 → mbuzz-0.7.0}/pyproject.toml +1 -1
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/__init__.py +2 -8
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/conversion.py +26 -5
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/track.py +35 -19
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/context.py +3 -1
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/cookies.py +1 -3
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/middleware/flask.py +11 -55
- {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_client.py +148 -57
- {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_context.py +50 -15
- {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_middleware.py +37 -87
- mbuzz-0.1.1/src/mbuzz/client/session.py +0 -38
- mbuzz-0.1.1/src/mbuzz/utils/session_id.py +0 -39
- mbuzz-0.1.1/tests/test_session_id.py +0 -146
- {mbuzz-0.1.1 → mbuzz-0.7.0}/.gitignore +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/README.md +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/api.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/__init__.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/client/identify.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/config.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/middleware/__init__.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/utils/__init__.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/src/mbuzz/utils/identifier.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/__init__.py +0 -0
- {mbuzz-0.1.1 → mbuzz-0.7.0}/tests/test_api.py +0 -0
- {mbuzz-0.1.1 → 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,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/
|
|
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
|
-
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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,
|
|
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,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(
|
|
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(
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
""
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
320
|
-
url="https://example.com",
|
|
407
|
+
identifier={"email": "test@example.com"}
|
|
321
408
|
)
|
|
322
|
-
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
|