Skip to content

Commit 24bf6d6

Browse files
eprifticlaude
andcommitted
Add tests, Docker multi-stage build, CI/CD, and SPA serving
- 20 backend tests (5 categories: health, projects, datasets, analysis, schema validation) - Multi-stage Dockerfile (Node frontend → Rust/maturin builder → Python runtime) - docker-compose.yml (prod) and docker-compose.dev.yml (hot-reload) - GitHub Actions CI pipeline (backend-test, frontend-build, docker-build) - Fix DatasetRef schema (list[str] → list[DatasetRef]) - SPA serving for production (Vue.js from backend/static/) - README: test coverage section and CI/CD documentation - Sample data (cirrhosis train/test datasets + param.yaml) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 01ad3e4 commit 24bf6d6

15 files changed

Lines changed: 6383 additions & 51 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
# -------------------------------------------------------------------------
11+
# Backend tests (Python + FastAPI)
12+
# -------------------------------------------------------------------------
13+
backend-test:
14+
runs-on: ubuntu-latest
15+
defaults:
16+
run:
17+
working-directory: backend
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Set up Python
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: "3.11"
26+
27+
- name: Install dependencies
28+
run: pip install -e ".[dev]" pyyaml
29+
30+
- name: Run tests
31+
run: PYTHONPATH=. pytest tests/ -v --tb=short
32+
env:
33+
PREDOMICS_DATA_DIR: /tmp/predomics-test
34+
PREDOMICS_PROJECT_DIR: /tmp/predomics-test/projects
35+
PREDOMICS_UPLOAD_DIR: /tmp/predomics-test/uploads
36+
37+
# -------------------------------------------------------------------------
38+
# Frontend lint + build (Vue.js)
39+
# -------------------------------------------------------------------------
40+
frontend-build:
41+
runs-on: ubuntu-latest
42+
defaults:
43+
run:
44+
working-directory: frontend
45+
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- name: Set up Node.js
50+
uses: actions/setup-node@v4
51+
with:
52+
node-version: "20"
53+
54+
- name: Install dependencies
55+
run: npm ci
56+
57+
- name: Build
58+
run: npm run build
59+
60+
# -------------------------------------------------------------------------
61+
# Docker build (full stack)
62+
# -------------------------------------------------------------------------
63+
docker-build:
64+
runs-on: ubuntu-latest
65+
needs: [backend-test, frontend-build]
66+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
67+
68+
steps:
69+
- uses: actions/checkout@v4
70+
with:
71+
# Also checkout gpredomicspy for Docker build
72+
path: predomicsapp-web
73+
74+
- name: Checkout gpredomicspy
75+
uses: actions/checkout@v4
76+
with:
77+
repository: predomics/gpredomicspy
78+
path: gpredomicspy
79+
80+
- name: Set up Docker Buildx
81+
uses: docker/setup-buildx-action@v3
82+
83+
- name: Build Docker image
84+
run: |
85+
cd predomicsapp-web
86+
docker build -t predomicsapp-web:latest -f Dockerfile ..
87+
88+
- name: Test Docker image
89+
run: |
90+
docker run -d --name test-app -p 8000:8000 predomicsapp-web:latest
91+
sleep 5
92+
curl -f http://localhost:8000/health
93+
docker stop test-app

‎Dockerfile‎

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,64 @@
1+
# =============================================================================
12
# Stage 1: Build Vue.js frontend
3+
# =============================================================================
24
FROM node:20-alpine AS frontend-builder
35
WORKDIR /app/frontend
46
COPY frontend/package*.json ./
57
RUN npm ci
68
COPY frontend/ ./
79
RUN npm run build
810

9-
# Stage 2: Python backend + compiled frontend
10-
FROM python:3.11-slim AS runtime
11+
# =============================================================================
12+
# Stage 2: Build gpredomicspy (Rust → Python native module)
13+
# =============================================================================
14+
FROM python:3.11-slim AS rust-builder
1115

