Skip to content

Commit 6c81282

Browse files
Allow2CEOruvnet
andcommitted
Add tests
39 tests covering voice code (generate, verify, decode, cross-secret), CheckResult (from_api_response, day types, activity lookup), PermissionChecker (normalize_activities 3 formats, cache key), MemoryCache (TTL, expiry, overwrite). MockHttpClient for test isolation. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 390cfef commit 6c81282

File tree

6 files changed

+624
-0
lines changed

6 files changed

+624
-0
lines changed

tests/__init__.py

Whitespace-only changes.

tests/mock_http_client.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Mock HTTP client for testing."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any, Dict, List, Optional, Tuple
7+
8+
from allow2_service.http_response import HttpResponse
9+
10+
11+
class MockHttpClient:
12+
"""Mock HTTP client that returns pre-configured responses.
13+
14+
Records all requests for later assertions and returns queued responses.
15+
"""
16+
17+
def __init__(self) -> None:
18+
self.requests: List[Dict[str, Any]] = []
19+
self._responses: List[HttpResponse] = []
20+
21+
def queue_response(
22+
self,
23+
status_code: int = 200,
24+
body: Optional[Dict[str, Any]] = None,
25+
) -> None:
26+
"""Queue a response to be returned by the next request.
27+
28+
Args:
29+
status_code: HTTP status code.
30+
body: Response body dictionary (JSON-encoded).
31+
"""
32+
self._responses.append(HttpResponse(
33+
status_code=status_code,
34+
body=json.dumps(body) if body is not None else "{}",
35+
))
36+
37+
def post(
38+
self,
39+
url: str,
40+
data: Optional[Dict[str, Any]] = None,
41+
headers: Optional[Dict[str, str]] = None,
42+
) -> HttpResponse:
43+
"""Send a mock POST request."""
44+
self.requests.append({
45+
"method": "POST",
46+
"url": url,
47+
"data": data or {},
48+
"headers": headers or {},
49+
})
50+
return self._next_response()
51+
52+
def get(
53+
self,
54+
url: str,
55+
headers: Optional[Dict[str, str]] = None,
56+
) -> HttpResponse:
57+
"""Send a mock GET request."""
58+
self.requests.append({
59+
"method": "GET",
60+
"url": url,
61+
"data": {},
62+
"headers": headers or {},
63+
})
64+
return self._next_response()
65+
66+
def _next_response(self) -> HttpResponse:
67+
"""Return the next queued response, or a default 200 OK."""
68+
if self._responses:
69+
return self._responses.pop(0)
70+
return HttpResponse(status_code=200, body="{}")
71+
72+
@property
73+
def last_request(self) -> Optional[Dict[str, Any]]:
74+
"""Return the most recent request, if any."""
75+
if self.requests:
76+
return self.requests[-1]
77+
return None

