juniper-data 0.4.2__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.
- juniper_data/__init__.py +88 -0
- juniper_data/__main__.py +78 -0
- juniper_data/api/__init__.py +10 -0
- juniper_data/api/app.py +111 -0
- juniper_data/api/middleware.py +95 -0
- juniper_data/api/routes/__init__.py +9 -0
- juniper_data/api/routes/datasets.py +414 -0
- juniper_data/api/routes/generators.py +125 -0
- juniper_data/api/routes/health.py +49 -0
- juniper_data/api/security.py +238 -0
- juniper_data/api/settings.py +109 -0
- juniper_data/core/__init__.py +32 -0
- juniper_data/core/artifacts.py +63 -0
- juniper_data/core/dataset_id.py +38 -0
- juniper_data/core/models.py +135 -0
- juniper_data/core/split.py +120 -0
- juniper_data/generators/__init__.py +15 -0
- juniper_data/generators/arc_agi/__init__.py +11 -0
- juniper_data/generators/arc_agi/generator.py +229 -0
- juniper_data/generators/arc_agi/params.py +56 -0
- juniper_data/generators/checkerboard/__init__.py +15 -0
- juniper_data/generators/checkerboard/generator.py +114 -0
- juniper_data/generators/checkerboard/params.py +32 -0
- juniper_data/generators/circles/__init__.py +11 -0
- juniper_data/generators/circles/generator.py +112 -0
- juniper_data/generators/circles/params.py +31 -0
- juniper_data/generators/csv_import/__init__.py +15 -0
- juniper_data/generators/csv_import/generator.py +198 -0
- juniper_data/generators/csv_import/params.py +48 -0
- juniper_data/generators/gaussian/__init__.py +11 -0
- juniper_data/generators/gaussian/generator.py +149 -0
- juniper_data/generators/gaussian/params.py +53 -0
- juniper_data/generators/mnist/__init__.py +11 -0
- juniper_data/generators/mnist/generator.py +124 -0
- juniper_data/generators/mnist/params.py +39 -0
- juniper_data/generators/spiral/__init__.py +57 -0
- juniper_data/generators/spiral/defaults.py +39 -0
- juniper_data/generators/spiral/generator.py +206 -0
- juniper_data/generators/spiral/params.py +148 -0
- juniper_data/generators/xor/__init__.py +11 -0
- juniper_data/generators/xor/generator.py +162 -0
- juniper_data/generators/xor/params.py +30 -0
- juniper_data/storage/__init__.py +120 -0
- juniper_data/storage/base.py +279 -0
- juniper_data/storage/cached.py +211 -0
- juniper_data/storage/hf_store.py +257 -0
- juniper_data/storage/kaggle_store.py +333 -0
- juniper_data/storage/local_fs.py +232 -0
- juniper_data/storage/memory.py +136 -0
- juniper_data/storage/postgres_store.py +373 -0
- juniper_data/storage/redis_store.py +264 -0
- juniper_data/tests/__init__.py +1 -0
- juniper_data/tests/conftest.py +68 -0
- juniper_data/tests/fixtures/generate_golden_datasets.py +199 -0
- juniper_data/tests/integration/__init__.py +1 -0
- juniper_data/tests/integration/test_api.py +283 -0
- juniper_data/tests/integration/test_e2e_workflow.py +378 -0
- juniper_data/tests/integration/test_lifecycle_api.py +304 -0
- juniper_data/tests/integration/test_security_integration.py +189 -0
- juniper_data/tests/integration/test_storage_workflow.py +259 -0
- juniper_data/tests/performance/__init__.py +1 -0
- juniper_data/tests/performance/test_generator_benchmarks.py +178 -0
- juniper_data/tests/performance/test_storage_benchmarks.py +257 -0
- juniper_data/tests/unit/__init__.py +1 -0
- juniper_data/tests/unit/test_api_app.py +206 -0
- juniper_data/tests/unit/test_api_routes.py +407 -0
- juniper_data/tests/unit/test_api_settings.py +100 -0
- juniper_data/tests/unit/test_arc_agi_generator.py +525 -0
- juniper_data/tests/unit/test_artifacts.py +145 -0
- juniper_data/tests/unit/test_cached_store.py +423 -0
- juniper_data/tests/unit/test_checkerboard_generator.py +232 -0
- juniper_data/tests/unit/test_circles_generator.py +256 -0
- juniper_data/tests/unit/test_csv_import_generator.py +345 -0
- juniper_data/tests/unit/test_dataset_id.py +181 -0
- juniper_data/tests/unit/test_gaussian_generator.py +333 -0
- juniper_data/tests/unit/test_hf_store.py +416 -0
- juniper_data/tests/unit/test_init.py +93 -0
- juniper_data/tests/unit/test_kaggle_store.py +469 -0
- juniper_data/tests/unit/test_lifecycle.py +394 -0
- juniper_data/tests/unit/test_main.py +127 -0
- juniper_data/tests/unit/test_middleware.py +79 -0
- juniper_data/tests/unit/test_mnist_generator.py +370 -0
- juniper_data/tests/unit/test_postgres_store.py +490 -0
- juniper_data/tests/unit/test_redis_store.py +500 -0
- juniper_data/tests/unit/test_security.py +281 -0
- juniper_data/tests/unit/test_security_boundaries.py +517 -0
- juniper_data/tests/unit/test_spiral_generator.py +566 -0
- juniper_data/tests/unit/test_split.py +245 -0
- juniper_data/tests/unit/test_storage.py +767 -0
- juniper_data/tests/unit/test_xor_generator.py +223 -0
- juniper_data-0.4.2.dist-info/METADATA +216 -0
- juniper_data-0.4.2.dist-info/RECORD +95 -0
- juniper_data-0.4.2.dist-info/WHEEL +5 -0
- juniper_data-0.4.2.dist-info/licenses/LICENSE +9 -0
- juniper_data-0.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#####################################################################################################################################################################################################
|
|
2
|
+
# Project: Juniper
|
|
3
|
+
# Sub-Project: JuniperData
|
|
4
|
+
# Application: juniper_data
|
|
5
|
+
# File Name: test_storage_benchmarks.py
|
|
6
|
+
# Author: Paul Calnon
|
|
7
|
+
# Version: 0.4.2
|
|
8
|
+
#
|
|
9
|
+
# Date Created: 2026-02-25
|
|
10
|
+
# Last Modified: 2026-02-25
|
|
11
|
+
#
|
|
12
|
+
# License: MIT License
|
|
13
|
+
# Copyright: Copyright (c) 2024-2026 Paul Calnon
|
|
14
|
+
#
|
|
15
|
+
# Description:
|
|
16
|
+
# Performance benchmarks for storage backends.
|
|
17
|
+
# Measures throughput for save, retrieve, list, and delete operations
|
|
18
|
+
# on InMemoryDatasetStore and LocalFSDatasetStore.
|
|
19
|
+
#
|
|
20
|
+
# Usage:
|
|
21
|
+
# # Run benchmarks with timing (disabled by default in addopts):
|
|
22
|
+
# pytest juniper_data/tests/performance/test_storage_benchmarks.py --benchmark-enable -v
|
|
23
|
+
#
|
|
24
|
+
# # Run with autosave for regression tracking:
|
|
25
|
+
# pytest juniper_data/tests/performance/test_storage_benchmarks.py --benchmark-enable --benchmark-autosave
|
|
26
|
+
#
|
|
27
|
+
# References:
|
|
28
|
+
# - RD-009: Performance Test Infrastructure
|
|
29
|
+
# - pytest-benchmark: https://pytest-benchmark.readthedocs.io/
|
|
30
|
+
#####################################################################################################################################################################################################
|
|
31
|
+
|
|
32
|
+
"""Performance benchmarks for storage backends.
|
|
33
|
+
|
|
34
|
+
Benchmarks measure throughput for core storage operations (save, get_meta,
|
|
35
|
+
get_artifact_bytes, list, delete) on InMemoryDatasetStore (baseline) and
|
|
36
|
+
LocalFSDatasetStore (I/O-bound). Optional backends (Redis, PostgreSQL,
|
|
37
|
+
HuggingFace, Kaggle) are excluded as they require external services.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from datetime import UTC, datetime
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
import pytest
|
|
44
|
+
|
|
45
|
+
from juniper_data.core.models import DatasetMeta
|
|
46
|
+
from juniper_data.storage.local_fs import LocalFSDatasetStore
|
|
47
|
+
from juniper_data.storage.memory import InMemoryDatasetStore
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _make_meta(dataset_id: str) -> DatasetMeta:
|
|
51
|
+
"""Create a representative DatasetMeta for benchmarks."""
|
|
52
|
+
return DatasetMeta(
|
|
53
|
+
dataset_id=dataset_id,
|
|
54
|
+
generator="spiral",
|
|
55
|
+
generator_version="0.4.2",
|
|
56
|
+
params={"n_spirals": 2, "n_points_per_spiral": 500, "seed": 42},
|
|
57
|
+
n_samples=1000,
|
|
58
|
+
n_features=2,
|
|
59
|
+
n_classes=2,
|
|
60
|
+
n_train=800,
|
|
61
|
+
n_test=200,
|
|
62
|
+
class_distribution={"0": 500, "1": 500},
|
|
63
|
+
created_at=datetime.now(UTC),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _make_arrays(n_train: int = 800, n_test: int = 200, n_features: int = 2) -> dict[str, np.ndarray]:
|
|
68
|
+
"""Create representative dataset arrays for benchmarks."""
|
|
69
|
+
rng = np.random.default_rng(42)
|
|
70
|
+
return {
|
|
71
|
+
"X_train": rng.random((n_train, n_features), dtype=np.float32),
|
|
72
|
+
"y_train": rng.random((n_train, 2), dtype=np.float32),
|
|
73
|
+
"X_test": rng.random((n_test, n_features), dtype=np.float32),
|
|
74
|
+
"y_test": rng.random((n_test, 2), dtype=np.float32),
|
|
75
|
+
"X_full": rng.random((n_train + n_test, n_features), dtype=np.float32),
|
|
76
|
+
"y_full": rng.random((n_train + n_test, 2), dtype=np.float32),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
81
|
+
# InMemoryDatasetStore Benchmarks (Baseline)
|
|
82
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.performance
|
|
86
|
+
class TestInMemoryStoreBenchmarks:
|
|
87
|
+
"""Benchmark InMemoryDatasetStore operations.
|
|
88
|
+
|
|
89
|
+
In-memory store provides the baseline measurement for storage
|
|
90
|
+
operations without filesystem or network I/O overhead.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def test_save(self, benchmark):
|
|
94
|
+
"""Benchmark save operation."""
|
|
95
|
+
store = InMemoryDatasetStore()
|
|
96
|
+
meta = _make_meta("bench-save")
|
|
97
|
+
arrays = _make_arrays()
|
|
98
|
+
benchmark(store.save, "bench-save", meta, arrays)
|
|
99
|
+
assert store.exists("bench-save")
|
|
100
|
+
|
|
101
|
+
def test_get_meta(self, benchmark):
|
|
102
|
+
"""Benchmark metadata retrieval."""
|
|
103
|
+
store = InMemoryDatasetStore()
|
|
104
|
+
store.save("bench-meta", _make_meta("bench-meta"), _make_arrays())
|
|
105
|
+
result = benchmark(store.get_meta, "bench-meta")
|
|
106
|
+
assert result is not None
|
|
107
|
+
assert result.dataset_id == "bench-meta"
|
|
108
|
+
|
|
109
|
+
def test_get_artifact_bytes(self, benchmark):
|
|
110
|
+
"""Benchmark artifact retrieval (NPZ bytes)."""
|
|
111
|
+
store = InMemoryDatasetStore()
|
|
112
|
+
store.save("bench-artifact", _make_meta("bench-artifact"), _make_arrays())
|
|
113
|
+
result = benchmark(store.get_artifact_bytes, "bench-artifact")
|
|
114
|
+
assert result is not None
|
|
115
|
+
assert len(result) > 0
|
|
116
|
+
|
|
117
|
+
def test_exists(self, benchmark):
|
|
118
|
+
"""Benchmark existence check."""
|
|
119
|
+
store = InMemoryDatasetStore()
|
|
120
|
+
store.save("bench-exists", _make_meta("bench-exists"), _make_arrays())
|
|
121
|
+
result = benchmark(store.exists, "bench-exists")
|
|
122
|
+
assert result is True
|
|
123
|
+
|
|
124
|
+
def test_list_datasets(self, benchmark):
|
|
125
|
+
"""Benchmark list operation with 50 datasets."""
|
|
126
|
+
store = InMemoryDatasetStore()
|
|
127
|
+
arrays = _make_arrays()
|
|
128
|
+
for i in range(50):
|
|
129
|
+
store.save(f"bench-list-{i:03d}", _make_meta(f"bench-list-{i:03d}"), arrays)
|
|
130
|
+
result = benchmark(store.list_datasets, 50, 0)
|
|
131
|
+
assert len(result) == 50
|
|
132
|
+
|
|
133
|
+
def test_delete(self, benchmark):
|
|
134
|
+
"""Benchmark delete operation."""
|
|
135
|
+
store = InMemoryDatasetStore()
|
|
136
|
+
arrays = _make_arrays()
|
|
137
|
+
|
|
138
|
+
def save_and_delete():
|
|
139
|
+
store.save("bench-delete", _make_meta("bench-delete"), arrays)
|
|
140
|
+
return store.delete("bench-delete")
|
|
141
|
+
|
|
142
|
+
result = benchmark(save_and_delete)
|
|
143
|
+
assert result is True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
147
|
+
# LocalFSDatasetStore Benchmarks (I/O-bound)
|
|
148
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.performance
|
|
152
|
+
class TestLocalFSStoreBenchmarks:
|
|
153
|
+
"""Benchmark LocalFSDatasetStore operations.
|
|
154
|
+
|
|
155
|
+
Filesystem store measures I/O-bound performance for JSON metadata
|
|
156
|
+
writes and NPZ artifact serialization/deserialization.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def test_save(self, benchmark, tmp_path):
|
|
160
|
+
"""Benchmark save operation (JSON meta + NPZ artifact)."""
|
|
161
|
+
store = LocalFSDatasetStore(str(tmp_path))
|
|
162
|
+
meta = _make_meta("bench-save")
|
|
163
|
+
arrays = _make_arrays()
|
|
164
|
+
benchmark(store.save, "bench-save", meta, arrays)
|
|
165
|
+
assert store.exists("bench-save")
|
|
166
|
+
|
|
167
|
+
def test_get_meta(self, benchmark, tmp_path):
|
|
168
|
+
"""Benchmark metadata retrieval from filesystem."""
|
|
169
|
+
store = LocalFSDatasetStore(str(tmp_path))
|
|
170
|
+
store.save("bench-meta", _make_meta("bench-meta"), _make_arrays())
|
|
171
|
+
result = benchmark(store.get_meta, "bench-meta")
|
|
172
|
+
assert result is not None
|
|
173
|
+
assert result.dataset_id == "bench-meta"
|
|
174
|
+
|
|
175
|
+
def test_get_artifact_bytes(self, benchmark, tmp_path):
|
|
176
|
+
"""Benchmark artifact retrieval from filesystem."""
|
|
177
|
+
store = LocalFSDatasetStore(str(tmp_path))
|
|
178
|
+
store.save("bench-artifact", _make_meta("bench-artifact"), _make_arrays())
|
|
179
|
+
result = benchmark(store.get_artifact_bytes, "bench-artifact")
|
|
180
|
+
assert result is not None
|
|
181
|
+
assert len(result) > 0
|
|
182
|
+
|
|
183
|
+
def test_exists(self, benchmark, tmp_path):
|
|
184
|
+
"""Benchmark existence check on filesystem."""
|
|
185
|
+
store = LocalFSDatasetStore(str(tmp_path))
|
|
186
|
+
store.save("bench-exists", _make_meta("bench-exists"), _make_arrays())
|
|
187
|
+
result = benchmark(store.exists, "bench-exists")
|
|
188
|
+
assert result is True
|
|
189
|
+
|
|
190
|
+
def test_list_datasets(self, benchmark, tmp_path):
|
|
191
|
+
"""Benchmark list operation with 50 datasets on filesystem."""
|
|
192
|
+
store = LocalFSDatasetStore(str(tmp_path))
|
|
193
|
+
arrays = _make_arrays()
|
|
194
|
+
for i in range(50):
|
|
195
|
+
store.save(f"bench-list-{i:03d}", _make_meta(f"bench-list-{i:03d}"), arrays)
|
|
196
|
+
result = benchmark(store.list_datasets, 50, 0)
|
|
197
|
+
assert len(result) == 50
|
|
198
|
+
|
|
199
|
+
def test_delete(self, benchmark, tmp_path):
|
|
200
|
+
"""Benchmark delete operation on filesystem."""
|
|
201
|
+
store = LocalFSDatasetStore(str(tmp_path))
|
|
202
|
+
arrays = _make_arrays()
|
|
203
|
+
|
|
204
|
+
def save_and_delete():
|
|
205
|
+
store.save("bench-delete", _make_meta("bench-delete"), arrays)
|
|
206
|
+
return store.delete("bench-delete")
|
|
207
|
+
|
|
208
|
+
result = benchmark(save_and_delete)
|
|
209
|
+
assert result is True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
213
|
+
# Dataset Size Scaling Benchmarks
|
|
214
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.mark.performance
|
|
218
|
+
class TestStorageScaling:
|
|
219
|
+
"""Benchmark storage throughput across dataset sizes.
|
|
220
|
+
|
|
221
|
+
Measures how save/retrieve times scale with increasing dataset
|
|
222
|
+
sizes (points * features), using InMemoryDatasetStore to isolate
|
|
223
|
+
serialization overhead from filesystem I/O.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
@pytest.mark.parametrize(
|
|
227
|
+
("n_train", "n_test"),
|
|
228
|
+
[(80, 20), (800, 200), (4000, 1000), (8000, 2000)],
|
|
229
|
+
ids=["100pts", "1000pts", "5000pts", "10000pts"],
|
|
230
|
+
)
|
|
231
|
+
def test_save_scaling(self, benchmark, n_train, n_test):
|
|
232
|
+
"""Benchmark save with increasing dataset sizes."""
|
|
233
|
+
store = InMemoryDatasetStore()
|
|
234
|
+
meta = _make_meta("bench-scale")
|
|
235
|
+
meta.n_train = n_train
|
|
236
|
+
meta.n_test = n_test
|
|
237
|
+
meta.n_samples = n_train + n_test
|
|
238
|
+
arrays = _make_arrays(n_train=n_train, n_test=n_test)
|
|
239
|
+
benchmark(store.save, "bench-scale", meta, arrays)
|
|
240
|
+
assert store.exists("bench-scale")
|
|
241
|
+
|
|
242
|
+
@pytest.mark.parametrize(
|
|
243
|
+
("n_train", "n_test"),
|
|
244
|
+
[(80, 20), (800, 200), (4000, 1000), (8000, 2000)],
|
|
245
|
+
ids=["100pts", "1000pts", "5000pts", "10000pts"],
|
|
246
|
+
)
|
|
247
|
+
def test_retrieve_scaling(self, benchmark, n_train, n_test):
|
|
248
|
+
"""Benchmark artifact retrieval with increasing dataset sizes."""
|
|
249
|
+
store = InMemoryDatasetStore()
|
|
250
|
+
meta = _make_meta("bench-scale")
|
|
251
|
+
meta.n_train = n_train
|
|
252
|
+
meta.n_test = n_test
|
|
253
|
+
meta.n_samples = n_train + n_test
|
|
254
|
+
arrays = _make_arrays(n_train=n_train, n_test=n_test)
|
|
255
|
+
store.save("bench-scale", meta, arrays)
|
|
256
|
+
result = benchmark(store.get_artifact_bytes, "bench-scale")
|
|
257
|
+
assert result is not None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Unit tests for Juniper Data."""
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Unit tests for the FastAPI application factory and configuration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
|
|
10
|
+
from juniper_data import __version__
|
|
11
|
+
from juniper_data.api.app import create_app, lifespan
|
|
12
|
+
from juniper_data.api.routes import datasets
|
|
13
|
+
from juniper_data.api.settings import Settings
|
|
14
|
+
from juniper_data.storage.memory import InMemoryDatasetStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def test_settings() -> Settings:
|
|
19
|
+
"""Create test settings."""
|
|
20
|
+
return Settings(
|
|
21
|
+
storage_path="/tmp/juniper_test",
|
|
22
|
+
host="127.0.0.1",
|
|
23
|
+
port=8200,
|
|
24
|
+
log_level="DEBUG",
|
|
25
|
+
cors_origins=["http://localhost:3000"],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def memory_store() -> InMemoryDatasetStore:
|
|
31
|
+
"""Create in-memory store for testing."""
|
|
32
|
+
return InMemoryDatasetStore()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.unit
|
|
36
|
+
class TestCreateApp:
|
|
37
|
+
"""Tests for the create_app factory function."""
|
|
38
|
+
|
|
39
|
+
def test_create_app_returns_fastapi_instance(self, test_settings: Settings) -> None:
|
|
40
|
+
"""Test create_app returns a FastAPI instance."""
|
|
41
|
+
app = create_app(settings=test_settings)
|
|
42
|
+
assert isinstance(app, FastAPI)
|
|
43
|
+
|
|
44
|
+
def test_create_app_sets_title(self, test_settings: Settings) -> None:
|
|
45
|
+
"""Test app has correct title."""
|
|
46
|
+
app = create_app(settings=test_settings)
|
|
47
|
+
assert app.title == "Juniper Data API"
|
|
48
|
+
|
|
49
|
+
def test_create_app_sets_version(self, test_settings: Settings) -> None:
|
|
50
|
+
"""Test app has correct version."""
|
|
51
|
+
app = create_app(settings=test_settings)
|
|
52
|
+
assert app.version == __version__
|
|
53
|
+
|
|
54
|
+
def test_create_app_stores_settings(self, test_settings: Settings) -> None:
|
|
55
|
+
"""Test settings are stored in app state."""
|
|
56
|
+
app = create_app(settings=test_settings)
|
|
57
|
+
assert app.state.settings == test_settings
|
|
58
|
+
|
|
59
|
+
def test_create_app_includes_health_router(self, test_settings: Settings) -> None:
|
|
60
|
+
"""Test health router is included."""
|
|
61
|
+
app = create_app(settings=test_settings)
|
|
62
|
+
routes = [getattr(route, "path", None) for route in app.routes]
|
|
63
|
+
assert "/v1/health" in routes
|
|
64
|
+
|
|
65
|
+
def test_create_app_includes_generators_router(self, test_settings: Settings) -> None:
|
|
66
|
+
"""Test generators router is included."""
|
|
67
|
+
app = create_app(settings=test_settings)
|
|
68
|
+
routes = [getattr(route, "path", None) for route in app.routes]
|
|
69
|
+
assert "/v1/generators" in routes
|
|
70
|
+
|
|
71
|
+
def test_create_app_includes_datasets_router(self, test_settings: Settings) -> None:
|
|
72
|
+
"""Test datasets router is included."""
|
|
73
|
+
app = create_app(settings=test_settings)
|
|
74
|
+
routes = [getattr(route, "path", None) for route in app.routes]
|
|
75
|
+
assert "/v1/datasets" in routes
|
|
76
|
+
|
|
77
|
+
def test_create_app_uses_default_settings_when_none_provided(self) -> None:
|
|
78
|
+
"""Test create_app loads settings from environment when not provided."""
|
|
79
|
+
with patch("juniper_data.api.app.get_settings") as mock_get:
|
|
80
|
+
mock_settings = Settings()
|
|
81
|
+
mock_get.return_value = mock_settings
|
|
82
|
+
app = create_app(settings=None)
|
|
83
|
+
mock_get.assert_called_once()
|
|
84
|
+
assert app.state.settings == mock_settings
|
|
85
|
+
|
|
86
|
+
def test_create_app_cors_middleware_added(self, test_settings: Settings) -> None:
|
|
87
|
+
"""Test CORS middleware is configured."""
|
|
88
|
+
app = create_app(settings=test_settings)
|
|
89
|
+
middleware_classes = [getattr(m.cls, "__name__", None) for m in app.user_middleware]
|
|
90
|
+
assert "CORSMiddleware" in middleware_classes
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.unit
|
|
94
|
+
class TestExceptionHandlers:
|
|
95
|
+
"""Tests for custom exception handlers."""
|
|
96
|
+
|
|
97
|
+
def test_value_error_returns_400(self, test_settings: Settings, memory_store: InMemoryDatasetStore) -> None:
|
|
98
|
+
"""Test ValueError is handled with 400 status."""
|
|
99
|
+
app = create_app(settings=test_settings)
|
|
100
|
+
datasets.set_store(memory_store)
|
|
101
|
+
|
|
102
|
+
@app.get("/test-value-error")
|
|
103
|
+
async def raise_value_error():
|
|
104
|
+
raise ValueError("Test error message")
|
|
105
|
+
|
|
106
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
107
|
+
response = client.get("/test-value-error")
|
|
108
|
+
|
|
109
|
+
assert response.status_code == 400
|
|
110
|
+
assert response.json()["detail"] == "Test error message"
|
|
111
|
+
|
|
112
|
+
def test_general_exception_returns_500(self, test_settings: Settings, memory_store: InMemoryDatasetStore) -> None:
|
|
113
|
+
"""Test unhandled Exception returns 500 status."""
|
|
114
|
+
app = create_app(settings=test_settings)
|
|
115
|
+
datasets.set_store(memory_store)
|
|
116
|
+
|
|
117
|
+
@app.get("/test-general-error")
|
|
118
|
+
async def raise_general_error():
|
|
119
|
+
raise RuntimeError("Unexpected error")
|
|
120
|
+
|
|
121
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
122
|
+
|
|
123
|
+
with patch("logging.Logger.exception"):
|
|
124
|
+
response = client.get("/test-general-error")
|
|
125
|
+
|
|
126
|
+
assert response.status_code == 500
|
|
127
|
+
assert response.json()["detail"] == "Internal server error"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.unit
|
|
131
|
+
class TestLifespan:
|
|
132
|
+
"""Tests for the lifespan context manager."""
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_lifespan_initializes_store(self, test_settings: Settings) -> None:
|
|
136
|
+
"""Test lifespan sets up the dataset store."""
|
|
137
|
+
app = FastAPI()
|
|
138
|
+
app.state.settings = test_settings
|
|
139
|
+
|
|
140
|
+
with patch("juniper_data.api.app.LocalFSDatasetStore") as MockStore:
|
|
141
|
+
mock_store = MagicMock()
|
|
142
|
+
MockStore.return_value = mock_store
|
|
143
|
+
|
|
144
|
+
with patch("juniper_data.api.app.datasets") as mock_datasets:
|
|
145
|
+
async with lifespan(app):
|
|
146
|
+
MockStore.assert_called_once()
|
|
147
|
+
mock_datasets.set_store.assert_called_once_with(mock_store)
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_lifespan_logs_startup_message(self, test_settings: Settings) -> None:
|
|
151
|
+
"""Test lifespan logs startup message."""
|
|
152
|
+
app = FastAPI()
|
|
153
|
+
app.state.settings = test_settings
|
|
154
|
+
|
|
155
|
+
with patch("juniper_data.api.app.LocalFSDatasetStore"):
|
|
156
|
+
with patch("juniper_data.api.app.datasets"):
|
|
157
|
+
with patch("logging.Logger.info") as mock_info:
|
|
158
|
+
async with lifespan(app):
|
|
159
|
+
startup_calls = [call for call in mock_info.call_args_list if "starting" in str(call).lower()]
|
|
160
|
+
assert len(startup_calls) >= 1
|
|
161
|
+
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_lifespan_logs_shutdown_message(self, test_settings: Settings) -> None:
|
|
164
|
+
"""Test lifespan logs shutdown message."""
|
|
165
|
+
app = FastAPI()
|
|
166
|
+
app.state.settings = test_settings
|
|
167
|
+
|
|
168
|
+
with patch("juniper_data.api.app.LocalFSDatasetStore"):
|
|
169
|
+
with patch("juniper_data.api.app.datasets"):
|
|
170
|
+
with patch("logging.Logger.info") as mock_info:
|
|
171
|
+
async with lifespan(app):
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
shutdown_calls = [call for call in mock_info.call_args_list if "shutting" in str(call).lower()]
|
|
175
|
+
assert len(shutdown_calls) >= 1
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_lifespan_configures_logging(self, test_settings: Settings) -> None:
|
|
179
|
+
"""Test lifespan configures logging with correct level."""
|
|
180
|
+
app = FastAPI()
|
|
181
|
+
app.state.settings = test_settings
|
|
182
|
+
|
|
183
|
+
with patch("juniper_data.api.app.LocalFSDatasetStore"):
|
|
184
|
+
with patch("juniper_data.api.app.datasets"):
|
|
185
|
+
with patch("logging.basicConfig") as mock_config:
|
|
186
|
+
async with lifespan(app):
|
|
187
|
+
mock_config.assert_called_once()
|
|
188
|
+
call_kwargs = mock_config.call_args[1]
|
|
189
|
+
assert call_kwargs["level"] == logging.DEBUG
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@pytest.mark.unit
|
|
193
|
+
class TestGlobalApp:
|
|
194
|
+
"""Tests for the global app instance."""
|
|
195
|
+
|
|
196
|
+
def test_global_app_exists(self) -> None:
|
|
197
|
+
"""Test global app is created at module level."""
|
|
198
|
+
from juniper_data.api.app import app
|
|
199
|
+
|
|
200
|
+
assert isinstance(app, FastAPI)
|
|
201
|
+
|
|
202
|
+
def test_global_app_has_correct_title(self) -> None:
|
|
203
|
+
"""Test global app has correct title."""
|
|
204
|
+
from juniper_data.api.app import app
|
|
205
|
+
|
|
206
|
+
assert app.title == "Juniper Data API"
|