12-
# Install Rust toolchain for building gpredomicspy
1316
RUN apt-get update && apt-get install -y --no-install-recommends \
14-
build-essential curl && \
17+
build-essential curl git pkg-config libssl-dev && \
1518
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
1619
rm -rf /var/lib/apt/lists/*
1720
ENV PATH="/root/.cargo/bin:${PATH}"
1821

22+
RUN pip install --no-cache-dir maturin
23+
24+
WORKDIR /build
25+
COPY gpredomicspy/ ./gpredomicspy/
26+
RUN cd gpredomicspy && maturin build --release --out /build/wheels
27+
28+
# =============================================================================
29+
# Stage 3: Runtime — Python backend + frontend + gpredomicspy wheel
30+
# =============================================================================
31+
FROM python:3.11-slim AS runtime
32+
33+
RUN apt-get update && apt-get install -y --no-install-recommends \
34+
libgomp1 && \
35+
rm -rf /var/lib/apt/lists/*
36+
1937
WORKDIR /app
2038

21-
# Install Python dependencies
22-
COPY backend/pyproject.toml backend/
23-
RUN pip install --no-cache-dir -e backend/
39+
# Install gpredomicspy wheel (built in stage 2)
40+
COPY --from=rust-builder /build/wheels/*.whl /tmp/wheels/
41+
RUN pip install --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels
2442

25-
# Install gpredomicspy from source (requires Rust)
26-
# For now, this is optional — the app works in mock mode without it
27-
# COPY gpredomicspy/ gpredomicspy/
28-
# RUN cd gpredomicspy && pip install maturin && maturin develop --release
43+
# Install Python backend dependencies
44+
COPY backend/pyproject.toml backend/
45+
RUN pip install --no-cache-dir backend/
2946

3047
# Copy backend code
3148
COPY backend/ backend/
3249

33-
# Copy built frontend
50+
# Copy built frontend into static directory
3451
COPY --from=frontend-builder /app/frontend/dist backend/static/
3552

3653
# Copy sample data
3754
COPY data/sample/ data/sample/
3855

56+
# Create directories for runtime data
57+
RUN mkdir -p data/uploads data/projects
58+
3959
EXPOSE 8000
4060

61+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
62+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
63+
4164
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]

‎README.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,56 @@ predomicsapp-web/
7272
└── docker-compose.yml
7373
```
7474

75+
## Tests
76+
77+
### Running Tests
78+
79+
```bash
80+
cd backend
81+
pip install -e ".[dev]"
82+
pytest -v
83+
```
84+
85+
### Coverage Summary
86+
87+
The backend test suite contains **20 tests** organized in 5 categories, covering all API endpoints and input validation:
88+
89+
| Test Class | Tests | What's Covered |
90+
|------------|-------|----------------|
91+
| **TestHealth** | 2 | `/health` endpoint returns correct fields (`status`, `engine_available`, `version`); status is always `"ok"` |
92+
| **TestProjects** | 7 | Full CRUD lifecycle — create, list, get, delete; 404 on missing project; unique IDs; ISO timestamps |
93+
| **TestDatasets** | 4 | TSV and CSV file upload via multipart; 404 on missing project; project metadata updated after upload |
94+
| **TestAnalysis** | 3 | `/run` returns `job_id` with `pending` status; 404 on missing project; empty job list for new projects |
95+
| **TestSchemaValidation** | 4 | Rejects invalid `algo` values (422); rejects invalid `fit` values (422); accepts valid non-default params; defaults are applied correctly |
96+
97+
**Total: 20 tests — all passing.**
98+
99+
### What's Tested
100+
101+
- All 9 REST API endpoints (see table above)
102+
- Request validation via Pydantic schemas (invalid enum values return 422)
103+
- File upload with correct content-type handling
104+
- Project isolation (operations on non-existent projects return 404)
105+
- Default parameter injection for analysis configuration
106+
- Async request handling (tests use `httpx.AsyncClient` with `ASGITransport`)
107+
108+
### Not Yet Covered
109+
110+
- End-to-end analysis execution (requires gpredomicspy + sample data)
111+
- WebSocket/SSE progress streaming (not yet implemented)
112+
- Frontend component tests (planned for Phase 3)
113+
- Docker build integration tests
114+
115+
## CI/CD
116+
117+
GitHub Actions runs on every push and PR:
118+
119+
- **backend-test**: Installs dependencies and runs `pytest`
120+
- **frontend-build**: Installs npm packages and runs `npm run build`
121+
- **docker-build**: Builds the multi-stage Docker image
122+
123+
See [.github/workflows/ci.yml](.github/workflows/ci.yml).
124+
75125
## License
76126

77127
GPL-3.0

‎backend/app/main.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from contextlib import asynccontextmanager
5+
from pathlib import Path
56

67
from fastapi import FastAPI
78
from fastapi.middleware.cors import CORSMiddleware
@@ -47,3 +48,18 @@ async def lifespan(app: FastAPI):
4748
app.include_router(health.router)
4849
app.include_router(projects.router, prefix="/api")
4950
app.include_router(analysis.router, prefix="/api")
51+
52+
# Serve Vue.js frontend (production: built into backend/static/)
53+
_static_dir = Path(__file__).parent / "static"
54+
if _static_dir.is_dir():
55+
from fastapi.responses import FileResponse
56+
57+
app.mount("/assets", StaticFiles(directory=_static_dir / "assets"), name="assets")
58+
59+
@app.get("/{full_path:path}")
60+
async def serve_spa(full_path: str):
61+
"""Serve Vue.js SPA — all non-API routes return index.html."""
62+
file_path = _static_dir / full_path
63+
if file_path.is_file():
64+
return FileResponse(file_path)
65+
return FileResponse(_static_dir / "index.html")

‎backend/app/models/schemas.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,17 @@ class DatasetInfo(BaseModel):
150150
features_in_rows: bool = True
151151

152152

153+
class DatasetRef(BaseModel):
154+
id: str
155+
filename: str
156+
path: str = ""
157+
158+
153159
class ProjectInfo(BaseModel):
154160
project_id: str
155161
name: str
156162
created_at: str
157-
datasets: list[str] = []
163+
datasets: list[DatasetRef] = []
158164
jobs: list[str] = []
159165

160166

‎backend/pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ dev = [
2424
[build-system]
2525
requires = ["setuptools>=68"]
2626
build-backend = "setuptools.build_meta"
27+
28+
[tool.pytest.ini_options]
29+
asyncio_mode = "auto"

0 commit comments

Comments
 (0)