tests/test_check_result.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Tests for CheckResult model."""
2+
3+
from __future__ import annotations
4+
5+
from allow2_service.models import CheckResult
6+
7+
8+
def test_from_api_response_basic() -> None:
9+
response = {
10+
"allowed": True,
11+
"activities": [
12+
{"id": 1, "activity": "Internet", "allowed": True, "remaining": 3600, "banned": False},
13+
{"id": 3, "activity": "Gaming", "allowed": False, "remaining": 0, "banned": True},
14+
],
15+
"dayType": {"id": 1, "name": "School Day"},
16+
}
17+
18+
result = CheckResult.from_api_response(response)
19+
20+
# Server says allowed=True, so it should be True
21+
assert result.allowed is True
22+
assert len(result.activities) == 2
23+
24+
25+
def test_from_api_response_all_activities_blocked() -> None:
26+
response = {
27+
"activities": [
28+
{"id": 1, "activity": "Internet", "allowed": False, "remaining": 0},
29+
{"id": 3, "activity": "Gaming", "allowed": False, "remaining": 0},
30+
],
31+
}
32+
33+
result = CheckResult.from_api_response(response)
34+
35+
# No explicit "allowed" key; should be derived from activities
36+
assert result.allowed is False
37+
38+
39+
def test_from_api_response_all_activities_allowed() -> None:
40+
response = {
41+
"activities": [
42+
{"id": 1, "activity": "Internet", "allowed": True, "remaining": 3600},
43+
{"id": 3, "activity": "Gaming", "allowed": True, "remaining": 1800},
44+
],
45+
}
46+
47+
result = CheckResult.from_api_response(response)
48+
49+
assert result.allowed is True
50+
51+
52+
def test_from_api_response_explicit_allowed_overrides() -> None:
53+
response = {
54+
"allowed": False,
55+
"activities": [
56+
{"id": 1, "activity": "Internet", "allowed": True, "remaining": 3600},
57+
],
58+
}
59+
60+
result = CheckResult.from_api_response(response)
61+
62+
# Server says allowed=False even though activity is allowed
63+
assert result.allowed is False
64+
65+
66+
def test_get_activity_returns_matching() -> None:
67+
response = {
68+
"activities": [
69+
{"id": 1, "activity": "Internet", "allowed": True, "remaining": 3600},
70+
{"id": 3, "activity": "Gaming", "allowed": False, "remaining": 0},
71+
],
72+
}
73+
74+
result = CheckResult.from_api_response(response)
75+
76+
activity = result.get_activity(3)
77+
assert activity is not None
78+
assert activity.id == 3
79+
assert activity.name == "Gaming"
80+
assert activity.allowed is False
81+
82+
83+
def test_get_activity_returns_none_for_missing() -> None:
84+
response = {
85+
"activities": [
86+
{"id": 1, "activity": "Internet", "allowed": True, "remaining": 3600},
87+
],
88+
}
89+
90+
result = CheckResult.from_api_response(response)
91+
92+
assert result.get_activity(99) is None
93+
94+
95+
def test_is_activity_allowed() -> None:
96+
response = {
97+
"activities": [
98+
{"id": 1, "activity": "Internet", "allowed": True, "remaining": 3600},
99+
{"id": 3, "activity": "Gaming", "allowed": False, "remaining": 0},
100+
],
101+
}
102+
103+
result = CheckResult.from_api_response(response)
104+
105+
assert result.is_activity_allowed(1) is True
106+
assert result.is_activity_allowed(3) is False
107+
assert result.is_activity_allowed(99) is False
108+
109+
110+
def test_get_remaining_seconds() -> None:
111+
response = {
112+
"activities": [
113+
{"id": 1, "activity": "Internet", "allowed": True, "remaining": 3600},
114+
],
115+
}
116+
117+
result = CheckResult.from_api_response(response)
118+
119+
assert result.get_remaining_seconds(1) == 3600
120+
assert result.get_remaining_seconds(99) == 0
121+
122+
123+
def test_from_api_response_day_types() -> None:
124+
response = {
125+
"activities": [],
126+
"dayType": {"id": 1, "name": "School Day"},
127+
"tomorrowDayType": {"id": 2, "name": "Weekend"},
128+
}
129+
130+
result = CheckResult.from_api_response(response)
131+
132+
assert result.today_day_type is not None
133+
assert result.today_day_type.id == 1
134+
assert result.today_day_type.name == "School Day"
135+
assert result.tomorrow_day_type is not None
136+
assert result.tomorrow_day_type.id == 2
137+
assert result.tomorrow_day_type.name == "Weekend"
138+
139+
140+
def test_from_api_response_alternate_day_type_keys() -> None:
141+
response = {
142+
"activities": [],
143+
"today": {"id": 3, "name": "Holiday"},
144+
"tomorrow": {"id": 1, "name": "School Day"},
145+
}
146+
147+
result = CheckResult.from_api_response(response)
148+
149+
assert result.today_day_type is not None
150+
assert result.today_day_type.id == 3
151+
assert result.tomorrow_day_type is not None
152+
assert result.tomorrow_day_type.id == 1
153+
154+
155+
def test_from_api_response_raw_preserved() -> None:
156+
response = {
157+
"allowed": True,
158+
"activities": [],
159+
"custom_field": "custom_value",
160+
}
161+
162+
result = CheckResult.from_api_response(response)
163+
164+
assert result.raw == response
165+
assert result.raw["custom_field"] == "custom_value"

tests/test_memory_cache.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Tests for MemoryCache TTL behavior."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from unittest.mock import patch
7+
8+
from allow2_service.cache import MemoryCache
9+
10+
11+
def test_set_and_get() -> None:
12+
cache = MemoryCache()
13+
cache.set("key1", "value1", ttl=60)
14+
assert cache.get("key1") == "value1"
15+
16+
17+
def test_get_returns_none_for_missing() -> None:
18+
cache = MemoryCache()
19+
assert cache.get("nonexistent") is None
20+
21+
22+
def test_delete() -> None:
23+
cache = MemoryCache()
24+
cache.set("key1", "value1")
25+
cache.delete("key1")
26+
assert cache.get("key1") is None
27+
28+
29+
def test_delete_nonexistent_does_not_raise() -> None:
30+
cache = MemoryCache()
31+
cache.delete("nonexistent") # should not raise
32+
33+
34+
def test_expired_entry_returns_none() -> None:
35+
cache = MemoryCache()
36+
37+
# Store with TTL 1 second
38+
cache.set("key1", "value1", ttl=1)
39+
40+
# Verify it's there initially
41+
assert cache.get("key1") == "value1"
42+
43+
# Mock time to be in the future
44+
with patch("allow2_service.cache.memory_cache.time") as mock_time:
45+
# First call is for the get check
46+
mock_time.time.return_value = time.time() + 10
47+
assert cache.get("key1") is None
48+
49+
50+
def test_overwrite_existing_key() -> None:
51+
cache = MemoryCache()
52+
cache.set("key1", "value1")
53+
cache.set("key1", "value2")
54+
assert cache.get("key1") == "value2"
55+
56+
57+
def test_different_ttls() -> None:
58+
cache = MemoryCache()
59+
cache.set("short", "short-value", ttl=1)
60+
cache.set("long", "long-value", ttl=3600)
61+
62+
with patch("allow2_service.cache.memory_cache.time") as mock_time:
63+
mock_time.time.return_value = time.time() + 10
64+
assert cache.get("short") is None
65+
assert cache.get("long") == "long-value"

tests/test_permission_checker.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for PermissionChecker, focusing on activity normalization."""
2+
3+
from __future__ import annotations
4+
5+
from allow2_service.checker import PermissionChecker
6+
7+
8+
def test_normalize_activities_list_of_dicts() -> None:
9+
"""Format 1: list of dicts [{"id": 1, "log": True}, ...] -- pass through."""
10+
activities = [{"id": 1, "log": True}, {"id": 3, "log": False}]
11+
result = PermissionChecker._normalize_activities(activities)
12+
assert result == [{"id": 1, "log": True}, {"id": 3, "log": False}]
13+
14+
15+
def test_normalize_activities_simple_list() -> None:
16+
"""Format 2: simple list of IDs [1, 3, 8] -- expand with log=True."""
17+
activities = [1, 3, 8]
18+
result = PermissionChecker._normalize_activities(activities)
19+
assert result == [
20+
{"id": 1, "log": True},
21+
{"id": 3, "log": True},
22+
{"id": 8, "log": True},
23+
]
24+
25+
26+
def test_normalize_activities_legacy_dict() -> None:
27+
"""Format 3: legacy dict {1: 1, 3: 1} -- convert."""
28+
activities = {1: 1, 3: 1, 8: 0}
29+
result = PermissionChecker._normalize_activities(activities)
30+
assert len(result) == 3
31+
32+
# Check all are converted properly
33+
ids = {a["id"] for a in result}
34+
assert ids == {1, 3, 8}
35+
36+
# Check log values
37+
for a in result:
38+
if a["id"] == 8:
39+
assert a["log"] is False
40+
else:
41+
assert a["log"] is True
42+
43+
44+
def test_normalize_activities_empty_list() -> None:
45+
result = PermissionChecker._normalize_activities([])
46+
assert result == []
47+
48+
49+
def test_normalize_activities_empty_dict() -> None:
50+
result = PermissionChecker._normalize_activities({})
51+
assert result == []
52+
53+
54+
def test_build_cache_key_deterministic() -> None:
55+
"""Cache key should be the same regardless of activity order."""
56+
activities1 = [{"id": 3, "log": True}, {"id": 1, "log": True}]
57+
activities2 = [{"id": 1, "log": True}, {"id": 3, "log": True}]
58+
59+
key1 = PermissionChecker._build_cache_key("user-1", activities1)
60+
key2 = PermissionChecker._build_cache_key("user-1", activities2)
61+
62+
assert key1 == key2
63+
assert "allow2_check_user-1_1_3" == key1
64+
65+
66+
def test_build_cache_key_different_users() -> None:
67+
activities = [{"id": 1, "log": True}]
68+
69+
key1 = PermissionChecker._build_cache_key("user-1", activities)
70+
key2 = PermissionChecker._build_cache_key("user-2", activities)
71+
72+
assert key1 != key2

0 commit comments

Comments
 (0)