roksta 0.2.4__cp313-cp313-win_amd64.whl → 0.2.5__cp313-cp313-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.

Files changed (78) hide show
  1. roksta/__init__.cp313-win_amd64.pyd +0 -0
  2. roksta/ai/__init__.cp313-win_amd64.pyd +0 -0
  3. roksta/ai/call_ai.cp313-win_amd64.pyd +0 -0
  4. roksta/ai/gemini.cp313-win_amd64.pyd +0 -0
  5. roksta/ai/generic.cp313-win_amd64.pyd +0 -0
  6. roksta/ai/llm.cp313-win_amd64.pyd +0 -0
  7. roksta/ai/openai.cp313-win_amd64.pyd +0 -0
  8. roksta/ai/tools.cp313-win_amd64.pyd +0 -0
  9. roksta/analytics.cp313-win_amd64.pyd +0 -0
  10. roksta/balance.cp313-win_amd64.pyd +0 -0
  11. roksta/build_project.cp313-win_amd64.pyd +0 -0
  12. roksta/chat_workflow.cp313-win_amd64.pyd +0 -0
  13. roksta/check_for_updates.cp313-win_amd64.pyd +0 -0
  14. roksta/checkpoints.cp313-win_amd64.pyd +0 -0
  15. roksta/clarify_goal.cp313-win_amd64.pyd +0 -0
  16. roksta/codebase_listing.cp313-win_amd64.pyd +0 -0
  17. roksta/command_handlers.cp313-win_amd64.pyd +0 -0
  18. roksta/create_default_config.cp313-win_amd64.pyd +0 -0
  19. roksta/default_config.cp313-win_amd64.pyd +0 -0
  20. roksta/enums.cp313-win_amd64.pyd +0 -0
  21. roksta/env.cp313-win_amd64.pyd +0 -0
  22. roksta/extended_text_area.cp313-win_amd64.pyd +0 -0
  23. roksta/firebase.cp313-win_amd64.pyd +0 -0
  24. roksta/firebase_auth_web.cp313-win_amd64.pyd +0 -0
  25. roksta/firebase_config.cp313-win_amd64.pyd +0 -0
  26. roksta/fix_tests.cp313-win_amd64.pyd +0 -0
  27. roksta/gen_codebase_summaries.cp313-win_amd64.pyd +0 -0
  28. roksta/gen_one_line_goal.cp313-win_amd64.pyd +0 -0
  29. roksta/get_codebase_structure.cp313-win_amd64.pyd +0 -0
  30. roksta/get_failing_tests.cp313-win_amd64.pyd +0 -0
  31. roksta/goal_workflow.cp313-win_amd64.pyd +0 -0
  32. roksta/init_codebase.cp313-win_amd64.pyd +0 -0
  33. roksta/lint_code.cp313-win_amd64.pyd +0 -0
  34. roksta/logger.cp313-win_amd64.pyd +0 -0
  35. roksta/main.cp313-win_amd64.pyd +0 -0
  36. roksta/make_issue.cp313-win_amd64.pyd +0 -0
  37. roksta/new_features.cp313-win_amd64.pyd +0 -0
  38. roksta/parse_readme.cp313-win_amd64.pyd +0 -0
  39. roksta/propose_solution.cp313-win_amd64.pyd +0 -0
  40. roksta/response_formats.cp313-win_amd64.pyd +0 -0
  41. roksta/rewrite_goal.cp313-win_amd64.pyd +0 -0
  42. roksta/roksta.cp313-win_amd64.pyd +0 -0
  43. roksta/select_files.cp313-win_amd64.pyd +0 -0
  44. roksta/tips.cp313-win_amd64.pyd +0 -0
  45. roksta/utils.cp313-win_amd64.pyd +0 -0
  46. roksta/write_code.cp313-win_amd64.pyd +0 -0
  47. {roksta-0.2.4.dist-info → roksta-0.2.5.dist-info}/METADATA +1 -1
  48. roksta-0.2.5.dist-info/RECORD +77 -0
  49. {roksta-0.2.4.dist-info → roksta-0.2.5.dist-info}/top_level.txt +1 -0
  50. tests/__init__.py +2 -0
  51. tests/conftest.py +169 -0
  52. tests/functions/__init__.py +2 -0
  53. tests/functions/api_v0_01/__init__.py +2 -0
  54. tests/functions/api_v0_01/test__analytics.py +417 -0
  55. tests/functions/api_v0_01/test__gemini_proxy.py +307 -0
  56. tests/functions/api_v0_01/test__generic_proxy.py +399 -0
  57. tests/functions/api_v0_01/test__get_payment_details.py +356 -0
  58. tests/functions/api_v0_01/test__openai_proxy.py +413 -0
  59. tests/functions/api_v0_01/test__redeem_credit_code.py +167 -0
  60. tests/functions/api_v0_01/test__sync_emails.py +324 -0
  61. tests/functions/api_v0_01/test__take_payment.py +491 -0
  62. tests/functions/api_v0_01/test__use_activation_code.py +437 -0
  63. tests/functions/api_v1_00/__init__.py +2 -0
  64. tests/functions/api_v1_00/test__analytics.py +416 -0
  65. tests/functions/api_v1_00/test__gemini_proxy.py +352 -0
  66. tests/functions/api_v1_00/test__generic_proxy.py +428 -0
  67. tests/functions/api_v1_00/test__get_payment_details.py +356 -0
  68. tests/functions/api_v1_00/test__openai_proxy.py +449 -0
  69. tests/functions/api_v1_00/test__redeem_credit_code.py +167 -0
  70. tests/functions/api_v1_00/test__sync_emails.py +325 -0
  71. tests/functions/api_v1_00/test__take_payment.py +491 -0
  72. tests/functions/api_v1_00/test__use_activation_code.py +438 -0
  73. tests/functions/test_auth.py +24 -0
  74. tests/functions/test_main_functions.py +73 -0
  75. tests/functions/test_utils_functions.py +222 -0
  76. roksta-0.2.4.dist-info/RECORD +0 -51
  77. {roksta-0.2.4.dist-info → roksta-0.2.5.dist-info}/WHEEL +0 -0
  78. {roksta-0.2.4.dist-info → roksta-0.2.5.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,413 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import types
5
+ import importlib
6
+ from unittest.mock import patch
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
+ # Prepare lightweight fake modules to satisfy imports inside _openai_proxy
16
+ # We'll temporarily inject these into sys.modules while importing the module
17
+
18
+ # Save any originals so we can restore them after import
19
+ _orig_sys_modules = {}
20
+ _names_to_fake = [
21
+ 'firebase_functions',
22
+ 'utils',
23
+ 'auth',
24
+ 'openai',
25
+ ]
26
+ for name in _names_to_fake:
27
+ _orig_sys_modules[name] = sys.modules.get(name)
28
+
29
+ # Fake firebase_functions.https_fn.Response to capture returned data
30
+ firebase_functions = types.ModuleType('firebase_functions')
31
+
32
+ class FakeResponse:
33
+ def __init__(self, response=None, mimetype=None, status=200, **kwargs):
34
+ # Mirror the small subset of the interface tests expect
35
+ self.status_code = status
36
+ if isinstance(response, (dict, list)):
37
+ self._body_text = json.dumps(response)
38
+ else:
39
+ self._body_text = '' if response is None else response
40
+ self.headers = kwargs.get('headers', {})
41
+
42
+ def get_data(self, as_text=False):
43
+ if as_text:
44
+ return self._body_text
45
+ return self._body_text.encode('utf-8')
46
+
47
+ firebase_functions.https_fn = types.SimpleNamespace(Request=object, Response=FakeResponse)
48
+ sys.modules['firebase_functions'] = firebase_functions
49
+
50
+ # Fake utils module (provides functions imported by _openai_proxy)
51
+ utils_mod = types.ModuleType('utils')
52
+
53
+
54
+ def _fake_create_json_response(success: bool, payload: any, status_code: int):
55
+ response_body = {"success": success, "payload": payload}
56
+ return firebase_functions.https_fn.Response(response=json.dumps(response_body), status=status_code, headers={'Content-Type': 'application/json'})
57
+
58
+
59
+ def _fake_get_api_key(llm_family=None):
60
+ return 'DUMMY_KEY'
61
+
62
+
63
+ def _fake_verify_firebase_token(req):
64
+ # Default: no-op (successful)
65
+ return {}
66
+
67
+
68
+ utils_mod.create_json_response = _fake_create_json_response
69
+ utils_mod.get_api_key = _fake_get_api_key
70
+ utils_mod.verify_firebase_token = _fake_verify_firebase_token
71
+ sys.modules['utils'] = utils_mod
72
+
73
+ # Fake auth module with validate_auth_key
74
+ auth_mod = types.ModuleType('auth')
75
+
76
+
77
+ def _fake_validate_auth_key(val: str) -> bool:
78
+ return True
79
+
80
+
81
+ auth_mod.validate_auth_key = _fake_validate_auth_key
82
+ sys.modules['auth'] = auth_mod
83
+
84
+ # Fake openai module with APIError and a default OpenAI class
85
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
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)
91
+
92
+ class APIError(Exception):
93
+ pass
94
+
95
+
96
+ class DummyOpenAI:
97
+ def __init__(self, api_key=None, timeout=None):
98
+ self.api_key = api_key
99
+ self.timeout = timeout
100
+ # The proxy expects openai_client.responses.create / parse
101
+ self.responses = self
102
+
103
+ def create(self, **params):
104
+ raise NotImplementedError("create not implemented for dummy client")
105
+
106
+ def parse(self, **params):
107
+ raise NotImplementedError("parse not implemented for dummy client")
108
+
109
+
110
+ _openai.OpenAI = DummyOpenAI
111
+ _openai.APIError = APIError
112
+ sys.modules['openai'] = _openai
113
+
114
+ # Import the module under test after preparing the fake imports
115
+ _openai = importlib.import_module('_openai_proxy')
116
+
117
+ # Restore original sys.modules mappings to avoid side-effects for other tests
118
+ for name, orig in _orig_sys_modules.items():
119
+ if orig is None:
120
+ try:
121
+ del sys.modules[name]
122
+ except KeyError:
123
+ pass
124
+ else:
125
+ sys.modules[name] = orig
126
+
127
+
128
+ # Helper request stub used in tests
129
+ class DummyRequest:
130
+ def __init__(self, headers=None, method='POST', json_data=None, raise_on_get_json=False):
131
+ self.headers = headers or {}
132
+ self.method = method
133
+ self._json_data = json_data
134
+ self._raise = raise_on_get_json
135
+
136
+ def get_json(self, silent=False):
137
+ if self._raise:
138
+ raise Exception('Malformed JSON')
139
+ return self._json_data
140
+
141
+
142
+ def _parse_response(resp):
143
+ data = resp.get_data(as_text=True)
144
+ return json.loads(data)
145
+
146
+
147
+ # -----------------------------
148
+ # Tests
149
+ # -----------------------------
150
+
151
+
152
+ def test_verify_firebase_token_failure_returns_401():
153
+ req = DummyRequest(headers={_openai.AUTH_HEADER_NAME: 'ok'}, method='POST')
154
+ with patch.object(_openai, 'verify_firebase_token', side_effect=Exception('invalid token')):
155
+ resp = _openai._openai_proxy(req)
156
+
157
+ assert resp.status_code == 401
158
+ payload = _parse_response(resp)
159
+ assert payload['success'] is False
160
+ assert 'Unauthorized' in payload['payload']
161
+
162
+
163
+ def test_missing_auth_header_returns_401():
164
+ # no app auth header
165
+ req = DummyRequest(headers={}, method='POST', json_data={'call_type': 'create', 'call_params': {}})
166
+ with patch.object(_openai, 'validate_auth_key', return_value=True), patch.object(_openai, 'verify_firebase_token', return_value={}):
167
+ resp = _openai._openai_proxy(req)
168
+
169
+ assert resp.status_code == 401
170
+ payload = _parse_response(resp)
171
+ assert payload['success'] is False
172
+ assert 'Missing app authentication key' in payload['payload']
173
+
174
+
175
+ def test_invalid_auth_key_returns_403():
176
+ headers = {_openai.AUTH_HEADER_NAME: 'bad'}
177
+ req = DummyRequest(headers=headers, method='POST', json_data={'call_type': 'create', 'call_params': {}})
178
+ with patch.object(_openai, 'validate_auth_key', return_value=False), patch.object(_openai, 'verify_firebase_token', return_value={}):
179
+ resp = _openai._openai_proxy(req)
180
+
181
+ assert resp.status_code == 403
182
+ payload = _parse_response(resp)
183
+ assert payload['success'] is False
184
+ assert 'Invalid app authentication key' in payload['payload']
185
+
186
+
187
+ def test_non_post_method_returns_405():
188
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
189
+ req = DummyRequest(headers=headers, method='GET')
190
+ with patch.object(_openai, 'validate_auth_key', return_value=True), patch.object(_openai, 'verify_firebase_token', return_value={}):
191
+ resp = _openai._openai_proxy(req)
192
+
193
+ assert resp.status_code == 405
194
+ payload = _parse_response(resp)
195
+ assert payload['success'] is False
196
+ assert 'POST method required' in payload['payload']
197
+
198
+
199
+ def test_malformed_json_returns_400():
200
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
201
+ req = DummyRequest(headers=headers, method='POST', raise_on_get_json=True)
202
+ with patch.object(_openai, 'validate_auth_key', return_value=True), patch.object(_openai, 'verify_firebase_token', return_value={}):
203
+ resp = _openai._openai_proxy(req)
204
+
205
+ assert resp.status_code == 400
206
+ payload = _parse_response(resp)
207
+ assert payload['success'] is False
208
+ assert 'Invalid JSON payload' in payload['payload']
209
+
210
+
211
+ def test_missing_or_invalid_call_type_or_call_params_returns_400():
212
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
213
+ req = DummyRequest(headers=headers, method='POST', json_data={'call_type': None, 'call_params': {}})
214
+ with patch.object(_openai, 'validate_auth_key', return_value=True), patch.object(_openai, 'verify_firebase_token', return_value={}):
215
+ resp = _openai._openai_proxy(req)
216
+
217
+ assert resp.status_code == 400
218
+ payload = _parse_response(resp)
219
+ assert payload['success'] is False
220
+ assert 'Missing or invalid required fields' in payload['payload']
221
+
222
+
223
+ def test_invalid_call_type_returns_400():
224
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
225
+ req = DummyRequest(headers=headers, method='POST', json_data={'call_type': 'bad', 'call_params': {}})
226
+ with patch.object(_openai, 'validate_auth_key', return_value=True), patch.object(_openai, 'verify_firebase_token', return_value={}):
227
+ resp = _openai._openai_proxy(req)
228
+
229
+ assert resp.status_code == 400
230
+ payload = _parse_response(resp)
231
+ assert payload['success'] is False
232
+ assert "Invalid 'call_type'" in payload['payload']
233
+
234
+
235
+ def test_get_api_key_failure_returns_500():
236
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
237
+ req = DummyRequest(headers=headers, method='POST', json_data={'call_type': 'create', 'call_params': {'model': 'm', 'input': 'hi'}})
238
+ with patch.object(_openai, 'validate_auth_key', return_value=True), \
239
+ patch.object(_openai, 'verify_firebase_token', return_value={}), \
240
+ patch.object(_openai, 'get_api_key', side_effect=Exception('boom')):
241
+ resp = _openai._openai_proxy(req)
242
+
243
+ assert resp.status_code == 500
244
+ payload = _parse_response(resp)
245
+ assert payload['success'] is False
246
+ assert 'Could not retrieve API key' in payload['payload']
247
+
248
+
249
+ def test_parse_missing_input_or_text_format_returns_400():
250
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
251
+ # missing 'text_format' to trigger the parse validation
252
+ req = DummyRequest(headers=headers, method='POST', json_data={'call_type': 'parse', 'call_params': {'model': 'm', 'input': 'hi'}})
253
+
254
+ class NoopOpenAI:
255
+ def __init__(self, api_key=None, timeout=None):
256
+ self.api_key = api_key
257
+ self.timeout = timeout
258
+ self.responses = self
259
+
260
+ def parse(self, **p):
261
+ return None
262
+
263
+ def create(self, **p):
264
+ return None
265
+
266
+ with patch.object(_openai, 'validate_auth_key', return_value=True), \
267
+ patch.object(_openai, 'verify_firebase_token', return_value={}), \
268
+ patch.object(_openai, 'get_api_key', return_value='KEY'), \
269
+ patch.object(_openai.openai, 'OpenAI', NoopOpenAI):
270
+ resp = _openai._openai_proxy(req)
271
+
272
+ assert resp.status_code == 400
273
+ payload = _parse_response(resp)
274
+ assert payload['success'] is False
275
+ assert "Missing 'input' or 'text_format' for parse call" in payload['payload']
276
+
277
+
278
+ def test_create_missing_input_returns_400():
279
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
280
+ req = DummyRequest(headers=headers, method='POST', json_data={'call_type': 'create', 'call_params': {'model': 'm'}})
281
+
282
+ class NoopOpenAI:
283
+ def __init__(self, api_key=None, timeout=None):
284
+ self.api_key = api_key
285
+ self.timeout = timeout
286
+ self.responses = self
287
+
288
+ def parse(self, **p):
289
+ return None
290
+
291
+ def create(self, **p):
292
+ return None
293
+
294
+ with patch.object(_openai, 'validate_auth_key', return_value=True), \
295
+ patch.object(_openai, 'verify_firebase_token', return_value={}), \
296
+ patch.object(_openai, 'get_api_key', return_value='KEY'), \
297
+ patch.object(_openai.openai, 'OpenAI', NoopOpenAI):
298
+ resp = _openai._openai_proxy(req)
299
+
300
+ assert resp.status_code == 400
301
+ payload = _parse_response(resp)
302
+ assert payload['success'] is False
303
+ assert "Missing 'input' for create call" in payload['payload']
304
+
305
+
306
+ def test_successful_create_calls_openai_and_returns_payload():
307
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
308
+ req_payload = {'call_type': 'create', 'call_params': {'model': 'gem-model', 'input': 'hello'}}
309
+ req = DummyRequest(headers=headers, method='POST', json_data=req_payload)
310
+
311
+ class FakeClient:
312
+ def __init__(self, api_key, timeout=None):
313
+ self.api_key = api_key
314
+ self.timeout = timeout
315
+ self.responses = self
316
+
317
+ def create(self, **params):
318
+ class FakeResp:
319
+ def model_dump(self_inner, mode='json'):
320
+ return {'result': 'ok', 'received_model': params.get('model')}
321
+ return FakeResp()
322
+
323
+ with patch.object(_openai, 'validate_auth_key', return_value=True), \
324
+ patch.object(_openai, 'verify_firebase_token', return_value={}), \
325
+ patch.object(_openai, 'get_api_key', return_value='OPENAI-KEY'), \
326
+ patch.object(_openai.openai, 'OpenAI', FakeClient):
327
+ resp = _openai._openai_proxy(req)
328
+
329
+ assert resp.status_code == 200
330
+ payload = _parse_response(resp)
331
+ assert payload['success'] is True
332
+ assert isinstance(payload['payload'], dict)
333
+ assert payload['payload']['result'] == 'ok'
334
+ assert payload['payload']['received_model'] == 'gem-model'
335
+
336
+
337
+ def test_successful_parse_calls_openai_and_returns_parsed_and_usage():
338
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
339
+ req_payload = {'call_type': 'parse', 'call_params': {'model': 'gem-model', 'input': 'hello', 'text_format': 'FileSummaryModel'}}
340
+ req = DummyRequest(headers=headers, method='POST', json_data=req_payload)
341
+
342
+ class FakeClient2:
343
+ def __init__(self, api_key, timeout=None):
344
+ self.api_key = api_key
345
+ self.timeout = timeout
346
+ self.responses = self
347
+
348
+ def parse(self, **params):
349
+ class FakeOutputParsed:
350
+ def __init__(self, data):
351
+ self._data = data
352
+
353
+ def dict(self):
354
+ return self._data
355
+
356
+ class FakeResp:
357
+ def __init__(self, data, usage):
358
+ self.output_parsed = FakeOutputParsed(data)
359
+ self._usage = usage
360
+
361
+ def model_dump(self_inner, mode='json'):
362
+ return {'usage': self_inner._usage}
363
+
364
+ # detect whether text_format was left as a string
365
+ is_text_format_string = isinstance(params.get('text_format'), str)
366
+ parsed = {'result': 'ok', 'received_model': params.get('model'), 'is_text_format_string': is_text_format_string}
367
+ usage = {'prompt_tokens': 5}
368
+ return FakeResp(parsed, usage)
369
+
370
+ with patch.object(_openai, 'validate_auth_key', return_value=True), \
371
+ patch.object(_openai, 'verify_firebase_token', return_value={}), \
372
+ patch.object(_openai, 'get_api_key', return_value='OPENAI-KEY'), \
373
+ patch.object(_openai.openai, 'OpenAI', FakeClient2):
374
+ resp = _openai._openai_proxy(req)
375
+
376
+ assert resp.status_code == 200
377
+ payload = _parse_response(resp)
378
+ assert payload['success'] is True
379
+ assert payload['payload']['output_parsed']['result'] == 'ok'
380
+ assert payload['payload']['output_parsed']['received_model'] == 'gem-model'
381
+ # The proxy should have replaced the text_format string with the model (i.e., not a string)
382
+ assert payload['payload']['output_parsed']['is_text_format_string'] is False
383
+ assert payload['payload']['usage'] == {'prompt_tokens': 5}
384
+
385
+
386
+ def test_openai_api_error_with_status_code_is_returned_as_error_status():
387
+ headers = {_openai.AUTH_HEADER_NAME: 'ok'}
388
+ req_payload = {'call_type': 'create', 'call_params': {'model': 'g', 'input': 'hey'}}
389
+ req = DummyRequest(headers=headers, method='POST', json_data=req_payload)
390
+
391
+ # Use the module's openai.APIError so the proxy's except block catches it
392
+ err = _openai.openai.APIError('rate limited')
393
+ err.status_code = 502
394
+
395
+ class ErrClient:
396
+ def __init__(self, api_key=None, timeout=None):
397
+ self.api_key = api_key
398
+ self.timeout = timeout
399
+ self.responses = self
400
+
401
+ def create(self, **params):
402
+ raise err
403
+
404
+ with patch.object(_openai, 'validate_auth_key', return_value=True), \
405
+ patch.object(_openai, 'verify_firebase_token', return_value={}), \
406
+ patch.object(_openai, 'get_api_key', return_value='OPENAI-KEY'), \
407
+ patch.object(_openai.openai, 'OpenAI', ErrClient):
408
+ resp = _openai._openai_proxy(req)
409
+
410
+ assert resp.status_code == 502
411
+ payload = _parse_response(resp)
412
+ assert payload['success'] is False
413
+ assert 'OpenAI API Error' in payload['payload']
@@ -0,0 +1,167 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import types
5
+ import importlib
6
+ from unittest.mock import patch
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 _redeem_credit_code
17
+ # These are minimal and will be patched in individual tests as needed.
18
+ # -----------------------------
19
+ # Save original sys.modules entries so we can restore them after importing the module under test
20
+ _orig_sys_modules = {}
21
+ _names_to_fake = ['firebase_functions', 'utils', 'auth', 'firebase_admin']
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 (verify_firebase_token, create_json_response)
47
+ utils_mod = types.ModuleType('utils')
48
+
49
+ def _dummy_verify_firebase_token(req: object) -> dict:
50
+ # Default behavior: return a decoded token with a uid
51
+ return {'uid': 'test_user'}
52
+
53
+ def _create_json_response(success: bool, payload=None, status_code: int = 200):
54
+ # Normalize payload to include a message field
55
+ if isinstance(payload, dict):
56
+ message = payload.get('message', '')
57
+ data = {'success': success, 'message': message}
58
+ # include rest of payload under 'payload' to mirror production structure
59
+ data['payload'] = payload
60
+ else:
61
+ message = payload if payload is not None else ''
62
+ data = {'success': success, 'message': message}
63
+ # Use the fake firebase_functions response object
64
+ return firebase_functions.https_fn.Response(response=data, status=status_code)
65
+
66
+ utils_mod.verify_firebase_token = _dummy_verify_firebase_token
67
+ utils_mod.create_json_response = _create_json_response
68
+ sys.modules['utils'] = utils_mod
69
+
70
+ # Fake auth module (validate_auth_key will be patched per-test as needed)
71
+ auth_mod = types.ModuleType('auth')
72
+
73
+ def _simple_validate_auth_key(val: str) -> bool:
74
+ return bool(val)
75
+
76
+ auth_mod.validate_auth_key = _simple_validate_auth_key
77
+ sys.modules['auth'] = auth_mod
78
+
79
+ # Fake firebase_admin to prevent importing the real package during tests
80
+ firebase_admin = types.ModuleType('firebase_admin')
81
+ # Minimal fake firestore object with attributes referenced by the function
82
+ fake_firestore = types.SimpleNamespace(
83
+ client=lambda: None,
84
+ transactional=lambda f: f,
85
+ SERVER_TIMESTAMP=None
86
+ )
87
+ firebase_admin.firestore = fake_firestore
88
+ sys.modules['firebase_admin'] = firebase_admin
89
+
90
+ # -----------------------------
91
+ # Import the module under test after preparing the fake imports
92
+ # -----------------------------
93
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
94
+ functions_root = os.path.join(repo_root, 'functions')
95
+ module_path = os.path.join(functions_root, 'api_v0_01', '_redeem_credit_code.py')
96
+ spec = importlib.util.spec_from_file_location('api_v0_01._redeem_credit_code', module_path)
97
+ _redeem = importlib.util.module_from_spec(spec)
98
+ spec.loader.exec_module(_redeem)
99
+
100
+
101
+ # Restore original sys.modules mappings to avoid side-effects for other tests
102
+ for name, orig in _orig_sys_modules.items():
103
+ if orig is None:
104
+ try:
105
+ del sys.modules[name]
106
+ except KeyError:
107
+ pass
108
+ else:
109
+ sys.modules[name] = orig
110
+
111
+ # Simple helper request stub used in the tests
112
+ class DummyRequest:
113
+ def __init__(self, headers=None, method='POST', json_data=None, raise_on_get_json=False):
114
+ self.headers = headers or {}
115
+ self.method = method
116
+ self._json_data = json_data
117
+ self._raise = raise_on_get_json
118
+
119
+ def get_json(self, silent=True):
120
+ if self._raise:
121
+ raise Exception('Malformed JSON')
122
+ return self._json_data
123
+
124
+
125
+ def _parse_response(resp):
126
+ data = resp.get_data(as_text=True)
127
+ return json.loads(data)
128
+
129
+
130
+ # -----------------------------
131
+ # Tests
132
+ # -----------------------------
133
+
134
+ def test_missing_auth_header_returns_401():
135
+ req = DummyRequest(headers={}, method='POST')
136
+ # Ensure verify_firebase_token is not called when header is missing
137
+ with patch.object(_redeem, 'verify_firebase_token', side_effect=Exception('Should not be called')):
138
+ resp = _redeem._redeem_credit_code(req)
139
+
140
+ assert resp.status_code == 401
141
+ payload = _parse_response(resp)
142
+ assert payload['success'] is False
143
+ assert 'Missing authentication key' in payload['message']
144
+
145
+
146
+ def test_invalid_auth_key_returns_403():
147
+ headers = {_redeem.AUTH_HEADER_NAME: 'bad-token'}
148
+ req = DummyRequest(headers=headers, method='POST')
149
+ with patch.object(_redeem, 'validate_auth_key', return_value=False):
150
+ resp = _redeem._redeem_credit_code(req)
151
+ assert resp.status_code == 403
152
+ payload = _parse_response(resp)
153
+ assert payload['success'] is False
154
+ assert 'Invalid authentication key' in payload['message']
155
+
156
+
157
+ def test_valid_auth_key_passes_and_proceeds():
158
+ headers = {_redeem.AUTH_HEADER_NAME: 'ok'}
159
+ # Provide an empty JSON payload to trigger the existing JSON validation path
160
+ req = DummyRequest(headers=headers, method='POST', json_data={})
161
+ with patch.object(_redeem, 'validate_auth_key', return_value=True):
162
+ resp = _redeem._redeem_credit_code(req)
163
+
164
+ assert resp.status_code == 400
165
+ payload = _parse_response(resp)
166
+ assert payload['success'] is False
167
+ assert 'Invalid or missing JSON payload' in payload['message']