roksta 0.2.7__cp311-cp311-win_amd64.whl → 0.3.2__cp311-cp311-win_amd64.whl
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.
Potentially problematic release.
This version of roksta might be problematic. Click here for more details.
- roksta/__init__.cp311-win_amd64.pyd +0 -0
- roksta/ai/__init__.cp311-win_amd64.pyd +0 -0
- roksta/ai/call_ai.cp311-win_amd64.pyd +0 -0
- roksta/ai/gemini.cp311-win_amd64.pyd +0 -0
- roksta/ai/generic.cp311-win_amd64.pyd +0 -0
- roksta/ai/llm.cp311-win_amd64.pyd +0 -0
- roksta/ai/openai.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/__init__.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/delete_file.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/edit_file.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/final_response.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/get_file_summaries.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/read_file.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/regex_replace.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/shell_any.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/shell_limited.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/tool_defs.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/tool_utils.cp311-win_amd64.pyd +0 -0
- roksta/ai/tools/write_file.cp311-win_amd64.pyd +0 -0
- roksta/analytics.cp311-win_amd64.pyd +0 -0
- roksta/balance.cp311-win_amd64.pyd +0 -0
- roksta/build_project.cp311-win_amd64.pyd +0 -0
- roksta/chat_workflow.cp311-win_amd64.pyd +0 -0
- roksta/check_for_updates.cp311-win_amd64.pyd +0 -0
- roksta/checkpoints.cp311-win_amd64.pyd +0 -0
- roksta/clarify_goal.cp311-win_amd64.pyd +0 -0
- roksta/codebase_listing.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/__init__.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_activate_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_add_funds_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_auto_charge_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_auto_commit_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_building_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_chat_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_dev_rate_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_feedback_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_goal_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_help_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_init_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_linting_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_login_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_logout_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_payment_details_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_quit_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_redeem_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_request_activation_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_testing_command.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers/handle_usage_command.cp311-win_amd64.pyd +0 -0
- roksta/create_default_config.cp311-win_amd64.pyd +0 -0
- roksta/default_config.cp311-win_amd64.pyd +0 -0
- roksta/enums.cp311-win_amd64.pyd +0 -0
- roksta/env.cp311-win_amd64.pyd +0 -0
- roksta/extended_text_area.cp311-win_amd64.pyd +0 -0
- roksta/firebase.cp311-win_amd64.pyd +0 -0
- roksta/firebase_auth_web.cp311-win_amd64.pyd +0 -0
- roksta/firebase_config.cp311-win_amd64.pyd +0 -0
- roksta/fix_tests.cp311-win_amd64.pyd +0 -0
- roksta/gen_codebase_summaries.cp311-win_amd64.pyd +0 -0
- roksta/gen_one_line_goal.cp311-win_amd64.pyd +0 -0
- roksta/get_codebase_structure.cp311-win_amd64.pyd +0 -0
- roksta/get_failing_tests.cp311-win_amd64.pyd +0 -0
- roksta/goal_workflow.cp311-win_amd64.pyd +0 -0
- roksta/init_codebase.cp311-win_amd64.pyd +0 -0
- roksta/lint_code.cp311-win_amd64.pyd +0 -0
- roksta/logger.cp311-win_amd64.pyd +0 -0
- roksta/main.cp311-win_amd64.pyd +0 -0
- roksta/make_issue.cp311-win_amd64.pyd +0 -0
- roksta/new_features.cp311-win_amd64.pyd +0 -0
- roksta/parse_readme.cp311-win_amd64.pyd +0 -0
- roksta/propose_solution.cp311-win_amd64.pyd +0 -0
- roksta/response_formats.cp311-win_amd64.pyd +0 -0
- roksta/rewrite_goal.cp311-win_amd64.pyd +0 -0
- roksta/roksta.cp311-win_amd64.pyd +0 -0
- roksta/run_cli_goal.cp311-win_amd64.pyd +0 -0
- roksta/select_files.cp311-win_amd64.pyd +0 -0
- roksta/tips.cp311-win_amd64.pyd +0 -0
- roksta/utils.cp311-win_amd64.pyd +0 -0
- roksta/write_code.cp311-win_amd64.pyd +0 -0
- {roksta-0.2.7.dist-info → roksta-0.3.2.dist-info}/METADATA +2 -1
- roksta-0.3.2.dist-info/RECORD +121 -0
- tests/conftest.py +42 -0
- tests/functions/{api_v0_01 → api_v1_01}/__init__.py +1 -1
- tests/functions/{api_v0_01 → api_v1_01}/test__analytics.py +2 -3
- tests/functions/{api_v0_01 → api_v1_01}/test__gemini_proxy.py +51 -6
- tests/functions/{api_v0_01 → api_v1_01}/test__generic_proxy.py +31 -2
- tests/functions/{api_v0_01 → api_v1_01}/test__get_payment_details.py +2 -2
- tests/functions/{api_v0_01 → api_v1_01}/test__openai_proxy.py +50 -14
- tests/functions/{api_v0_01 → api_v1_01}/test__redeem_credit_code.py +2 -2
- tests/functions/{api_v0_01 → api_v1_01}/test__sync_emails.py +3 -2
- tests/functions/{api_v0_01 → api_v1_01}/test__take_payment.py +2 -2
- tests/functions/{api_v0_01 → api_v1_01}/test__use_activation_code.py +3 -2
- tests/functions/api_v1_02/__init__.py +2 -0
- tests/functions/api_v1_02/test__analytics.py +416 -0
- tests/functions/api_v1_02/test__gemini_proxy.py +352 -0
- tests/functions/api_v1_02/test__generic_proxy.py +428 -0
- tests/functions/api_v1_02/test__get_payment_details.py +356 -0
- tests/functions/api_v1_02/test__openai_proxy.py +449 -0
- tests/functions/api_v1_02/test__redeem_credit_code.py +167 -0
- tests/functions/api_v1_02/test__sync_emails.py +325 -0
- tests/functions/api_v1_02/test__take_payment.py +491 -0
- tests/functions/api_v1_02/test__use_activation_code.py +438 -0
- tests/functions/api_v1_02/test_proxy_keyword_replacement.py +557 -0
- tests/functions/api_v1_02/test_replace_keywords.py +74 -0
- tests/functions/test_utils.py +484 -0
- roksta/ai/tools.cp311-win_amd64.pyd +0 -0
- roksta/command_handlers.cp311-win_amd64.pyd +0 -0
- roksta-0.2.7.dist-info/RECORD +0 -78
- tests/functions/test_utils_functions.py +0 -222
- {roksta-0.2.7.dist-info → roksta-0.3.2.dist-info}/WHEEL +0 -0
- {roksta-0.2.7.dist-info → roksta-0.3.2.dist-info}/entry_points.txt +0 -0
- {roksta-0.2.7.dist-info → roksta-0.3.2.dist-info}/top_level.txt +0 -0
- /tests/functions/{test_main_functions.py → test_main.py} +0 -0
|
@@ -7,7 +7,8 @@ from unittest.mock import patch
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
# Ensure the functions/ directory is importable as a top-level module location
|
|
10
|
-
|
|
10
|
+
# Project root is three levels up from this test file (tests/functions/api_v1_00/...)
|
|
11
|
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
|
11
12
|
FUNCTIONS_DIR = os.path.join(PROJECT_ROOT, 'functions')
|
|
12
13
|
if FUNCTIONS_DIR not in sys.path:
|
|
13
14
|
sys.path.insert(0, FUNCTIONS_DIR)
|
|
@@ -22,6 +23,8 @@ _names_to_fake = [
|
|
|
22
23
|
'utils',
|
|
23
24
|
'auth',
|
|
24
25
|
'openai',
|
|
26
|
+
'firebase_admin',
|
|
27
|
+
'billing',
|
|
25
28
|
]
|
|
26
29
|
for name in _names_to_fake:
|
|
27
30
|
_orig_sys_modules[name] = sys.modules.get(name)
|
|
@@ -82,12 +85,7 @@ auth_mod.validate_auth_key = _fake_validate_auth_key
|
|
|
82
85
|
sys.modules['auth'] = auth_mod
|
|
83
86
|
|
|
84
87
|
# Fake openai module with APIError and a default OpenAI class
|
|
85
|
-
|
|
86
|
-
functions_root = os.path.join(repo_root, 'functions')
|
|
87
|
-
module_path = os.path.join(functions_root, 'api_v0_01', '_openai_proxy.py')
|
|
88
|
-
spec = importlib.util.spec_from_file_location('api_v0_01._openai_proxy', module_path)
|
|
89
|
-
_openai = importlib.util.module_from_spec(spec)
|
|
90
|
-
spec.loader.exec_module(_openai)
|
|
88
|
+
openai_mod = types.ModuleType('openai')
|
|
91
89
|
|
|
92
90
|
class APIError(Exception):
|
|
93
91
|
pass
|
|
@@ -107,12 +105,42 @@ class DummyOpenAI:
|
|
|
107
105
|
raise NotImplementedError("parse not implemented for dummy client")
|
|
108
106
|
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
sys.modules['openai'] =
|
|
108
|
+
openai_mod.OpenAI = DummyOpenAI
|
|
109
|
+
openai_mod.APIError = APIError
|
|
110
|
+
sys.modules['openai'] = openai_mod
|
|
111
|
+
|
|
112
|
+
# Fake billing module to satisfy imports in v1_00 _openai_proxy (billing helpers)
|
|
113
|
+
billing_mod = types.ModuleType('billing')
|
|
114
|
+
|
|
115
|
+
def _fake_ensure_balance_positive(db, user_id):
|
|
116
|
+
return (True, 100.0)
|
|
117
|
+
|
|
118
|
+
def _fake_calculate_cost(model_id, input_tokens, output_tokens):
|
|
119
|
+
return 0.0
|
|
120
|
+
|
|
121
|
+
def _fake_bill_with_retry(db, user_id, model_id, usage, cost, reason='usage'):
|
|
122
|
+
return ("ok", 100.0)
|
|
123
|
+
|
|
124
|
+
billing_mod.ensure_balance_positive = _fake_ensure_balance_positive
|
|
125
|
+
billing_mod.calculate_cost = _fake_calculate_cost
|
|
126
|
+
billing_mod.bill_with_retry = _fake_bill_with_retry
|
|
127
|
+
sys.modules['billing'] = billing_mod
|
|
128
|
+
|
|
129
|
+
# Fake firebase_admin module providing a minimal firestore.client to avoid
|
|
130
|
+
# attempting to initialize the real Firebase SDK during tests
|
|
131
|
+
firebase_admin_mod = types.ModuleType('firebase_admin')
|
|
132
|
+
firebase_admin_mod.firestore = types.SimpleNamespace(client=lambda: types.SimpleNamespace(), Client=type('Client', (), {}))
|
|
133
|
+
sys.modules['firebase_admin'] = firebase_admin_mod
|
|
113
134
|
|
|
114
135
|
# Import the module under test after preparing the fake imports
|
|
115
|
-
|
|
136
|
+
# Use a package-qualified import to ensure we always import the v1_00 implementation
|
|
137
|
+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
|
138
|
+
functions_root = os.path.join(repo_root, 'functions')
|
|
139
|
+
module_path = os.path.join(functions_root, 'api_v1_00', '_openai_proxy.py')
|
|
140
|
+
spec = importlib.util.spec_from_file_location('api_v1_00._openai_proxy', module_path)
|
|
141
|
+
_openai = importlib.util.module_from_spec(spec)
|
|
142
|
+
spec.loader.exec_module(_openai)
|
|
143
|
+
|
|
116
144
|
|
|
117
145
|
# Restore original sys.modules mappings to avoid side-effects for other tests
|
|
118
146
|
for name, orig in _orig_sys_modules.items():
|
|
@@ -317,12 +345,15 @@ def test_successful_create_calls_openai_and_returns_payload():
|
|
|
317
345
|
def create(self, **params):
|
|
318
346
|
class FakeResp:
|
|
319
347
|
def model_dump(self_inner, mode='json'):
|
|
320
|
-
return {'result': 'ok', 'received_model': params.get('model')}
|
|
348
|
+
return {'result': 'ok', 'received_model': params.get('model'), 'usage': {'input_tokens': 1, 'output_tokens': 2}}
|
|
321
349
|
return FakeResp()
|
|
322
350
|
|
|
323
351
|
with patch.object(_openai, 'validate_auth_key', return_value=True), \
|
|
324
352
|
patch.object(_openai, 'verify_firebase_token', return_value={}), \
|
|
325
353
|
patch.object(_openai, 'get_api_key', return_value='OPENAI-KEY'), \
|
|
354
|
+
patch.object(_openai, 'ensure_balance_positive', return_value=(True, 100.0)), \
|
|
355
|
+
patch.object(_openai, 'bill_with_retry', return_value=("ok", 95.0)), \
|
|
356
|
+
patch.object(_openai.firestore, 'client', return_value=types.SimpleNamespace()), \
|
|
326
357
|
patch.object(_openai.openai, 'OpenAI', FakeClient):
|
|
327
358
|
resp = _openai._openai_proxy(req)
|
|
328
359
|
|
|
@@ -364,12 +395,15 @@ def test_successful_parse_calls_openai_and_returns_parsed_and_usage():
|
|
|
364
395
|
# detect whether text_format was left as a string
|
|
365
396
|
is_text_format_string = isinstance(params.get('text_format'), str)
|
|
366
397
|
parsed = {'result': 'ok', 'received_model': params.get('model'), 'is_text_format_string': is_text_format_string}
|
|
367
|
-
usage = {'prompt_tokens': 5}
|
|
398
|
+
usage = {'input_tokens': 1, 'output_tokens': 2, 'prompt_tokens': 5}
|
|
368
399
|
return FakeResp(parsed, usage)
|
|
369
400
|
|
|
370
401
|
with patch.object(_openai, 'validate_auth_key', return_value=True), \
|
|
371
402
|
patch.object(_openai, 'verify_firebase_token', return_value={}), \
|
|
372
403
|
patch.object(_openai, 'get_api_key', return_value='OPENAI-KEY'), \
|
|
404
|
+
patch.object(_openai, 'ensure_balance_positive', return_value=(True, 100.0)), \
|
|
405
|
+
patch.object(_openai, 'bill_with_retry', return_value=("ok", 95.0)), \
|
|
406
|
+
patch.object(_openai.firestore, 'client', return_value=types.SimpleNamespace()), \
|
|
373
407
|
patch.object(_openai.openai, 'OpenAI', FakeClient2):
|
|
374
408
|
resp = _openai._openai_proxy(req)
|
|
375
409
|
|
|
@@ -380,7 +414,7 @@ def test_successful_parse_calls_openai_and_returns_parsed_and_usage():
|
|
|
380
414
|
assert payload['payload']['output_parsed']['received_model'] == 'gem-model'
|
|
381
415
|
# The proxy should have replaced the text_format string with the model (i.e., not a string)
|
|
382
416
|
assert payload['payload']['output_parsed']['is_text_format_string'] is False
|
|
383
|
-
assert payload['payload']['usage']
|
|
417
|
+
assert payload['payload']['usage']['prompt_tokens'] == 5
|
|
384
418
|
|
|
385
419
|
|
|
386
420
|
def test_openai_api_error_with_status_code_is_returned_as_error_status():
|
|
@@ -404,6 +438,8 @@ def test_openai_api_error_with_status_code_is_returned_as_error_status():
|
|
|
404
438
|
with patch.object(_openai, 'validate_auth_key', return_value=True), \
|
|
405
439
|
patch.object(_openai, 'verify_firebase_token', return_value={}), \
|
|
406
440
|
patch.object(_openai, 'get_api_key', return_value='OPENAI-KEY'), \
|
|
441
|
+
patch.object(_openai, 'ensure_balance_positive', return_value=(True, 100.0)), \
|
|
442
|
+
patch.object(_openai.firestore, 'client', return_value=types.SimpleNamespace()), \
|
|
407
443
|
patch.object(_openai.openai, 'OpenAI', ErrClient):
|
|
408
444
|
resp = _openai._openai_proxy(req)
|
|
409
445
|
|
|
@@ -92,8 +92,8 @@ sys.modules['firebase_admin'] = firebase_admin
|
|
|
92
92
|
# -----------------------------
|
|
93
93
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
|
94
94
|
functions_root = os.path.join(repo_root, 'functions')
|
|
95
|
-
module_path = os.path.join(functions_root, '
|
|
96
|
-
spec = importlib.util.spec_from_file_location('
|
|
95
|
+
module_path = os.path.join(functions_root, 'api_v1_00', '_redeem_credit_code.py')
|
|
96
|
+
spec = importlib.util.spec_from_file_location('api_v1_00._redeem_credit_code', module_path)
|
|
97
97
|
_redeem = importlib.util.module_from_spec(spec)
|
|
98
98
|
spec.loader.exec_module(_redeem)
|
|
99
99
|
|
|
@@ -90,11 +90,12 @@ sys.modules['firebase_admin'] = firebase_admin
|
|
|
90
90
|
# -----------------------------
|
|
91
91
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
|
92
92
|
functions_root = os.path.join(repo_root, 'functions')
|
|
93
|
-
module_path = os.path.join(functions_root, '
|
|
94
|
-
spec = importlib.util.spec_from_file_location('
|
|
93
|
+
module_path = os.path.join(functions_root, 'api_v1_00', '_sync_emails.py')
|
|
94
|
+
spec = importlib.util.spec_from_file_location('api_v1_00._sync_emails', module_path)
|
|
95
95
|
_sync = importlib.util.module_from_spec(spec)
|
|
96
96
|
spec.loader.exec_module(_sync)
|
|
97
97
|
|
|
98
|
+
|
|
98
99
|
# Restore original sys.modules mappings to avoid side-effects for other tests
|
|
99
100
|
for name, orig in _orig_sys_modules.items():
|
|
100
101
|
if orig is None:
|
|
@@ -210,8 +210,8 @@ sys.modules['ulid'] = ulid_mod
|
|
|
210
210
|
# Import the module under test after preparing the fake imports
|
|
211
211
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
|
212
212
|
functions_root = os.path.join(repo_root, 'functions')
|
|
213
|
-
module_path = os.path.join(functions_root, '
|
|
214
|
-
spec = importlib.util.spec_from_file_location('
|
|
213
|
+
module_path = os.path.join(functions_root, 'api_v1_00', '_take_payment.py')
|
|
214
|
+
spec = importlib.util.spec_from_file_location('api_v1_00._take_payment', module_path)
|
|
215
215
|
_take_payment = importlib.util.module_from_spec(spec)
|
|
216
216
|
spec.loader.exec_module(_take_payment)
|
|
217
217
|
|
|
@@ -171,11 +171,12 @@ sys.modules['firebase_admin'] = firebase_admin_mod
|
|
|
171
171
|
# -----------------------------
|
|
172
172
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
|
173
173
|
functions_root = os.path.join(repo_root, 'functions')
|
|
174
|
-
module_path = os.path.join(functions_root, '
|
|
175
|
-
spec = importlib.util.spec_from_file_location('
|
|
174
|
+
module_path = os.path.join(functions_root, 'api_v1_00', '_use_activation_code.py')
|
|
175
|
+
spec = importlib.util.spec_from_file_location('api_v1_00._use_activation_code', module_path)
|
|
176
176
|
_use_activation = importlib.util.module_from_spec(spec)
|
|
177
177
|
spec.loader.exec_module(_use_activation)
|
|
178
178
|
|
|
179
|
+
|
|
179
180
|
# Restore original sys.modules mappings to avoid side-effects for other tests
|
|
180
181
|
for name, orig in _orig_sys_modules.items():
|
|
181
182
|
if orig is None:
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import types
|
|
5
|
+
import importlib
|
|
6
|
+
from unittest.mock import patch, Mock
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Ensure the functions/ directory is importable as a top-level module location
|
|
10
|
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
11
|
+
FUNCTIONS_DIR = os.path.join(PROJECT_ROOT, 'functions')
|
|
12
|
+
if FUNCTIONS_DIR not in sys.path:
|
|
13
|
+
sys.path.insert(0, FUNCTIONS_DIR)
|
|
14
|
+
|
|
15
|
+
# -----------------------------
|
|
16
|
+
# Provide lightweight fake modules to satisfy imports inside _analytics
|
|
17
|
+
# These are minimal and will be patched in individual tests as needed.
|
|
18
|
+
# -----------------------------
|
|
19
|
+
# Save any originals so we can restore them after import
|
|
20
|
+
_orig_sys_modules = {}
|
|
21
|
+
_names_to_fake = ['firebase_functions', 'utils', 'auth', 'httpx']
|
|
22
|
+
for name in _names_to_fake:
|
|
23
|
+
_orig_sys_modules[name] = sys.modules.get(name)
|
|
24
|
+
|
|
25
|
+
# Fake firebase_functions.https_fn.Response to capture returned data
|
|
26
|
+
firebase_functions = types.ModuleType('firebase_functions')
|
|
27
|
+
|
|
28
|
+
class FakeResponse:
|
|
29
|
+
def __init__(self, response=None, mimetype=None, status=200, **kwargs):
|
|
30
|
+
# Mirror the small subset of the interface tests expect
|
|
31
|
+
self.status_code = status
|
|
32
|
+
if isinstance(response, (dict, list)):
|
|
33
|
+
self._body_text = json.dumps(response)
|
|
34
|
+
else:
|
|
35
|
+
self._body_text = '' if response is None else response
|
|
36
|
+
self.headers = kwargs.get('headers', {})
|
|
37
|
+
|
|
38
|
+
def get_data(self, as_text=False):
|
|
39
|
+
if as_text:
|
|
40
|
+
return self._body_text
|
|
41
|
+
return self._body_text.encode('utf-8')
|
|
42
|
+
|
|
43
|
+
firebase_functions.https_fn = types.SimpleNamespace(Request=object, Response=FakeResponse)
|
|
44
|
+
sys.modules['firebase_functions'] = firebase_functions
|
|
45
|
+
|
|
46
|
+
# Fake utils module (avoid google.cloud import at module import time)
|
|
47
|
+
utils_mod = types.ModuleType('utils')
|
|
48
|
+
|
|
49
|
+
def _dummy_get_secret_key(name: str) -> str:
|
|
50
|
+
return 'DUMMY_SECRET'
|
|
51
|
+
|
|
52
|
+
# create_json_response should return a firebase_functions.https_fn.Response-like object
|
|
53
|
+
# with a JSON body containing at least 'success' and 'message' keys, and an HTTP status code.
|
|
54
|
+
def _dummy_create_json_response(success: bool, payload, status_code: int = 200, headers: dict | None = None):
|
|
55
|
+
body = {'success': success, 'message': payload}
|
|
56
|
+
return firebase_functions.https_fn.Response(response=body, status=status_code, headers=headers or {})
|
|
57
|
+
|
|
58
|
+
utils_mod.get_secret_key = _dummy_get_secret_key
|
|
59
|
+
utils_mod.create_json_response = _dummy_create_json_response
|
|
60
|
+
sys.modules['utils'] = utils_mod
|
|
61
|
+
|
|
62
|
+
# Fake auth module (validate_auth_key will be patched per-test as needed)
|
|
63
|
+
auth_mod = types.ModuleType('auth')
|
|
64
|
+
|
|
65
|
+
def _simple_validate_auth_key(val: str) -> bool:
|
|
66
|
+
return bool(val)
|
|
67
|
+
|
|
68
|
+
auth_mod.validate_auth_key = _simple_validate_auth_key
|
|
69
|
+
sys.modules['auth'] = auth_mod
|
|
70
|
+
|
|
71
|
+
# Fake httpx module to avoid external dependency; tests will patch Client behavior
|
|
72
|
+
httpx_mod = types.ModuleType('httpx')
|
|
73
|
+
|
|
74
|
+
class Request:
|
|
75
|
+
def __init__(self, method, url, headers=None, content=None):
|
|
76
|
+
self.method = method
|
|
77
|
+
self.url = url
|
|
78
|
+
self.headers = headers or {}
|
|
79
|
+
self.content = content
|
|
80
|
+
|
|
81
|
+
class _DummyResponse:
|
|
82
|
+
def __init__(self, status_code=200, text='', json_data=None, request=None, headers=None):
|
|
83
|
+
self.status_code = status_code
|
|
84
|
+
self.text = text if text is not None else ''
|
|
85
|
+
self._json = json_data
|
|
86
|
+
self.request = request
|
|
87
|
+
self.headers = headers or {}
|
|
88
|
+
# Provide a reason_phrase attribute similar to real httpx.Response
|
|
89
|
+
self.reason_phrase = self.text or ''
|
|
90
|
+
|
|
91
|
+
def json(self):
|
|
92
|
+
# Prefer explicit JSON payload if provided, otherwise try to parse text
|
|
93
|
+
if self._json is not None:
|
|
94
|
+
return self._json
|
|
95
|
+
try:
|
|
96
|
+
return json.loads(self.text) if self.text else {}
|
|
97
|
+
except Exception:
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
def raise_for_status(self):
|
|
101
|
+
if 400 <= self.status_code:
|
|
102
|
+
raise httpx_mod.HTTPStatusError(response=self)
|
|
103
|
+
|
|
104
|
+
class _DummyHttpxClient:
|
|
105
|
+
def __enter__(self):
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, exc_type, exc, tb):
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def post(self, url, json=None, timeout=30):
|
|
112
|
+
resp = _DummyResponse(status_code=200, text='', json_data=None)
|
|
113
|
+
resp.raise_for_status = Mock(return_value=None)
|
|
114
|
+
resp.json = Mock(return_value={})
|
|
115
|
+
return resp
|
|
116
|
+
|
|
117
|
+
class _DummyAsyncClient:
|
|
118
|
+
async def __aenter__(self):
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
async def get(self, url, headers=None, timeout=None):
|
|
125
|
+
resp = _DummyResponse(status_code=200, text='', json_data=None)
|
|
126
|
+
resp.raise_for_status = Mock(return_value=None)
|
|
127
|
+
resp.json = Mock(return_value={})
|
|
128
|
+
return resp
|
|
129
|
+
|
|
130
|
+
async def post(self, url, json=None, data=None, headers=None, timeout=None):
|
|
131
|
+
resp = _DummyResponse(status_code=200, text='', json_data=None)
|
|
132
|
+
resp.raise_for_status = Mock(return_value=None)
|
|
133
|
+
resp.json = Mock(return_value={})
|
|
134
|
+
return resp
|
|
135
|
+
|
|
136
|
+
httpx_mod.Client = _DummyHttpxClient
|
|
137
|
+
httpx_mod.AsyncClient = _DummyAsyncClient
|
|
138
|
+
httpx_mod.Response = _DummyResponse
|
|
139
|
+
httpx_mod.Request = Request
|
|
140
|
+
httpx_mod.Headers = dict
|
|
141
|
+
|
|
142
|
+
class _HTTPStatusError(Exception):
|
|
143
|
+
def __init__(self, *args, request=None, response=None):
|
|
144
|
+
# Support both signatures:
|
|
145
|
+
# HTTPStatusError(response)
|
|
146
|
+
# HTTPStatusError(message, request=..., response=...)
|
|
147
|
+
message = ""
|
|
148
|
+
if response is None and len(args) == 1 and not isinstance(args[0], str):
|
|
149
|
+
# Single positional non-string arg -> treated as response
|
|
150
|
+
response = args[0]
|
|
151
|
+
elif len(args) >= 1 and isinstance(args[0], str):
|
|
152
|
+
message = args[0]
|
|
153
|
+
elif len(args) > 1:
|
|
154
|
+
message = args[0]
|
|
155
|
+
super().__init__(message)
|
|
156
|
+
self.request = request
|
|
157
|
+
self.response = response
|
|
158
|
+
|
|
159
|
+
def __str__(self):
|
|
160
|
+
try:
|
|
161
|
+
return f"{self.args[0]} (status={getattr(self.response,'status_code',None)})"
|
|
162
|
+
except Exception:
|
|
163
|
+
return self.args[0] if self.args else "HTTPStatusError"
|
|
164
|
+
|
|
165
|
+
class _RequestError(Exception):
|
|
166
|
+
def __init__(self, message="", request=None):
|
|
167
|
+
super().__init__(message)
|
|
168
|
+
self.request = request
|
|
169
|
+
|
|
170
|
+
httpx_mod.HTTPStatusError = _HTTPStatusError
|
|
171
|
+
httpx_mod.RequestError = _RequestError
|
|
172
|
+
sys.modules['httpx'] = httpx_mod
|
|
173
|
+
|
|
174
|
+
# -----------------------------
|
|
175
|
+
# Import the module under test after preparing the fake imports
|
|
176
|
+
# -----------------------------
|
|
177
|
+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
|
178
|
+
functions_root = os.path.join(repo_root, 'functions')
|
|
179
|
+
module_path = os.path.join(functions_root, 'api_v1_00', '_analytics.py')
|
|
180
|
+
spec = importlib.util.spec_from_file_location('api_v1_00._analytics', module_path)
|
|
181
|
+
_analytics = importlib.util.module_from_spec(spec)
|
|
182
|
+
spec.loader.exec_module(_analytics)
|
|
183
|
+
|
|
184
|
+
# Restore original sys.modules mappings to avoid side-effects for other tests
|
|
185
|
+
for name, orig in _orig_sys_modules.items():
|
|
186
|
+
if orig is None:
|
|
187
|
+
try:
|
|
188
|
+
del sys.modules[name]
|
|
189
|
+
except KeyError:
|
|
190
|
+
pass
|
|
191
|
+
else:
|
|
192
|
+
sys.modules[name] = orig
|
|
193
|
+
|
|
194
|
+
# Simple helper request stub used in the tests
|
|
195
|
+
class DummyRequest:
|
|
196
|
+
def __init__(self, headers=None, method='POST', json_data=None, raise_on_get_json=False):
|
|
197
|
+
self.headers = headers or {}
|
|
198
|
+
self.method = method
|
|
199
|
+
self._json_data = json_data
|
|
200
|
+
self._raise = raise_on_get_json
|
|
201
|
+
|
|
202
|
+
def get_json(self, silent=False):
|
|
203
|
+
if self._raise:
|
|
204
|
+
raise Exception('Malformed JSON')
|
|
205
|
+
return self._json_data
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_response(resp):
|
|
209
|
+
data = resp.get_data(as_text=True)
|
|
210
|
+
return json.loads(data)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# -----------------------------
|
|
214
|
+
# Tests
|
|
215
|
+
# -----------------------------
|
|
216
|
+
|
|
217
|
+
def test_missing_auth_header_returns_401():
|
|
218
|
+
req = DummyRequest(headers={}, method='POST')
|
|
219
|
+
resp = _analytics._analytics(req)
|
|
220
|
+
assert resp.status_code == 401
|
|
221
|
+
payload = _parse_response(resp)
|
|
222
|
+
assert payload['success'] is False
|
|
223
|
+
assert 'Missing authentication key' in payload['message']
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_invalid_auth_key_returns_403():
|
|
227
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'bad-token'}
|
|
228
|
+
req = DummyRequest(headers=headers, method='POST')
|
|
229
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=False):
|
|
230
|
+
resp = _analytics._analytics(req)
|
|
231
|
+
assert resp.status_code == 403
|
|
232
|
+
payload = _parse_response(resp)
|
|
233
|
+
assert payload['success'] is False
|
|
234
|
+
assert 'Invalid authentication key' in payload['message']
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_non_post_method_returns_405():
|
|
238
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
239
|
+
req = DummyRequest(headers=headers, method='GET')
|
|
240
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True):
|
|
241
|
+
resp = _analytics._analytics(req)
|
|
242
|
+
assert resp.status_code == 405
|
|
243
|
+
payload = _parse_response(resp)
|
|
244
|
+
assert payload['success'] is False
|
|
245
|
+
assert 'POST method required' in payload['message']
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_malformed_json_returns_400():
|
|
249
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
250
|
+
req = DummyRequest(headers=headers, method='POST', raise_on_get_json=True)
|
|
251
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True):
|
|
252
|
+
resp = _analytics._analytics(req)
|
|
253
|
+
assert resp.status_code == 400
|
|
254
|
+
payload = _parse_response(resp)
|
|
255
|
+
assert payload['success'] is False
|
|
256
|
+
assert 'Invalid JSON payload' in payload['message']
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_missing_required_fields_returns_400():
|
|
260
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
261
|
+
# missing measurement_id
|
|
262
|
+
req = DummyRequest(headers=headers, method='POST', json_data={'client_id': 'c', 'events': [{'name': 'e'}]})
|
|
263
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True):
|
|
264
|
+
resp = _analytics._analytics(req)
|
|
265
|
+
assert resp.status_code == 400
|
|
266
|
+
payload = _parse_response(resp)
|
|
267
|
+
assert payload['success'] is False
|
|
268
|
+
assert 'Missing required fields' in payload['message']
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_invalid_events_field_returns_400_for_empty_or_non_list():
|
|
272
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
273
|
+
|
|
274
|
+
# events is empty list
|
|
275
|
+
req_empty = DummyRequest(headers=headers, method='POST', json_data={'measurement_id': 'm', 'client_id': 'c', 'events': []})
|
|
276
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True):
|
|
277
|
+
resp = _analytics._analytics(req_empty)
|
|
278
|
+
assert resp.status_code == 400
|
|
279
|
+
payload = _parse_response(resp)
|
|
280
|
+
assert payload['success'] is False
|
|
281
|
+
assert 'Missing required fields' in payload['message']
|
|
282
|
+
|
|
283
|
+
# events is not a list
|
|
284
|
+
req_not_list = DummyRequest(headers=headers, method='POST', json_data={'measurement_id': 'm', 'client_id': 'c', 'events': 'nope'})
|
|
285
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True):
|
|
286
|
+
resp = _analytics._analytics(req_not_list)
|
|
287
|
+
assert resp.status_code == 400
|
|
288
|
+
payload = _parse_response(resp)
|
|
289
|
+
assert payload['success'] is False
|
|
290
|
+
assert "'events' field must be a non-empty list" in payload['message']
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_secret_retrieval_failure_returns_500():
|
|
294
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
295
|
+
req_payload = {'measurement_id': 'm', 'client_id': 'c', 'events': [{'name': 'e'}]}
|
|
296
|
+
req = DummyRequest(headers=headers, method='POST', json_data=req_payload)
|
|
297
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True), patch.object(_analytics, 'get_secret_key', side_effect=Exception('boom')):
|
|
298
|
+
resp = _analytics._analytics(req)
|
|
299
|
+
assert resp.status_code == 500
|
|
300
|
+
payload = _parse_response(resp)
|
|
301
|
+
assert payload['success'] is False
|
|
302
|
+
assert 'Could not retrieve GA secret' in payload['message']
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_successful_flow_calls_httpx_with_expected_url_and_payload():
|
|
306
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
307
|
+
measurement_id = 'MEAS_ABC'
|
|
308
|
+
client_id = 'roksta_console'
|
|
309
|
+
events = [{'name': 'evt', 'params': {'a': 1}}]
|
|
310
|
+
user_id = 'user-123'
|
|
311
|
+
req_payload = {'measurement_id': measurement_id, 'client_id': client_id, 'events': events, 'user_id': user_id}
|
|
312
|
+
req = DummyRequest(headers=headers, method='POST', json_data=req_payload)
|
|
313
|
+
|
|
314
|
+
# Recording client to capture call details
|
|
315
|
+
class RecordingClient:
|
|
316
|
+
def __init__(self):
|
|
317
|
+
self.post_called = False
|
|
318
|
+
self.last_url = None
|
|
319
|
+
self.last_json = None
|
|
320
|
+
self.last_timeout = None
|
|
321
|
+
|
|
322
|
+
def __enter__(self):
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def __exit__(self, exc_type, exc, tb):
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
def post(self, url, json=None, timeout=30):
|
|
329
|
+
self.post_called = True
|
|
330
|
+
self.last_url = url
|
|
331
|
+
self.last_json = json
|
|
332
|
+
self.last_timeout = timeout
|
|
333
|
+
resp = Mock()
|
|
334
|
+
resp.raise_for_status = Mock(return_value=None)
|
|
335
|
+
resp.status_code = 200
|
|
336
|
+
resp.text = ''
|
|
337
|
+
return resp
|
|
338
|
+
|
|
339
|
+
recording = RecordingClient()
|
|
340
|
+
|
|
341
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True), \
|
|
342
|
+
patch.object(_analytics, 'get_secret_key', return_value='GA-SECRET'), \
|
|
343
|
+
patch.object(_analytics.httpx, 'Client', return_value=recording):
|
|
344
|
+
resp = _analytics._analytics(req)
|
|
345
|
+
|
|
346
|
+
assert resp.status_code == 200
|
|
347
|
+
payload = _parse_response(resp)
|
|
348
|
+
assert payload['success'] is True
|
|
349
|
+
|
|
350
|
+
expected_url = f"https://www.google-analytics.com/mp/collect?measurement_id={measurement_id}&api_secret=GA-SECRET"
|
|
351
|
+
assert recording.post_called is True
|
|
352
|
+
assert recording.last_url == expected_url
|
|
353
|
+
assert recording.last_json['client_id'] == client_id
|
|
354
|
+
assert recording.last_json['events'] == events
|
|
355
|
+
assert recording.last_json['user_id'] == user_id
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_httpx_http_status_error_is_logged_but_function_returns_200():
|
|
359
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
360
|
+
req_payload = {'measurement_id': 'm', 'client_id': 'c', 'events': [{'name': 'e'}]}
|
|
361
|
+
req = DummyRequest(headers=headers, method='POST', json_data=req_payload)
|
|
362
|
+
|
|
363
|
+
# Client that returns a response whose raise_for_status raises HTTPStatusError
|
|
364
|
+
class ErrClient1:
|
|
365
|
+
def __enter__(self):
|
|
366
|
+
return self
|
|
367
|
+
|
|
368
|
+
def __exit__(self, exc_type, exc, tb):
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
def post(self, url, json=None, timeout=30):
|
|
372
|
+
resp = Mock()
|
|
373
|
+
# Raise a fake HTTPStatusError instance with a response attribute
|
|
374
|
+
resp.raise_for_status = Mock(side_effect=_analytics.httpx.HTTPStatusError(Mock(status_code=400, text='bad')))
|
|
375
|
+
resp.status_code = 400
|
|
376
|
+
resp.text = 'bad'
|
|
377
|
+
return resp
|
|
378
|
+
|
|
379
|
+
err_client = ErrClient1()
|
|
380
|
+
|
|
381
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True), \
|
|
382
|
+
patch.object(_analytics, 'get_secret_key', return_value='GA-SECRET'), \
|
|
383
|
+
patch.object(_analytics.httpx, 'Client', return_value=err_client):
|
|
384
|
+
resp = _analytics._analytics(req)
|
|
385
|
+
|
|
386
|
+
assert resp.status_code == 200
|
|
387
|
+
payload = _parse_response(resp)
|
|
388
|
+
assert payload['success'] is True
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_httpx_request_error_is_logged_but_function_returns_200():
|
|
392
|
+
headers = {_analytics.AUTH_HEADER_NAME: 'ok'}
|
|
393
|
+
req_payload = {'measurement_id': 'm', 'client_id': 'c', 'events': [{'name': 'e'}]}
|
|
394
|
+
req = DummyRequest(headers=headers, method='POST', json_data=req_payload)
|
|
395
|
+
|
|
396
|
+
# Client whose post itself raises a RequestError
|
|
397
|
+
class ErrClient2:
|
|
398
|
+
def __enter__(self):
|
|
399
|
+
return self
|
|
400
|
+
|
|
401
|
+
def __exit__(self, exc_type, exc, tb):
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
def post(self, url, json=None, timeout=30):
|
|
405
|
+
raise _analytics.httpx.RequestError('network')
|
|
406
|
+
|
|
407
|
+
err_client = ErrClient2()
|
|
408
|
+
|
|
409
|
+
with patch.object(_analytics, 'validate_auth_key', return_value=True), \
|
|
410
|
+
patch.object(_analytics, 'get_secret_key', return_value='GA-SECRET'), \
|
|
411
|
+
patch.object(_analytics.httpx, 'Client', return_value=err_client):
|
|
412
|
+
resp = _analytics._analytics(req)
|
|
413
|
+
|
|
414
|
+
assert resp.status_code == 200
|
|
415
|
+
payload = _parse_response(resp)
|
|
416
|
+
assert payload['success'] is True
|