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.
Files changed (95) hide show
  1. juniper_data/__init__.py +88 -0
  2. juniper_data/__main__.py +78 -0
  3. juniper_data/api/__init__.py +10 -0
  4. juniper_data/api/app.py +111 -0
  5. juniper_data/api/middleware.py +95 -0
  6. juniper_data/api/routes/__init__.py +9 -0
  7. juniper_data/api/routes/datasets.py +414 -0
  8. juniper_data/api/routes/generators.py +125 -0
  9. juniper_data/api/routes/health.py +49 -0
  10. juniper_data/api/security.py +238 -0
  11. juniper_data/api/settings.py +109 -0
  12. juniper_data/core/__init__.py +32 -0
  13. juniper_data/core/artifacts.py +63 -0
  14. juniper_data/core/dataset_id.py +38 -0
  15. juniper_data/core/models.py +135 -0
  16. juniper_data/core/split.py +120 -0
  17. juniper_data/generators/__init__.py +15 -0
  18. juniper_data/generators/arc_agi/__init__.py +11 -0
  19. juniper_data/generators/arc_agi/generator.py +229 -0
  20. juniper_data/generators/arc_agi/params.py +56 -0
  21. juniper_data/generators/checkerboard/__init__.py +15 -0
  22. juniper_data/generators/checkerboard/generator.py +114 -0
  23. juniper_data/generators/checkerboard/params.py +32 -0
  24. juniper_data/generators/circles/__init__.py +11 -0
  25. juniper_data/generators/circles/generator.py +112 -0
  26. juniper_data/generators/circles/params.py +31 -0
  27. juniper_data/generators/csv_import/__init__.py +15 -0
  28. juniper_data/generators/csv_import/generator.py +198 -0
  29. juniper_data/generators/csv_import/params.py +48 -0
  30. juniper_data/generators/gaussian/__init__.py +11 -0
  31. juniper_data/generators/gaussian/generator.py +149 -0
  32. juniper_data/generators/gaussian/params.py +53 -0
  33. juniper_data/generators/mnist/__init__.py +11 -0
  34. juniper_data/generators/mnist/generator.py +124 -0
  35. juniper_data/generators/mnist/params.py +39 -0
  36. juniper_data/generators/spiral/__init__.py +57 -0
  37. juniper_data/generators/spiral/defaults.py +39 -0
  38. juniper_data/generators/spiral/generator.py +206 -0
  39. juniper_data/generators/spiral/params.py +148 -0
  40. juniper_data/generators/xor/__init__.py +11 -0
  41. juniper_data/generators/xor/generator.py +162 -0
  42. juniper_data/generators/xor/params.py +30 -0
  43. juniper_data/storage/__init__.py +120 -0
  44. juniper_data/storage/base.py +279 -0
  45. juniper_data/storage/cached.py +211 -0
  46. juniper_data/storage/hf_store.py +257 -0
  47. juniper_data/storage/kaggle_store.py +333 -0
  48. juniper_data/storage/local_fs.py +232 -0
  49. juniper_data/storage/memory.py +136 -0
  50. juniper_data/storage/postgres_store.py +373 -0
  51. juniper_data/storage/redis_store.py +264 -0
  52. juniper_data/tests/__init__.py +1 -0
  53. juniper_data/tests/conftest.py +68 -0
  54. juniper_data/tests/fixtures/generate_golden_datasets.py +199 -0
  55. juniper_data/tests/integration/__init__.py +1 -0
  56. juniper_data/tests/integration/test_api.py +283 -0
  57. juniper_data/tests/integration/test_e2e_workflow.py +378 -0
  58. juniper_data/tests/integration/test_lifecycle_api.py +304 -0
  59. juniper_data/tests/integration/test_security_integration.py +189 -0
  60. juniper_data/tests/integration/test_storage_workflow.py +259 -0
  61. juniper_data/tests/performance/__init__.py +1 -0
  62. juniper_data/tests/performance/test_generator_benchmarks.py +178 -0
  63. juniper_data/tests/performance/test_storage_benchmarks.py +257 -0
  64. juniper_data/tests/unit/__init__.py +1 -0
  65. juniper_data/tests/unit/test_api_app.py +206 -0
  66. juniper_data/tests/unit/test_api_routes.py +407 -0
  67. juniper_data/tests/unit/test_api_settings.py +100 -0
  68. juniper_data/tests/unit/test_arc_agi_generator.py +525 -0
  69. juniper_data/tests/unit/test_artifacts.py +145 -0
  70. juniper_data/tests/unit/test_cached_store.py +423 -0
  71. juniper_data/tests/unit/test_checkerboard_generator.py +232 -0
  72. juniper_data/tests/unit/test_circles_generator.py +256 -0
  73. juniper_data/tests/unit/test_csv_import_generator.py +345 -0
  74. juniper_data/tests/unit/test_dataset_id.py +181 -0
  75. juniper_data/tests/unit/test_gaussian_generator.py +333 -0
  76. juniper_data/tests/unit/test_hf_store.py +416 -0
  77. juniper_data/tests/unit/test_init.py +93 -0
  78. juniper_data/tests/unit/test_kaggle_store.py +469 -0
  79. juniper_data/tests/unit/test_lifecycle.py +394 -0
  80. juniper_data/tests/unit/test_main.py +127 -0
  81. juniper_data/tests/unit/test_middleware.py +79 -0
  82. juniper_data/tests/unit/test_mnist_generator.py +370 -0
  83. juniper_data/tests/unit/test_postgres_store.py +490 -0
  84. juniper_data/tests/unit/test_redis_store.py +500 -0
  85. juniper_data/tests/unit/test_security.py +281 -0
  86. juniper_data/tests/unit/test_security_boundaries.py +517 -0
  87. juniper_data/tests/unit/test_spiral_generator.py +566 -0
  88. juniper_data/tests/unit/test_split.py +245 -0
  89. juniper_data/tests/unit/test_storage.py +767 -0
  90. juniper_data/tests/unit/test_xor_generator.py +223 -0
  91. juniper_data-0.4.2.dist-info/METADATA +216 -0
  92. juniper_data-0.4.2.dist-info/RECORD +95 -0
  93. juniper_data-0.4.2.dist-info/WHEEL +5 -0
  94. juniper_data-0.4.2.dist-info/licenses/LICENSE +9 -0
  95. 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"