Skip to content

Commit 25338cf

Browse files
committed
coverage core + renames of fixtures
1 parent 144bbe5 commit 25338cf

14 files changed

+409
-412
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: 'Docker Image Cache'
2+
description: 'Cache and load Docker images for CI jobs'
3+
4+
inputs:
5+
images:
6+
description: 'Space-separated list of Docker images to cache'
7+
required: true
8+
9+
runs:
10+
using: 'composite'
11+
steps:
12+
- name: Generate cache key from images
13+
id: cache-key
14+
shell: bash
15+
run: |
16+
# Create a stable hash from the sorted image list
17+
IMAGES_HASH=$(echo "${{ inputs.images }}" | tr ' ' '\n' | sort | md5sum | cut -d' ' -f1)
18+
echo "key=docker-${{ runner.os }}-${IMAGES_HASH}" >> $GITHUB_OUTPUT
19+
20+
- name: Cache Docker images
21+
uses: actions/cache@v5
22+
id: docker-cache
23+
with:
24+
path: /tmp/docker-cache
25+
key: ${{ steps.cache-key.outputs.key }}
26+
27+
- name: Load cached Docker images
28+
if: steps.docker-cache.outputs.cache-hit == 'true'
29+
shell: bash
30+
run: |
31+
echo "Loading cached images..."
32+
for f in /tmp/docker-cache/*.tar.zst; do
33+
zstd -d -c "$f" | docker load &
34+
done
35+
wait
36+
docker images
37+
38+
- name: Pull and save Docker images
39+
if: steps.docker-cache.outputs.cache-hit != 'true'
40+
shell: bash
41+
run: |
42+
mkdir -p /tmp/docker-cache
43+
44+
echo "Pulling images in parallel..."
45+
for img in ${{ inputs.images }}; do
46+
docker pull "$img" &
47+
done
48+
wait
49+
50+
echo "Saving images with zstd compression..."
51+
for img in ${{ inputs.images }}; do
52+
# Create filename from image name (replace special chars)
53+
filename=$(echo "$img" | tr '/:' '_')
54+
docker save "$img" | zstd -T0 -3 > "/tmp/docker-cache/${filename}.tar.zst" &
55+
done
56+
wait
57+
58+
echo "Cache size:"
59+
du -sh /tmp/docker-cache/

.github/workflows/backend-ci.yml

Lines changed: 6 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ jobs:
4444
4545
- name: Run unit tests
4646
timeout-minutes: 5
47-
env:
48-
COVERAGE_CORE: sysmon
4947
run: |
5048
cd backend
5149
uv run pytest tests/unit -v -rs \
@@ -70,46 +68,10 @@ jobs:
7068
steps:
7169
- uses: actions/checkout@v6
7270

73-
# ========== DOCKER IMAGE CACHING ==========
74-
- name: Cache Docker images
75-
uses: actions/cache@v5
76-
id: docker-cache
71+
- name: Cache and load Docker images
72+
uses: ./.github/actions/docker-cache
7773
with:
78-
path: /tmp/docker-cache
79-
key: docker-${{ runner.os }}-${{ env.MONGO_IMAGE }}-${{ env.REDIS_IMAGE }}-${{ env.KAFKA_IMAGE }}-${{ env.SCHEMA_REGISTRY_IMAGE }}
80-
81-
- name: Load cached Docker images
82-
if: steps.docker-cache.outputs.cache-hit == 'true'
83-
run: |
84-
echo "Loading cached images..."
85-
for f in /tmp/docker-cache/*.tar.zst; do
86-
zstd -d -c "$f" | docker load &
87-
done
88-
wait
89-
docker images
90-
91-
- name: Pull and save Docker images
92-
if: steps.docker-cache.outputs.cache-hit != 'true'
93-
run: |
94-
mkdir -p /tmp/docker-cache
95-
96-
echo "Pulling images in parallel..."
97-
docker pull $MONGO_IMAGE &
98-
docker pull $REDIS_IMAGE &
99-
docker pull $KAFKA_IMAGE &
100-
docker pull $SCHEMA_REGISTRY_IMAGE &
101-
wait
102-
103-
echo "Saving images with zstd compression..."
104-
docker save $MONGO_IMAGE | zstd -T0 -3 > /tmp/docker-cache/mongo.tar.zst &
105-
docker save $REDIS_IMAGE | zstd -T0 -3 > /tmp/docker-cache/redis.tar.zst &
106-
docker save $KAFKA_IMAGE | zstd -T0 -3 > /tmp/docker-cache/kafka.tar.zst &
107-
docker save $SCHEMA_REGISTRY_IMAGE | zstd -T0 -3 > /tmp/docker-cache/schema-registry.tar.zst &
108-
wait
109-
110-
echo "Cache size:"
111-
du -sh /tmp/docker-cache/
112-
# ==========================================
74+
images: ${{ env.MONGO_IMAGE }} ${{ env.REDIS_IMAGE }} ${{ env.KAFKA_IMAGE }} ${{ env.SCHEMA_REGISTRY_IMAGE }}
11375

11476
- name: Set up uv
11577
uses: astral-sh/setup-uv@v7
@@ -141,7 +103,6 @@ jobs:
141103
REDIS_HOST: localhost
142104
REDIS_PORT: 6379
143105
SCHEMA_SUBJECT_PREFIX: "ci.${{ github.run_id }}."
144-
COVERAGE_CORE: sysmon
145106
run: |
146107
cd backend
147108
uv run pytest tests/integration -v -rs \
@@ -182,46 +143,10 @@ jobs:
182143
steps:
183144
- uses: actions/checkout@v6
184145

185-
# ========== DOCKER IMAGE CACHING ==========
186-
- name: Cache Docker images
187-
uses: actions/cache@v5
188-
id: docker-cache
146+
- name: Cache and load Docker images
147+
uses: ./.github/actions/docker-cache
189148
with:
190-
path: /tmp/docker-cache
191-
key: docker-${{ runner.os }}-${{ env.MONGO_IMAGE }}-${{ env.REDIS_IMAGE }}-${{ env.KAFKA_IMAGE }}-${{ env.SCHEMA_REGISTRY_IMAGE }}
192-
193-
- name: Load cached Docker images
194-
if: steps.docker-cache.outputs.cache-hit == 'true'
195-
run: |
196-
echo "Loading cached images..."
197-
for f in /tmp/docker-cache/*.tar.zst; do
198-
zstd -d -c "$f" | docker load &
199-
done
200-
wait
201-
docker images
202-
203-
- name: Pull and save Docker images
204-
if: steps.docker-cache.outputs.cache-hit != 'true'
205-
run: |
206-
mkdir -p /tmp/docker-cache
207-
208-
echo "Pulling images in parallel..."
209-
docker pull $MONGO_IMAGE &
210-
docker pull $REDIS_IMAGE &
211-
docker pull $KAFKA_IMAGE &
212-
docker pull $SCHEMA_REGISTRY_IMAGE &
213-
wait
214-
215-
echo "Saving images with zstd compression..."
216-
docker save $MONGO_IMAGE | zstd -T0 -3 > /tmp/docker-cache/mongo.tar.zst &
217-
docker save $REDIS_IMAGE | zstd -T0 -3 > /tmp/docker-cache/redis.tar.zst &
218-
docker save $KAFKA_IMAGE | zstd -T0 -3 > /tmp/docker-cache/kafka.tar.zst &
219-
docker save $SCHEMA_REGISTRY_IMAGE | zstd -T0 -3 > /tmp/docker-cache/schema-registry.tar.zst &
220-
wait
221-
222-
echo "Cache size:"
223-
du -sh /tmp/docker-cache/
224-
# ==========================================
149+
images: ${{ env.MONGO_IMAGE }} ${{ env.REDIS_IMAGE }} ${{ env.KAFKA_IMAGE }} ${{ env.SCHEMA_REGISTRY_IMAGE }}
225150

226151
- name: Set up uv
227152
uses: astral-sh/setup-uv@v7
@@ -263,7 +188,6 @@ jobs:
263188
SCHEMA_SUBJECT_PREFIX: "ci.${{ github.run_id }}."
264189
KUBECONFIG: /home/runner/.kube/config
265190
K8S_NAMESPACE: integr8scode
266-
COVERAGE_CORE: sysmon
267191
run: |
268192
cd backend
269193
uv run pytest tests/integration/k8s -v -rs \

backend/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,8 @@ log_cli = false
208208
log_cli_level = "ERROR"
209209
log_level = "ERROR"
210210
addopts = "-n 4 --dist loadfile --tb=short -q --no-header -q"
211+
212+
# Coverage configuration
213+
[tool.coverage.run]
214+
# Use sysmon for faster coverage (requires Python 3.12+)
215+
core = "sysmon"

backend/tests/conftest.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,15 @@ def create_test_app():
128128
# ===== App without lifespan for tests =====
129129
@pytest_asyncio.fixture(scope="session")
130130
async def app():
131-
"""Create FastAPI app once per session/worker to avoid Pydantic schema crashes."""
131+
"""Create FastAPI app once per session/worker.
132+
133+
Session-scoped to avoid Pydantic schema validator memory issues when
134+
FastAPI recreates OpenAPI schemas hundreds of times with pytest-xdist.
135+
See: https://github.com/pydantic/pydantic/issues/1864
136+
137+
Note: Tests must not modify app.state or registered routes.
138+
Use function-scoped `client` fixture for test isolation.
139+
"""
132140
application = create_test_app()
133141

134142
yield application
@@ -201,7 +209,7 @@ async def _http_login(client: httpx.AsyncClient, username: str, password: str) -
201209

202210
# Session-scoped shared users for convenience
203211
@pytest.fixture(scope="session")
204-
def shared_user_credentials():
212+
def test_user_credentials():
205213
uid = os.environ.get("PYTEST_SESSION_ID", uuid.uuid4().hex[:8])
206214
return {
207215
"username": f"test_user_{uid}",
@@ -212,7 +220,7 @@ def shared_user_credentials():
212220

213221

214222
@pytest.fixture(scope="session")
215-
def shared_admin_credentials():
223+
def test_admin_credentials():
216224
uid = os.environ.get("PYTEST_SESSION_ID", uuid.uuid4().hex[:8])
217225
return {
218226
"username": f"admin_user_{uid}",
@@ -223,22 +231,23 @@ def shared_admin_credentials():
223231

224232

225233
@pytest_asyncio.fixture
226-
async def shared_user(client: httpx.AsyncClient, shared_user_credentials):
227-
creds = shared_user_credentials
228-
# Always attempt to register; DB is wiped after each test
234+
async def test_user(client: httpx.AsyncClient, test_user_credentials):
235+
"""Function-scoped authenticated user. Recreated each test (DB wiped between tests)."""
236+
creds = test_user_credentials
229237
r = await client.post("/api/v1/auth/register", json=creds)
230238
if r.status_code not in (200, 201, 400):
231-
pytest.skip(f"Cannot create shared user (status {r.status_code}).")
239+
pytest.skip(f"Cannot create test user (status {r.status_code}).")
232240
csrf = await _http_login(client, creds["username"], creds["password"])
233241
return {**creds, "csrf_token": csrf, "headers": {"X-CSRF-Token": csrf}}
234242

235243

236244
@pytest_asyncio.fixture
237-
async def shared_admin(client: httpx.AsyncClient, shared_admin_credentials):
238-
creds = shared_admin_credentials
245+
async def test_admin(client: httpx.AsyncClient, test_admin_credentials):
246+
"""Function-scoped authenticated admin. Recreated each test (DB wiped between tests)."""
247+
creds = test_admin_credentials
239248
r = await client.post("/api/v1/auth/register", json=creds)
240249
if r.status_code not in (200, 201, 400):
241-
pytest.skip(f"Cannot create shared admin (status {r.status_code}).")
250+
pytest.skip(f"Cannot create test admin (status {r.status_code}).")
242251
csrf = await _http_login(client, creds["username"], creds["password"])
243252
return {**creds, "csrf_token": csrf, "headers": {"X-CSRF-Token": csrf}}
244253

backend/tests/integration/test_admin_routes.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ async def test_get_settings_requires_auth(self, client: AsyncClient) -> None:
2727
assert "not authenticated" in error["detail"].lower() or "unauthorized" in error["detail"].lower()
2828

2929
@pytest.mark.asyncio
30-
async def test_get_settings_with_admin_auth(self, client: AsyncClient, shared_admin: Dict[str, str]) -> None:
30+
async def test_get_settings_with_admin_auth(self, client: AsyncClient, test_admin: Dict[str, str]) -> None:
3131
"""Test getting system settings with admin authentication."""
3232
# Login and get cookies
3333
login_data = {
34-
"username": shared_admin["username"],
35-
"password": shared_admin["password"]
34+
"username": test_admin["username"],
35+
"password": test_admin["password"]
3636
}
3737
login_response = await client.post("/api/v1/auth/login", data=login_data)
3838
assert login_response.status_code == 200
@@ -68,12 +68,12 @@ async def test_get_settings_with_admin_auth(self, client: AsyncClient, shared_ad
6868
assert settings.monitoring_settings.sampling_rate == 0.1
6969

7070
@pytest.mark.asyncio
71-
async def test_update_and_reset_settings(self, client: AsyncClient, shared_admin: Dict[str, str]) -> None:
71+
async def test_update_and_reset_settings(self, client: AsyncClient, test_admin: Dict[str, str]) -> None:
7272
"""Test updating and resetting system settings."""
7373
# Login as admin
7474
login_data = {
75-
"username": shared_admin["username"],
76-
"password": shared_admin["password"]
75+
"username": test_admin["username"],
76+
"password": test_admin["password"]
7777
}
7878
login_response = await client.post("/api/v1/auth/login", data=login_data)
7979
assert login_response.status_code == 200
@@ -125,12 +125,12 @@ async def test_update_and_reset_settings(self, client: AsyncClient, shared_admin
125125
assert reset_settings.monitoring_settings.log_level == "INFO"
126126

127127
@pytest.mark.asyncio
128-
async def test_regular_user_cannot_access_settings(self, client: AsyncClient, shared_user: Dict[str, str]) -> None:
128+
async def test_regular_user_cannot_access_settings(self, client: AsyncClient, test_user: Dict[str, str]) -> None:
129129
"""Test that regular users cannot access admin settings."""
130130
# Login as regular user
131131
login_data = {
132-
"username": shared_user["username"],
133-
"password": shared_user["password"]
132+
"username": test_user["username"],
133+
"password": test_user["password"]
134134
}
135135
login_response = await client.post("/api/v1/auth/login", data=login_data)
136136
assert login_response.status_code == 200
@@ -149,12 +149,12 @@ class TestAdminUsers:
149149
"""Test admin user management endpoints against real backend."""
150150

151151
@pytest.mark.asyncio
152-
async def test_list_users_with_pagination(self, client: AsyncClient, shared_admin: Dict[str, str]) -> None:
152+
async def test_list_users_with_pagination(self, client: AsyncClient, test_admin: Dict[str, str]) -> None:
153153
"""Test listing users with pagination."""
154154
# Login as admin
155155
login_data = {
156-
"username": shared_admin["username"],
157-
"password": shared_admin["password"]
156+
"username": test_admin["username"],
157+
"password": test_admin["password"]
158158
}
159159
login_response = await client.post("/api/v1/auth/login", data=login_data)
160160
assert login_response.status_code == 200
@@ -188,12 +188,12 @@ async def test_list_users_with_pagination(self, client: AsyncClient, shared_admi
188188
assert "updated_at" in user
189189

190190
@pytest.mark.asyncio
191-
async def test_create_and_manage_user(self, client: AsyncClient, shared_admin: Dict[str, str]) -> None:
191+
async def test_create_and_manage_user(self, client: AsyncClient, test_admin: Dict[str, str]) -> None:
192192
"""Test full user CRUD operations."""
193193
# Login as admin
194194
login_data = {
195-
"username": shared_admin["username"],
196-
"password": shared_admin["password"]
195+
"username": test_admin["username"],
196+
"password": test_admin["password"]
197197
}
198198
login_response = await client.post("/api/v1/auth/login", data=login_data)
199199
assert login_response.status_code == 200
@@ -257,12 +257,12 @@ class TestAdminEvents:
257257
"""Test admin event management endpoints against real backend."""
258258

259259
@pytest.mark.asyncio
260-
async def test_browse_events(self, client: AsyncClient, shared_admin: Dict[str, str]) -> None:
260+
async def test_browse_events(self, client: AsyncClient, test_admin: Dict[str, str]) -> None:
261261
"""Test browsing events with filters."""
262262
# Login as admin
263263
login_data = {
264-
"username": shared_admin["username"],
265-
"password": shared_admin["password"]
264+
"username": test_admin["username"],
265+
"password": test_admin["password"]
266266
}
267267
login_response = await client.post("/api/v1/auth/login", data=login_data)
268268
assert login_response.status_code == 200
@@ -291,12 +291,12 @@ async def test_browse_events(self, client: AsyncClient, shared_admin: Dict[str,
291291
assert data["total"] >= 0
292292

293293
@pytest.mark.asyncio
294-
async def test_event_statistics(self, client: AsyncClient, shared_admin: Dict[str, str]) -> None:
294+
async def test_event_statistics(self, client: AsyncClient, test_admin: Dict[str, str]) -> None:
295295
"""Test getting event statistics."""
296296
# Login as admin
297297
login_data = {
298-
"username": shared_admin["username"],
299-
"password": shared_admin["password"]
298+
"username": test_admin["username"],
299+
"password": test_admin["password"]
300300
}
301301
login_response = await client.post("/api/v1/auth/login", data=login_data)
302302
assert login_response.status_code == 200
@@ -324,10 +324,10 @@ async def test_event_statistics(self, client: AsyncClient, shared_admin: Dict[st
324324
assert data["error_rate"] >= 0.0
325325

326326
@pytest.mark.asyncio
327-
async def test_admin_events_export_csv_and_json(self, client: AsyncClient, shared_admin: Dict[str, str]) -> None:
327+
async def test_admin_events_export_csv_and_json(self, client: AsyncClient, test_admin: Dict[str, str]) -> None:
328328
"""Export admin events as CSV and JSON and validate basic structure."""
329329
# Login as admin
330-
login_data = {"username": shared_admin["username"], "password": shared_admin["password"]}
330+
login_data = {"username": test_admin["username"], "password": test_admin["password"]}
331331
login_response = await client.post("/api/v1/auth/login", data=login_data)
332332
assert login_response.status_code == 200
333333

@@ -352,10 +352,10 @@ async def test_admin_events_export_csv_and_json(self, client: AsyncClient, share
352352

353353
@pytest.mark.asyncio
354354
async def test_admin_user_rate_limits_and_password_reset(self, client: AsyncClient,
355-
shared_admin: Dict[str, str]) -> None:
355+
test_admin: Dict[str, str]) -> None:
356356
"""Create a user, manage rate limits, and reset password via admin endpoints."""
357357
# Login as admin
358-
login_data = {"username": shared_admin["username"], "password": shared_admin["password"]}
358+
login_data = {"username": test_admin["username"], "password": test_admin["password"]}
359359
login_response = await client.post("/api/v1/auth/login", data=login_data)
360360
assert login_response.status_code == 200
361361

0 commit comments

Comments
 (0)