fix: jobs router session handling, schedule creation, and tests

- Use new session in background task for thread safety
- Fix schedule creation to associate with job
- Fix schedule endpoint return type
- Update tests to use AsyncClient
- Add missing tests for list, get, delete, schedule
- Add updated_at field to Schedule model
This commit is contained in:
2026-05-11 21:20:31 +02:00
parent 1f1ed72494
commit 955ed9db49
3 changed files with 211 additions and 81 deletions
+5
View File
@@ -63,6 +63,11 @@ class Schedule(Base):
cron_expression = Column(String, nullable=False)
enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
job = relationship("Job", back_populates="schedule")
+11 -8
View File
@@ -2,9 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.database import get_db, AsyncSessionLocal
from app.models import Job, Schedule
from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate
from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate, Schedule as ScheduleSchema
from backup.engine import BackupEngine
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@@ -74,13 +74,14 @@ async def run_job(
# Run in background
async def execute():
engine = BackupEngine(db)
await engine.execute_job(job_id, triggered_by="manual")
async with AsyncSessionLocal() as session:
engine = BackupEngine(session)
await engine.execute_job(job_id, triggered_by="manual")
background_tasks.add_task(execute)
return {"message": "Job execution started"}
@router.post("/{job_id}/schedule", response_model=JobSchema)
@router.post("/{job_id}/schedule", response_model=ScheduleSchema)
async def create_schedule(
job_id: int,
schedule: ScheduleCreate,
@@ -91,8 +92,10 @@ async def create_schedule(
if not job:
raise HTTPException(status_code=404, detail="Job not found")
db_schedule = Schedule(**schedule.model_dump())
schedule_data = schedule.model_dump()
schedule_data["job_id"] = job_id
db_schedule = Schedule(**schedule_data)
db.add(db_schedule)
await db.commit()
await db.refresh(job)
return job
await db.refresh(db_schedule)
return ScheduleSchema.model_validate(db_schedule)
+195 -73
View File
@@ -1,31 +1,27 @@
import pytest
from httpx import AsyncClient
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
def test_create_job(client):
# Create a source first (job requires source_id)
source_resp = client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
assert source_resp.status_code == 200
source_id = source_resp.json()["id"]
response = client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
@pytest.mark.asyncio
async def test_create_job():
async with AsyncClient(app=app, base_url="http://test") as ac:
# Create a source first (job requires source_id)
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
assert source_resp.status_code == 200
source_id = source_resp.json()["id"]
response = await ac.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Job"
@@ -33,65 +29,191 @@ def test_create_job(client):
assert data["strategy"] == "full"
assert "id" in data
def test_run_job(client):
# Create source
source_resp = client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
@pytest.mark.asyncio
async def test_list_jobs():
async with AsyncClient(app=app, base_url="http://test") as ac:
# Create source and job
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
await ac.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
response = await ac.get("/api/jobs/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
@pytest.mark.asyncio
async def test_get_job():
async with AsyncClient(app=app, base_url="http://test") as ac:
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
job_resp = await ac.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await ac.get(f"/api/jobs/{job_id}")
assert response.status_code == 200
assert response.json()["id"] == job_id
@pytest.mark.asyncio
async def test_delete_job():
async with AsyncClient(app=app, base_url="http://test") as ac:
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
job_resp = await ac.post("/api/jobs/", json={
"name": "Delete Me",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await ac.delete(f"/api/jobs/{job_id}")
assert response.status_code == 200
# Create job
job_resp = client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = client.post(f"/api/jobs/{job_id}/run")
# Verify deletion
async with AsyncClient(app=app, base_url="http://test") as ac:
get_resp = await ac.get(f"/api/jobs/{job_id}")
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_run_job():
async with AsyncClient(app=app, base_url="http://test") as ac:
# Create source
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await ac.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await ac.post(f"/api/jobs/{job_id}/run")
assert response.status_code == 200
assert response.json()["message"] == "Job execution started"
def test_update_job(client):
# Create source
source_resp = client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = client.post("/api/jobs/", json={
"name": "Original Name",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = client.put(f"/api/jobs/{job_id}", json={
"name": "Updated Name"
})
@pytest.mark.asyncio
async def test_run_job_not_found():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.post("/api/jobs/999/run")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_job():
async with AsyncClient(app=app, base_url="http://test") as ac:
# Create source
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await ac.post("/api/jobs/", json={
"name": "Original Name",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await ac.put(f"/api/jobs/{job_id}", json={
"name": "Updated Name"
})
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
assert response.json()["strategy"] == "full" # Unchanged
def test_get_job_not_found(client):
response = client.get("/api/jobs/99999")
@pytest.mark.asyncio
async def test_create_schedule():
async with AsyncClient(app=app, base_url="http://test") as ac:
# Create source
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await ac.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await ac.post(f"/api/jobs/{job_id}/schedule", json={
"job_id": job_id,
"cron_expression": "0 0 * * *",
"enabled": True
})
assert response.status_code == 200
data = response.json()
assert data["job_id"] == job_id
assert data["cron_expression"] == "0 0 * * *"
assert "id" in data
@pytest.mark.asyncio
async def test_get_job_not_found():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.get("/api/jobs/99999")
assert response.status_code == 404
def test_update_job_not_found(client):
response = client.put("/api/jobs/99999", json={"name": "Test"})
@pytest.mark.asyncio
async def test_update_job_not_found():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.put("/api/jobs/99999", json={"name": "Test"})
assert response.status_code == 404
def test_delete_job_not_found(client):
response = client.delete("/api/jobs/99999")
@pytest.mark.asyncio
async def test_delete_job_not_found():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.delete("/api/jobs/99999")
assert response.status_code == 404