jac-scale 0.1.1__py3-none-any.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.
- jac_scale/__init__.py +0 -0
- jac_scale/abstractions/config/app_config.jac +30 -0
- jac_scale/abstractions/config/base_config.jac +26 -0
- jac_scale/abstractions/database_provider.jac +51 -0
- jac_scale/abstractions/deployment_target.jac +64 -0
- jac_scale/abstractions/image_registry.jac +54 -0
- jac_scale/abstractions/logger.jac +20 -0
- jac_scale/abstractions/models/deployment_result.jac +27 -0
- jac_scale/abstractions/models/resource_status.jac +38 -0
- jac_scale/config_loader.jac +31 -0
- jac_scale/context.jac +14 -0
- jac_scale/factories/database_factory.jac +43 -0
- jac_scale/factories/deployment_factory.jac +43 -0
- jac_scale/factories/registry_factory.jac +32 -0
- jac_scale/factories/utility_factory.jac +34 -0
- jac_scale/impl/config_loader.impl.jac +131 -0
- jac_scale/impl/context.impl.jac +24 -0
- jac_scale/impl/memory_hierarchy.main.impl.jac +63 -0
- jac_scale/impl/memory_hierarchy.mongo.impl.jac +239 -0
- jac_scale/impl/memory_hierarchy.redis.impl.jac +186 -0
- jac_scale/impl/serve.impl.jac +1785 -0
- jac_scale/jserver/__init__.py +0 -0
- jac_scale/jserver/impl/jfast_api.impl.jac +731 -0
- jac_scale/jserver/impl/jserver.impl.jac +79 -0
- jac_scale/jserver/jfast_api.jac +162 -0
- jac_scale/jserver/jserver.jac +101 -0
- jac_scale/memory_hierarchy.jac +138 -0
- jac_scale/plugin.jac +218 -0
- jac_scale/plugin_config.jac +175 -0
- jac_scale/providers/database/kubernetes_mongo.jac +137 -0
- jac_scale/providers/database/kubernetes_redis.jac +110 -0
- jac_scale/providers/registry/dockerhub.jac +64 -0
- jac_scale/serve.jac +118 -0
- jac_scale/targets/kubernetes/kubernetes_config.jac +215 -0
- jac_scale/targets/kubernetes/kubernetes_target.jac +841 -0
- jac_scale/targets/kubernetes/utils/kubernetes_utils.impl.jac +519 -0
- jac_scale/targets/kubernetes/utils/kubernetes_utils.jac +85 -0
- jac_scale/tests/__init__.py +0 -0
- jac_scale/tests/conftest.py +29 -0
- jac_scale/tests/fixtures/test_api.jac +159 -0
- jac_scale/tests/fixtures/todo_app.jac +68 -0
- jac_scale/tests/test_abstractions.py +88 -0
- jac_scale/tests/test_deploy_k8s.py +265 -0
- jac_scale/tests/test_examples.py +484 -0
- jac_scale/tests/test_factories.py +149 -0
- jac_scale/tests/test_file_upload.py +444 -0
- jac_scale/tests/test_k8s_utils.py +156 -0
- jac_scale/tests/test_memory_hierarchy.py +247 -0
- jac_scale/tests/test_serve.py +1835 -0
- jac_scale/tests/test_sso.py +711 -0
- jac_scale/utilities/loggers/standard_logger.jac +40 -0
- jac_scale/utils.jac +16 -0
- jac_scale-0.1.1.dist-info/METADATA +658 -0
- jac_scale-0.1.1.dist-info/RECORD +57 -0
- jac_scale-0.1.1.dist-info/WHEEL +5 -0
- jac_scale-0.1.1.dist-info/entry_points.txt +3 -0
- jac_scale-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Test file upload functionality in jac-scale serve."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import gc
|
|
5
|
+
import glob
|
|
6
|
+
import io
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_free_port() -> int:
|
|
17
|
+
"""Get a free port by binding to port 0 and releasing it."""
|
|
18
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
19
|
+
s.bind(("", 0))
|
|
20
|
+
s.listen(1)
|
|
21
|
+
port = s.getsockname()[1]
|
|
22
|
+
return port
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestFileUpload:
|
|
26
|
+
"""Test file upload functionality in jac-scale."""
|
|
27
|
+
|
|
28
|
+
fixtures_dir: Path
|
|
29
|
+
test_file: Path
|
|
30
|
+
port: int
|
|
31
|
+
base_url: str
|
|
32
|
+
server_process: subprocess.Popen[str] | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def setup_class(cls) -> None:
|
|
36
|
+
"""Set up test class - runs once for all tests."""
|
|
37
|
+
cls.fixtures_dir = Path(__file__).parent / "fixtures"
|
|
38
|
+
cls.test_file = cls.fixtures_dir / "test_api.jac"
|
|
39
|
+
|
|
40
|
+
if not cls.test_file.exists():
|
|
41
|
+
raise FileNotFoundError(f"Test fixture not found: {cls.test_file}")
|
|
42
|
+
|
|
43
|
+
cls.port = get_free_port()
|
|
44
|
+
cls.base_url = f"http://localhost:{cls.port}"
|
|
45
|
+
|
|
46
|
+
cls._cleanup_db_files()
|
|
47
|
+
cls.server_process = None
|
|
48
|
+
cls._start_server()
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def teardown_class(cls) -> None:
|
|
52
|
+
"""Tear down test class - runs once after all tests."""
|
|
53
|
+
if cls.server_process:
|
|
54
|
+
cls.server_process.terminate()
|
|
55
|
+
try:
|
|
56
|
+
cls.server_process.wait(timeout=5)
|
|
57
|
+
except subprocess.TimeoutExpired:
|
|
58
|
+
cls.server_process.kill()
|
|
59
|
+
cls.server_process.wait()
|
|
60
|
+
|
|
61
|
+
time.sleep(0.5)
|
|
62
|
+
gc.collect()
|
|
63
|
+
cls._cleanup_db_files()
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def _start_server(cls) -> None:
|
|
67
|
+
"""Start the jac-scale server in a subprocess."""
|
|
68
|
+
import sys
|
|
69
|
+
|
|
70
|
+
jac_executable = Path(sys.executable).parent / "jac"
|
|
71
|
+
|
|
72
|
+
# Build the command to start the server
|
|
73
|
+
# Use just the filename and set cwd to fixtures directory
|
|
74
|
+
# This is required for proper bytecode caching and module resolution
|
|
75
|
+
cmd = [
|
|
76
|
+
str(jac_executable),
|
|
77
|
+
"start",
|
|
78
|
+
cls.test_file.name,
|
|
79
|
+
"--port",
|
|
80
|
+
str(cls.port),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# Start the server process with cwd set to fixtures directory
|
|
84
|
+
cls.server_process = subprocess.Popen(
|
|
85
|
+
cmd,
|
|
86
|
+
stdout=subprocess.PIPE,
|
|
87
|
+
stderr=subprocess.PIPE,
|
|
88
|
+
text=True,
|
|
89
|
+
cwd=str(cls.fixtures_dir),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
max_attempts = 50
|
|
93
|
+
server_ready = False
|
|
94
|
+
|
|
95
|
+
for _ in range(max_attempts):
|
|
96
|
+
if cls.server_process.poll() is not None:
|
|
97
|
+
stdout, stderr = cls.server_process.communicate()
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
f"Server process terminated unexpectedly.\n"
|
|
100
|
+
f"STDOUT: {stdout}\nSTDERR: {stderr}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
response = requests.get(f"{cls.base_url}/docs", timeout=2)
|
|
105
|
+
if response.status_code in (200, 404):
|
|
106
|
+
print(f"Server started successfully on port {cls.port}")
|
|
107
|
+
server_ready = True
|
|
108
|
+
break
|
|
109
|
+
except (requests.ConnectionError, requests.Timeout):
|
|
110
|
+
time.sleep(2)
|
|
111
|
+
|
|
112
|
+
if not server_ready:
|
|
113
|
+
cls.server_process.terminate()
|
|
114
|
+
try:
|
|
115
|
+
stdout, stderr = cls.server_process.communicate(timeout=2)
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
cls.server_process.kill()
|
|
118
|
+
stdout, stderr = cls.server_process.communicate()
|
|
119
|
+
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"Server failed to start after {max_attempts} attempts.\n"
|
|
122
|
+
f"STDOUT: {stdout}\nSTDERR: {stderr}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def _cleanup_db_files(cls) -> None:
|
|
127
|
+
"""Delete SQLite database files and legacy shelf files."""
|
|
128
|
+
import shutil
|
|
129
|
+
|
|
130
|
+
for pattern in [
|
|
131
|
+
"*.db",
|
|
132
|
+
"*.db-wal",
|
|
133
|
+
"*.db-shm",
|
|
134
|
+
"anchor_store.db.dat",
|
|
135
|
+
"anchor_store.db.bak",
|
|
136
|
+
"anchor_store.db.dir",
|
|
137
|
+
]:
|
|
138
|
+
for db_file in glob.glob(pattern):
|
|
139
|
+
with contextlib.suppress(Exception):
|
|
140
|
+
Path(db_file).unlink()
|
|
141
|
+
|
|
142
|
+
for pattern in ["*.db", "*.db-wal", "*.db-shm"]:
|
|
143
|
+
for db_file in glob.glob(str(cls.fixtures_dir / pattern)):
|
|
144
|
+
with contextlib.suppress(Exception):
|
|
145
|
+
Path(db_file).unlink()
|
|
146
|
+
|
|
147
|
+
client_build_dir = cls.fixtures_dir / ".jac"
|
|
148
|
+
if client_build_dir.exists():
|
|
149
|
+
with contextlib.suppress(Exception):
|
|
150
|
+
shutil.rmtree(client_build_dir)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _extract_transport_response_data(
|
|
154
|
+
json_response: dict[str, Any] | list[Any],
|
|
155
|
+
) -> dict[str, Any] | list[Any]:
|
|
156
|
+
"""Extract data from TransportResponse envelope format."""
|
|
157
|
+
if isinstance(json_response, list) and len(json_response) == 2:
|
|
158
|
+
body: dict[str, Any] = json_response[1]
|
|
159
|
+
json_response = body
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
isinstance(json_response, dict)
|
|
163
|
+
and "ok" in json_response
|
|
164
|
+
and "data" in json_response
|
|
165
|
+
):
|
|
166
|
+
if json_response.get("ok") and json_response.get("data") is not None:
|
|
167
|
+
return json_response["data"]
|
|
168
|
+
elif not json_response.get("ok") and json_response.get("error"):
|
|
169
|
+
error_info = json_response["error"]
|
|
170
|
+
result: dict[str, Any] = {
|
|
171
|
+
"error": error_info.get("message", "Unknown error")
|
|
172
|
+
}
|
|
173
|
+
if "code" in error_info:
|
|
174
|
+
result["error_code"] = error_info["code"]
|
|
175
|
+
if "details" in error_info:
|
|
176
|
+
result["error_details"] = error_info["details"]
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
return json_response
|
|
180
|
+
|
|
181
|
+
def _get_auth_token(self, username: str = "filetest_user") -> str:
|
|
182
|
+
"""Register a user and get auth token."""
|
|
183
|
+
response = requests.post(
|
|
184
|
+
f"{self.base_url}/user/register",
|
|
185
|
+
json={"username": username, "password": "testpass123"},
|
|
186
|
+
timeout=5,
|
|
187
|
+
)
|
|
188
|
+
data = self._extract_transport_response_data(response.json())
|
|
189
|
+
if isinstance(data, dict) and "token" in data:
|
|
190
|
+
return data["token"]
|
|
191
|
+
|
|
192
|
+
# User might already exist, try login
|
|
193
|
+
response = requests.post(
|
|
194
|
+
f"{self.base_url}/user/login",
|
|
195
|
+
json={"username": username, "password": "testpass123"},
|
|
196
|
+
timeout=5,
|
|
197
|
+
)
|
|
198
|
+
data = self._extract_transport_response_data(response.json())
|
|
199
|
+
assert isinstance(data, dict)
|
|
200
|
+
return data["token"]
|
|
201
|
+
|
|
202
|
+
def _create_test_file(
|
|
203
|
+
self, filename: str = "test.txt", content: bytes = b"Hello, World!"
|
|
204
|
+
) -> tuple[str, io.BytesIO]:
|
|
205
|
+
"""Create a test file-like object for upload."""
|
|
206
|
+
file_obj = io.BytesIO(content)
|
|
207
|
+
return filename, file_obj
|
|
208
|
+
|
|
209
|
+
# ========================================================================
|
|
210
|
+
# Single File Upload Tests
|
|
211
|
+
# ========================================================================
|
|
212
|
+
|
|
213
|
+
def test_single_file_upload_basic(self) -> None:
|
|
214
|
+
"""Test basic single file upload."""
|
|
215
|
+
token = self._get_auth_token("single_file_user")
|
|
216
|
+
|
|
217
|
+
filename, file_obj = self._create_test_file("document.txt", b"Test content")
|
|
218
|
+
|
|
219
|
+
response = requests.post(
|
|
220
|
+
f"{self.base_url}/walker/UploadSingleFile",
|
|
221
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
222
|
+
files={"myfile": (filename, file_obj, "text/plain")},
|
|
223
|
+
data={"description": "Test document"},
|
|
224
|
+
timeout=10,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
assert response.status_code == 200
|
|
228
|
+
result = self._extract_transport_response_data(response.json())
|
|
229
|
+
assert isinstance(result, dict)
|
|
230
|
+
|
|
231
|
+
# Check walker reports are in the result
|
|
232
|
+
if "reports" in result:
|
|
233
|
+
reports = result["reports"]
|
|
234
|
+
assert len(reports) > 0
|
|
235
|
+
file_info = reports[0]
|
|
236
|
+
else:
|
|
237
|
+
file_info = result
|
|
238
|
+
|
|
239
|
+
assert file_info["filename"] == "document.txt"
|
|
240
|
+
assert file_info["content_type"] == "text/plain"
|
|
241
|
+
assert file_info["description"] == "Test document"
|
|
242
|
+
|
|
243
|
+
def test_single_file_upload_pdf(self) -> None:
|
|
244
|
+
"""Test uploading a PDF file."""
|
|
245
|
+
token = self._get_auth_token("pdf_upload_user")
|
|
246
|
+
|
|
247
|
+
# Create a minimal PDF-like content
|
|
248
|
+
pdf_content = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF"
|
|
249
|
+
filename, file_obj = self._create_test_file("report.pdf", pdf_content)
|
|
250
|
+
|
|
251
|
+
response = requests.post(
|
|
252
|
+
f"{self.base_url}/walker/UploadSingleFile",
|
|
253
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
254
|
+
files={"myfile": (filename, file_obj, "application/pdf")},
|
|
255
|
+
data={"description": "PDF Report"},
|
|
256
|
+
timeout=10,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
assert response.status_code == 200
|
|
260
|
+
result = self._extract_transport_response_data(response.json())
|
|
261
|
+
assert isinstance(result, dict)
|
|
262
|
+
|
|
263
|
+
file_info = result["reports"][0] if "reports" in result else result
|
|
264
|
+
|
|
265
|
+
assert file_info["filename"] == "report.pdf"
|
|
266
|
+
assert file_info["content_type"] == "application/pdf"
|
|
267
|
+
|
|
268
|
+
def test_single_file_upload_with_empty_description(self) -> None:
|
|
269
|
+
"""Test file upload with default empty description."""
|
|
270
|
+
token = self._get_auth_token("empty_desc_user")
|
|
271
|
+
|
|
272
|
+
filename, file_obj = self._create_test_file("image.png", b"\x89PNG\r\n\x1a\n")
|
|
273
|
+
|
|
274
|
+
response = requests.post(
|
|
275
|
+
f"{self.base_url}/walker/UploadSingleFile",
|
|
276
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
277
|
+
files={"myfile": (filename, file_obj, "image/png")},
|
|
278
|
+
timeout=10,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
assert response.status_code == 200
|
|
282
|
+
result = self._extract_transport_response_data(response.json())
|
|
283
|
+
assert isinstance(result, dict)
|
|
284
|
+
|
|
285
|
+
file_info = result["reports"][0] if "reports" in result else result
|
|
286
|
+
|
|
287
|
+
assert file_info["filename"] == "image.png"
|
|
288
|
+
# Default description should be empty string
|
|
289
|
+
assert file_info["description"] == ""
|
|
290
|
+
|
|
291
|
+
# ========================================================================
|
|
292
|
+
# Authentication Tests
|
|
293
|
+
# ========================================================================
|
|
294
|
+
|
|
295
|
+
def test_file_upload_requires_auth(self) -> None:
|
|
296
|
+
"""Test that private file upload walker requires authentication."""
|
|
297
|
+
filename, file_obj = self._create_test_file("secret.txt", b"Secret content")
|
|
298
|
+
|
|
299
|
+
# Try to upload without auth token
|
|
300
|
+
response = requests.post(
|
|
301
|
+
f"{self.base_url}/walker/SecureFileUpload",
|
|
302
|
+
files={"document": (filename, file_obj, "text/plain")},
|
|
303
|
+
data={"notes": "Confidential"},
|
|
304
|
+
timeout=10,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Should fail with 401 Unauthorized
|
|
308
|
+
assert response.status_code in (401, 200) # 200 if error is in response body
|
|
309
|
+
if response.status_code == 200:
|
|
310
|
+
result = self._extract_transport_response_data(response.json())
|
|
311
|
+
assert isinstance(result, dict)
|
|
312
|
+
assert "error" in result
|
|
313
|
+
|
|
314
|
+
def test_file_upload_with_valid_auth(self) -> None:
|
|
315
|
+
"""Test file upload with valid authentication."""
|
|
316
|
+
token = self._get_auth_token("secure_upload_user")
|
|
317
|
+
|
|
318
|
+
filename, file_obj = self._create_test_file("confidential.doc", b"Secret data")
|
|
319
|
+
|
|
320
|
+
response = requests.post(
|
|
321
|
+
f"{self.base_url}/walker/SecureFileUpload",
|
|
322
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
323
|
+
files={"document": (filename, file_obj, "application/msword")},
|
|
324
|
+
data={"notes": "Important document"},
|
|
325
|
+
timeout=10,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
assert response.status_code == 200
|
|
329
|
+
result = self._extract_transport_response_data(response.json())
|
|
330
|
+
assert isinstance(result, dict)
|
|
331
|
+
|
|
332
|
+
file_info = result["reports"][0] if "reports" in result else result
|
|
333
|
+
|
|
334
|
+
assert file_info["filename"] == "confidential.doc"
|
|
335
|
+
assert file_info["notes"] == "Important document"
|
|
336
|
+
assert file_info["authenticated"] is True
|
|
337
|
+
|
|
338
|
+
def test_public_file_upload_no_auth_required(self) -> None:
|
|
339
|
+
"""Test that public file upload walker works without authentication."""
|
|
340
|
+
filename, file_obj = self._create_test_file("public.txt", b"Public content")
|
|
341
|
+
|
|
342
|
+
response = requests.post(
|
|
343
|
+
f"{self.base_url}/walker/PublicFileUpload",
|
|
344
|
+
files={"attachment": (filename, file_obj, "text/plain")},
|
|
345
|
+
timeout=10,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
assert response.status_code == 200
|
|
349
|
+
result = self._extract_transport_response_data(response.json())
|
|
350
|
+
assert isinstance(result, dict)
|
|
351
|
+
|
|
352
|
+
file_info = result["reports"][0] if "reports" in result else result
|
|
353
|
+
|
|
354
|
+
assert file_info["filename"] == "public.txt"
|
|
355
|
+
assert file_info["content_type"] == "text/plain"
|
|
356
|
+
|
|
357
|
+
# ========================================================================
|
|
358
|
+
# File Type Tests
|
|
359
|
+
# ========================================================================
|
|
360
|
+
|
|
361
|
+
def test_upload_various_file_types(self) -> None:
|
|
362
|
+
"""Test uploading various file types."""
|
|
363
|
+
token = self._get_auth_token("various_types_user")
|
|
364
|
+
|
|
365
|
+
file_types = [
|
|
366
|
+
("document.json", b'{"key": "value"}', "application/json"),
|
|
367
|
+
("image.jpg", b"\xff\xd8\xff\xe0", "image/jpeg"),
|
|
368
|
+
("script.js", b"console.log('hello');", "application/javascript"),
|
|
369
|
+
("data.csv", b"col1,col2\nval1,val2", "text/csv"),
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
for filename, content, content_type in file_types:
|
|
373
|
+
file_obj = io.BytesIO(content)
|
|
374
|
+
|
|
375
|
+
response = requests.post(
|
|
376
|
+
f"{self.base_url}/walker/UploadSingleFile",
|
|
377
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
378
|
+
files={"myfile": (filename, file_obj, content_type)},
|
|
379
|
+
data={"description": f"Testing {content_type}"},
|
|
380
|
+
timeout=10,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
assert response.status_code == 200, f"Failed for {filename}"
|
|
384
|
+
result = self._extract_transport_response_data(response.json())
|
|
385
|
+
assert isinstance(result, dict)
|
|
386
|
+
|
|
387
|
+
file_info = result["reports"][0] if "reports" in result else result
|
|
388
|
+
|
|
389
|
+
assert file_info["filename"] == filename
|
|
390
|
+
assert file_info["content_type"] == content_type
|
|
391
|
+
|
|
392
|
+
# ========================================================================
|
|
393
|
+
# Error Handling Tests
|
|
394
|
+
# ========================================================================
|
|
395
|
+
|
|
396
|
+
def test_upload_missing_required_file(self) -> None:
|
|
397
|
+
"""Test upload endpoint when required file is missing."""
|
|
398
|
+
token = self._get_auth_token("missing_file_user")
|
|
399
|
+
|
|
400
|
+
# Send request without the required file
|
|
401
|
+
response = requests.post(
|
|
402
|
+
f"{self.base_url}/walker/UploadSingleFile",
|
|
403
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
404
|
+
data={"description": "No file attached"},
|
|
405
|
+
timeout=10,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Should return validation error (422) or error in response
|
|
409
|
+
assert response.status_code in (422, 400, 200)
|
|
410
|
+
result = response.json()
|
|
411
|
+
|
|
412
|
+
if response.status_code == 422:
|
|
413
|
+
# FastAPI validation error
|
|
414
|
+
assert "detail" in result
|
|
415
|
+
elif response.status_code == 200:
|
|
416
|
+
# Error in response body
|
|
417
|
+
extracted = self._extract_transport_response_data(result)
|
|
418
|
+
assert isinstance(extracted, dict)
|
|
419
|
+
assert "error" in extracted
|
|
420
|
+
|
|
421
|
+
def test_upload_large_file(self) -> None:
|
|
422
|
+
"""Test uploading a larger file (1MB)."""
|
|
423
|
+
token = self._get_auth_token("large_file_user")
|
|
424
|
+
|
|
425
|
+
# Create a 1MB file
|
|
426
|
+
large_content = b"X" * (1024 * 1024) # 1MB
|
|
427
|
+
filename, file_obj = self._create_test_file("large_file.bin", large_content)
|
|
428
|
+
|
|
429
|
+
response = requests.post(
|
|
430
|
+
f"{self.base_url}/walker/UploadSingleFile",
|
|
431
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
432
|
+
files={"myfile": (filename, file_obj, "application/octet-stream")},
|
|
433
|
+
data={"description": "Large file test"},
|
|
434
|
+
timeout=30, # Longer timeout for large file
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
assert response.status_code == 200
|
|
438
|
+
result = self._extract_transport_response_data(response.json())
|
|
439
|
+
assert isinstance(result, dict)
|
|
440
|
+
|
|
441
|
+
file_info = result["reports"][0] if "reports" in result else result
|
|
442
|
+
|
|
443
|
+
assert file_info["filename"] == "large_file.bin"
|
|
444
|
+
assert file_info["size"] == 1024 * 1024
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import tarfile
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from kubernetes.client.exceptions import ApiException
|
|
8
|
+
from pytest import MonkeyPatch
|
|
9
|
+
|
|
10
|
+
from jac_scale.targets.kubernetes.utils import kubernetes_utils as utils
|
|
11
|
+
from jac_scale.targets.kubernetes.utils.kubernetes_utils import (
|
|
12
|
+
create_tarball,
|
|
13
|
+
ensure_pvc_exists,
|
|
14
|
+
load_env_variables,
|
|
15
|
+
parse_cpu_quantity,
|
|
16
|
+
parse_memory_quantity,
|
|
17
|
+
validate_resource_limits,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize(
|
|
22
|
+
"raw,expected",
|
|
23
|
+
[
|
|
24
|
+
("500m", 0.5),
|
|
25
|
+
("2", 2.0),
|
|
26
|
+
(" 250 ", 250.0),
|
|
27
|
+
],
|
|
28
|
+
)
|
|
29
|
+
def test_parse_cpu_quantity_valid(raw: str, expected: float) -> None:
|
|
30
|
+
assert parse_cpu_quantity(raw) == pytest.approx(expected)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("raw", ["", "m", " m "])
|
|
34
|
+
def test_parse_cpu_quantity_invalid(raw: str) -> None:
|
|
35
|
+
with pytest.raises(ValueError):
|
|
36
|
+
parse_cpu_quantity(raw)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
"raw,expected",
|
|
41
|
+
[
|
|
42
|
+
("256Mi", float(256 * 1024**2)),
|
|
43
|
+
("1Gi", float(1024**3)),
|
|
44
|
+
("2", 2.0),
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
def test_parse_memory_quantity_valid(raw: str, expected: float) -> None:
|
|
48
|
+
assert parse_memory_quantity(raw) == pytest.approx(expected)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("raw", ["", "Mi", " Gi "])
|
|
52
|
+
def test_parse_memory_quantity_invalid(raw: str) -> None:
|
|
53
|
+
with pytest.raises(ValueError):
|
|
54
|
+
parse_memory_quantity(raw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_validate_resource_limits_accepts_valid_pairs() -> None:
|
|
58
|
+
validate_resource_limits("250m", "500m", "256Mi", "512Mi")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_validate_resource_limits_rejects_lower_limits() -> None:
|
|
62
|
+
with pytest.raises(ValueError):
|
|
63
|
+
validate_resource_limits("500m", "250m", None, None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_validate_resource_limits_rejects_invalid_quantity() -> None:
|
|
67
|
+
with pytest.raises(ValueError):
|
|
68
|
+
validate_resource_limits("abc", "1", None, None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_load_env_variables_reads_env_file(tmp_path: Path) -> None:
|
|
72
|
+
env_dir = tmp_path / "app"
|
|
73
|
+
env_dir.mkdir()
|
|
74
|
+
env_file = env_dir / ".env"
|
|
75
|
+
env_file.write_text("VAR1=1\nVAR2=two\n")
|
|
76
|
+
|
|
77
|
+
env_vars = load_env_variables(str(env_dir))
|
|
78
|
+
|
|
79
|
+
assert {"name": "VAR1", "value": "1"} in env_vars
|
|
80
|
+
assert {"name": "VAR2", "value": "two"} in env_vars
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_ensure_pvc_exists_skips_when_present() -> None:
|
|
84
|
+
core_v1 = MagicMock()
|
|
85
|
+
core_v1.read_namespaced_persistent_volume_claim.return_value = object()
|
|
86
|
+
|
|
87
|
+
ensure_pvc_exists(core_v1, "test-ns", "test-pvc", "5Gi")
|
|
88
|
+
|
|
89
|
+
core_v1.create_namespaced_persistent_volume_claim.assert_not_called()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_ensure_pvc_exists_creates_when_missing() -> None:
|
|
93
|
+
core_v1 = MagicMock()
|
|
94
|
+
core_v1.read_namespaced_persistent_volume_claim.side_effect = ApiException(
|
|
95
|
+
status=404
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
ensure_pvc_exists(
|
|
99
|
+
core_v1,
|
|
100
|
+
namespace="test-ns",
|
|
101
|
+
pvc_name="test-pvc",
|
|
102
|
+
storage_size="10Gi",
|
|
103
|
+
storage_class="fast",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
call_args = core_v1.create_namespaced_persistent_volume_claim.call_args
|
|
107
|
+
assert call_args is not None
|
|
108
|
+
args, kwargs = call_args
|
|
109
|
+
assert kwargs == {}
|
|
110
|
+
assert args[0] == "test-ns"
|
|
111
|
+
body = args[1]
|
|
112
|
+
assert body["metadata"]["name"] == "test-pvc"
|
|
113
|
+
assert body["spec"]["accessModes"] == ["ReadWriteOnce"]
|
|
114
|
+
assert body["spec"]["resources"]["requests"]["storage"] == "10Gi"
|
|
115
|
+
assert body["spec"]["storageClassName"] == "fast"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_cluster_type_detects_aws_by_provider(monkeypatch: MonkeyPatch) -> None:
|
|
119
|
+
class Node:
|
|
120
|
+
def __init__(self, provider_id: str) -> None:
|
|
121
|
+
self.spec = SimpleNamespace(provider_id=provider_id)
|
|
122
|
+
self.metadata = SimpleNamespace(labels={})
|
|
123
|
+
|
|
124
|
+
class Response:
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self.items = [Node("aws://12345")] # type: ignore[arg-type]
|
|
127
|
+
|
|
128
|
+
class FakeApi:
|
|
129
|
+
def list_node(self) -> Response:
|
|
130
|
+
return Response()
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(utils.client, "CoreV1Api", lambda: FakeApi())
|
|
133
|
+
|
|
134
|
+
assert utils.cluster_type() == "aws"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_create_tarball_captures_files(tmp_path: Path) -> None:
|
|
138
|
+
source_dir = tmp_path / "src"
|
|
139
|
+
source_dir.mkdir()
|
|
140
|
+
file_path = source_dir / "hello.txt"
|
|
141
|
+
file_path.write_text("hello")
|
|
142
|
+
tar_path = tmp_path / "archive.tar.gz"
|
|
143
|
+
|
|
144
|
+
create_tarball(str(source_dir), str(tar_path))
|
|
145
|
+
|
|
146
|
+
assert tar_path.exists()
|
|
147
|
+
with tarfile.open(tar_path, "r:gz") as tar:
|
|
148
|
+
member_names = tar.getnames()
|
|
149
|
+
assert "./hello.txt" in member_names
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_create_tarball_missing_source(tmp_path: Path) -> None:
|
|
153
|
+
tar_path = tmp_path / "archive.tar.gz"
|
|
154
|
+
|
|
155
|
+
with pytest.raises(FileNotFoundError):
|
|
156
|
+
create_tarball(str(tmp_path / "missing"), str(tar_path))
|