jac-scale 0.1.1__py3-none-any.whl → 0.1.4__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/abstractions/config/app_config.jac +5 -2
- jac_scale/config_loader.jac +2 -1
- jac_scale/context.jac +2 -1
- jac_scale/factories/storage_factory.jac +75 -0
- jac_scale/google_sso_provider.jac +85 -0
- jac_scale/impl/config_loader.impl.jac +28 -3
- jac_scale/impl/context.impl.jac +1 -0
- jac_scale/impl/serve.impl.jac +749 -266
- jac_scale/impl/user_manager.impl.jac +349 -0
- jac_scale/impl/webhook.impl.jac +212 -0
- jac_scale/jserver/impl/jfast_api.impl.jac +4 -0
- jac_scale/memory_hierarchy.jac +3 -1
- jac_scale/plugin.jac +46 -3
- jac_scale/plugin_config.jac +28 -1
- jac_scale/serve.jac +33 -16
- jac_scale/sso_provider.jac +72 -0
- jac_scale/targets/kubernetes/kubernetes_config.jac +9 -15
- jac_scale/targets/kubernetes/kubernetes_target.jac +174 -15
- jac_scale/tests/fixtures/scale-feats/components/Button.cl.jac +32 -0
- jac_scale/tests/fixtures/scale-feats/main.jac +147 -0
- jac_scale/tests/fixtures/test_api.jac +89 -0
- jac_scale/tests/fixtures/test_restspec.jac +88 -0
- jac_scale/tests/test_deploy_k8s.py +2 -1
- jac_scale/tests/test_examples.py +180 -5
- jac_scale/tests/test_hooks.py +39 -0
- jac_scale/tests/test_restspec.py +289 -0
- jac_scale/tests/test_serve.py +411 -4
- jac_scale/tests/test_sso.py +273 -284
- jac_scale/tests/test_storage.py +274 -0
- jac_scale/user_manager.jac +49 -0
- jac_scale/webhook.jac +93 -0
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/METADATA +11 -4
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/RECORD +36 -23
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/WHEEL +1 -1
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/entry_points.txt +0 -0
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Test for restspec decorator functionality."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import gc
|
|
5
|
+
import glob
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_free_port() -> int:
|
|
16
|
+
"""Get a free port by binding to port 0 and releasing it."""
|
|
17
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
18
|
+
s.bind(("", 0))
|
|
19
|
+
s.listen(1)
|
|
20
|
+
port = s.getsockname()[1]
|
|
21
|
+
return port
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestRestSpec:
|
|
25
|
+
"""Test restspec decorator functionality."""
|
|
26
|
+
|
|
27
|
+
fixtures_dir: Path
|
|
28
|
+
test_file: Path
|
|
29
|
+
port: int
|
|
30
|
+
base_url: str
|
|
31
|
+
server_process: subprocess.Popen[str] | None = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def setup_class(cls) -> None:
|
|
35
|
+
"""Set up test class - runs once for all tests."""
|
|
36
|
+
cls.fixtures_dir = Path(__file__).parent / "fixtures"
|
|
37
|
+
cls.test_file = cls.fixtures_dir / "test_restspec.jac"
|
|
38
|
+
|
|
39
|
+
if not cls.test_file.exists():
|
|
40
|
+
raise FileNotFoundError(f"Test fixture not found: {cls.test_file}")
|
|
41
|
+
|
|
42
|
+
cls.port = get_free_port()
|
|
43
|
+
cls.base_url = f"http://localhost:{cls.port}"
|
|
44
|
+
|
|
45
|
+
cls._cleanup_db_files()
|
|
46
|
+
cls._start_server()
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def teardown_class(cls) -> None:
|
|
50
|
+
"""Tear down test class - runs once after all tests."""
|
|
51
|
+
if cls.server_process:
|
|
52
|
+
cls.server_process.terminate()
|
|
53
|
+
try:
|
|
54
|
+
cls.server_process.wait(timeout=5)
|
|
55
|
+
except subprocess.TimeoutExpired:
|
|
56
|
+
cls.server_process.kill()
|
|
57
|
+
cls.server_process.wait()
|
|
58
|
+
|
|
59
|
+
time.sleep(0.5)
|
|
60
|
+
gc.collect()
|
|
61
|
+
cls._cleanup_db_files()
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _start_server(cls) -> None:
|
|
65
|
+
"""Start the jac-scale server in a subprocess."""
|
|
66
|
+
import sys
|
|
67
|
+
|
|
68
|
+
jac_executable = Path(sys.executable).parent / "jac"
|
|
69
|
+
cmd = [
|
|
70
|
+
str(jac_executable),
|
|
71
|
+
"start",
|
|
72
|
+
cls.test_file.name,
|
|
73
|
+
"--port",
|
|
74
|
+
str(cls.port),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
cls.server_process = subprocess.Popen(
|
|
78
|
+
cmd,
|
|
79
|
+
stdout=subprocess.PIPE,
|
|
80
|
+
stderr=subprocess.PIPE,
|
|
81
|
+
text=True,
|
|
82
|
+
cwd=str(cls.fixtures_dir),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Wait for server
|
|
86
|
+
max_attempts = 50
|
|
87
|
+
for _ in range(max_attempts):
|
|
88
|
+
if cls.server_process.poll() is not None:
|
|
89
|
+
stdout, stderr = cls.server_process.communicate()
|
|
90
|
+
raise RuntimeError(f"Server died: {stdout}\n{stderr}")
|
|
91
|
+
try:
|
|
92
|
+
requests.get(f"{cls.base_url}/docs", timeout=1)
|
|
93
|
+
return
|
|
94
|
+
except requests.RequestException:
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
|
|
97
|
+
cls.server_process.kill()
|
|
98
|
+
raise RuntimeError("Server failed to start")
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def _cleanup_db_files(cls) -> None:
|
|
102
|
+
"""Cleanup database files."""
|
|
103
|
+
import shutil
|
|
104
|
+
|
|
105
|
+
for pattern in ["*.db", "*.db-wal", "*.db-shm", "anchor_store*"]:
|
|
106
|
+
for f in glob.glob(pattern):
|
|
107
|
+
with contextlib.suppress(Exception):
|
|
108
|
+
Path(f).unlink()
|
|
109
|
+
for pattern in ["*.db", "*.db-wal", "*.db-shm"]:
|
|
110
|
+
for f in glob.glob(str(cls.fixtures_dir / pattern)):
|
|
111
|
+
with contextlib.suppress(Exception):
|
|
112
|
+
Path(f).unlink()
|
|
113
|
+
client_dir = cls.fixtures_dir / ".jac"
|
|
114
|
+
if client_dir.exists():
|
|
115
|
+
with contextlib.suppress(Exception):
|
|
116
|
+
shutil.rmtree(client_dir)
|
|
117
|
+
|
|
118
|
+
def _extract_data(self, response: dict[str, Any] | list[Any]) -> Any: # noqa: ANN401
|
|
119
|
+
# Handle tuple response [status, body]
|
|
120
|
+
if isinstance(response, list) and len(response) == 2:
|
|
121
|
+
response = response[1]
|
|
122
|
+
|
|
123
|
+
if isinstance(response, dict) and "data" in response:
|
|
124
|
+
return response["data"]
|
|
125
|
+
return response
|
|
126
|
+
|
|
127
|
+
def test_custom_method_walker(self) -> None:
|
|
128
|
+
"""Test walker with custom GET method."""
|
|
129
|
+
response = requests.get(f"{self.base_url}/walker/GetWalker", timeout=5)
|
|
130
|
+
assert response.status_code == 200
|
|
131
|
+
data = self._extract_data(response.json())
|
|
132
|
+
assert data["reports"][0]["message"] == "GetWalker executed"
|
|
133
|
+
|
|
134
|
+
def test_custom_path_walker(self) -> None:
|
|
135
|
+
"""Test walker with custom path."""
|
|
136
|
+
response = requests.get(f"{self.base_url}/custom/walker", timeout=5)
|
|
137
|
+
assert response.status_code == 200
|
|
138
|
+
data = self._extract_data(response.json())
|
|
139
|
+
assert data["reports"][0]["message"] == "CustomPathWalker executed"
|
|
140
|
+
assert data["reports"][0]["path"] == "/custom/walker"
|
|
141
|
+
|
|
142
|
+
def test_post_method_walker(self) -> None:
|
|
143
|
+
"""Test walker with explicit POST method."""
|
|
144
|
+
response = requests.post(f"{self.base_url}/walker/PostWalker", timeout=5)
|
|
145
|
+
assert response.status_code == 200
|
|
146
|
+
data = self._extract_data(response.json())
|
|
147
|
+
assert data["reports"][0]["message"] == "PostWalker executed"
|
|
148
|
+
assert data["reports"][0]["method"] == "POST"
|
|
149
|
+
|
|
150
|
+
def test_default_method_walker(self) -> None:
|
|
151
|
+
"""Test walker with default method (POST)."""
|
|
152
|
+
response = requests.post(f"{self.base_url}/walker/DefaultWalker", timeout=5)
|
|
153
|
+
assert response.status_code == 200
|
|
154
|
+
data = self._extract_data(response.json())
|
|
155
|
+
assert data["reports"][0]["message"] == "DefaultWalker executed"
|
|
156
|
+
assert data["reports"][0]["method"] == "DEFAULT"
|
|
157
|
+
|
|
158
|
+
def test_custom_method_func(self) -> None:
|
|
159
|
+
"""Test function with custom GET method."""
|
|
160
|
+
requests.post(
|
|
161
|
+
f"{self.base_url}/user/register", json={"username": "u1", "password": "p1"}
|
|
162
|
+
)
|
|
163
|
+
login = requests.post(
|
|
164
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
165
|
+
)
|
|
166
|
+
token = self._extract_data(login.json())["token"]
|
|
167
|
+
|
|
168
|
+
response = requests.get(
|
|
169
|
+
f"{self.base_url}/function/get_func",
|
|
170
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
171
|
+
timeout=5,
|
|
172
|
+
)
|
|
173
|
+
assert response.status_code == 200
|
|
174
|
+
data = self._extract_data(response.json())
|
|
175
|
+
assert data["result"]["message"] == "get_func executed"
|
|
176
|
+
|
|
177
|
+
def test_custom_path_func(self) -> None:
|
|
178
|
+
"""Test function with custom path."""
|
|
179
|
+
# Use existing user token if possible, but simplest is fresh login
|
|
180
|
+
login = requests.post(
|
|
181
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
182
|
+
)
|
|
183
|
+
token = self._extract_data(login.json())["token"]
|
|
184
|
+
|
|
185
|
+
response = requests.get(
|
|
186
|
+
f"{self.base_url}/custom/func",
|
|
187
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
188
|
+
timeout=5,
|
|
189
|
+
)
|
|
190
|
+
assert response.status_code == 200
|
|
191
|
+
data = self._extract_data(response.json())
|
|
192
|
+
assert data["result"]["message"] == "custom_path_func executed"
|
|
193
|
+
assert data["result"]["path"] == "/custom/func"
|
|
194
|
+
|
|
195
|
+
def test_post_method_func(self) -> None:
|
|
196
|
+
"""Test function with explicit POST method."""
|
|
197
|
+
# Use existing user token if possible, but simplest is fresh login
|
|
198
|
+
login = requests.post(
|
|
199
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
200
|
+
)
|
|
201
|
+
token = self._extract_data(login.json())["token"]
|
|
202
|
+
|
|
203
|
+
response = requests.post(
|
|
204
|
+
f"{self.base_url}/function/post_func",
|
|
205
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
206
|
+
timeout=5,
|
|
207
|
+
)
|
|
208
|
+
assert response.status_code == 200
|
|
209
|
+
data = self._extract_data(response.json())
|
|
210
|
+
assert data["result"]["message"] == "post_func executed"
|
|
211
|
+
assert data["result"]["method"] == "POST"
|
|
212
|
+
|
|
213
|
+
def test_default_method_func(self) -> None:
|
|
214
|
+
"""Test function with default method (POST)."""
|
|
215
|
+
# Use existing user token if possible, but simplest is fresh login
|
|
216
|
+
login = requests.post(
|
|
217
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
218
|
+
)
|
|
219
|
+
token = self._extract_data(login.json())["token"]
|
|
220
|
+
|
|
221
|
+
response = requests.post(
|
|
222
|
+
f"{self.base_url}/function/default_func",
|
|
223
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
224
|
+
timeout=5,
|
|
225
|
+
)
|
|
226
|
+
assert response.status_code == 200
|
|
227
|
+
data = self._extract_data(response.json())
|
|
228
|
+
assert data["result"]["message"] == "default_func executed"
|
|
229
|
+
assert data["result"]["method"] == "DEFAULT"
|
|
230
|
+
|
|
231
|
+
def test_get_walker_with_params(self) -> None:
|
|
232
|
+
"""Test walker with GET method and query parameters."""
|
|
233
|
+
# Parameters should be passed as query string
|
|
234
|
+
params: dict[str, str | int] = {"name": "Alice", "age": 30}
|
|
235
|
+
response = requests.get(
|
|
236
|
+
f"{self.base_url}/walker/GetWalkerWithParams",
|
|
237
|
+
params=params,
|
|
238
|
+
timeout=5,
|
|
239
|
+
)
|
|
240
|
+
assert response.status_code == 200
|
|
241
|
+
data = self._extract_data(response.json())
|
|
242
|
+
assert data["reports"][0]["message"] == "GetWalkerWithParams executed"
|
|
243
|
+
assert data["reports"][0]["name"] == "Alice"
|
|
244
|
+
assert data["reports"][0]["age"] == 30
|
|
245
|
+
|
|
246
|
+
def test_get_func_with_params(self) -> None:
|
|
247
|
+
"""Test function with GET method and query parameters."""
|
|
248
|
+
# Use existing user token if possible, but simplest is fresh login
|
|
249
|
+
login = requests.post(
|
|
250
|
+
f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
|
|
251
|
+
)
|
|
252
|
+
token = self._extract_data(login.json())["token"]
|
|
253
|
+
|
|
254
|
+
# Parameters should be passed as query string
|
|
255
|
+
params: dict[str, str | int] = {"name": "Bob", "age": 40}
|
|
256
|
+
response = requests.get(
|
|
257
|
+
f"{self.base_url}/function/get_func_with_params",
|
|
258
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
259
|
+
params=params,
|
|
260
|
+
timeout=5,
|
|
261
|
+
)
|
|
262
|
+
assert response.status_code == 200
|
|
263
|
+
data = self._extract_data(response.json())
|
|
264
|
+
assert data["result"]["message"] == "get_func_with_params executed"
|
|
265
|
+
assert data["result"]["name"] == "Bob"
|
|
266
|
+
assert data["result"]["age"] == 40
|
|
267
|
+
|
|
268
|
+
def test_openapi_specs(self) -> None:
|
|
269
|
+
"""Verify OpenAPI documentation reflects custom paths and methods."""
|
|
270
|
+
spec = requests.get(f"{self.base_url}/openapi.json").json()
|
|
271
|
+
paths = spec["paths"]
|
|
272
|
+
|
|
273
|
+
assert "/custom/walker" in paths
|
|
274
|
+
assert "get" in paths["/custom/walker"]
|
|
275
|
+
|
|
276
|
+
assert "/custom/func" in paths
|
|
277
|
+
assert "get" in paths["/custom/func"]
|
|
278
|
+
|
|
279
|
+
assert "/walker/GetWalker" in paths
|
|
280
|
+
assert "get" in paths["/walker/GetWalker"]
|
|
281
|
+
assert "post" not in paths["/walker/GetWalker"]
|
|
282
|
+
|
|
283
|
+
assert "/walker/PostWalker" in paths
|
|
284
|
+
assert "post" in paths["/walker/PostWalker"]
|
|
285
|
+
assert "get" not in paths["/walker/PostWalker"]
|
|
286
|
+
|
|
287
|
+
assert "/walker/DefaultWalker" in paths
|
|
288
|
+
assert "post" in paths["/walker/DefaultWalker"]
|
|
289
|
+
assert "get" not in paths["/walker/DefaultWalker"]
|