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) cron_expression = Column(String, nullable=False)
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) 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") 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.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import List 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.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 from backup.engine import BackupEngine
router = APIRouter(prefix="/api/jobs", tags=["jobs"]) router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@@ -74,13 +74,14 @@ async def run_job(
# Run in background # Run in background
async def execute(): async def execute():
engine = BackupEngine(db) async with AsyncSessionLocal() as session:
await engine.execute_job(job_id, triggered_by="manual") engine = BackupEngine(session)
await engine.execute_job(job_id, triggered_by="manual")
background_tasks.add_task(execute) background_tasks.add_task(execute)
return {"message": "Job execution started"} 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( async def create_schedule(
job_id: int, job_id: int,
schedule: ScheduleCreate, schedule: ScheduleCreate,
@@ -91,8 +92,10 @@ async def create_schedule(
if not job: if not job:
raise HTTPException(status_code=404, detail="Job not found") 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) db.add(db_schedule)
await db.commit() await db.commit()
await db.refresh(job) await db.refresh(db_schedule)
return job return ScheduleSchema.model_validate(db_schedule)
+195 -73
View File
@@ -1,31 +1,27 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from fastapi.testclient import TestClient
from app.main import app from app.main import app
@pytest.fixture @pytest.mark.asyncio
def client(): async def test_create_job():
with TestClient(app) as c: async with AsyncClient(app=app, base_url="http://test") as ac:
yield c # Create a source first (job requires source_id)
source_resp = await ac.post("/api/sources/", json={
def test_create_job(client): "name": "Test Source",
# Create a source first (job requires source_id) "type": "local",
source_resp = client.post("/api/sources/", json={ "config": {"path": "/tmp/test"}
"name": "Test Source", })
"type": "local", assert source_resp.status_code == 200
"config": {"path": "/tmp/test"} source_id = source_resp.json()["id"]
})
assert source_resp.status_code == 200 response = await ac.post("/api/jobs/", json={
source_id = source_resp.json()["id"] "name": "Test Job",
"source_id": source_id,
response = client.post("/api/jobs/", json={ "strategy": "full",
"name": "Test Job", "destination_path": "/tmp/backups",
"source_id": source_id, "exclude_patterns": [],
"strategy": "full", "enabled": True
"destination_path": "/tmp/backups", })
"exclude_patterns": [],
"enabled": True
})
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["name"] == "Test Job" assert data["name"] == "Test Job"
@@ -33,65 +29,191 @@ def test_create_job(client):
assert data["strategy"] == "full" assert data["strategy"] == "full"
assert "id" in data assert "id" in data
def test_run_job(client): @pytest.mark.asyncio
# Create source async def test_list_jobs():
source_resp = client.post("/api/sources/", json={ async with AsyncClient(app=app, base_url="http://test") as ac:
"name": "Test Source", # Create source and job
"type": "local", source_resp = await ac.post("/api/sources/", json={
"config": {"path": "/tmp/test"} "name": "Test Source",
}) "type": "local",
source_id = source_resp.json()["id"] "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 # Verify deletion
job_resp = client.post("/api/jobs/", json={ async with AsyncClient(app=app, base_url="http://test") as ac:
"name": "Test Job", get_resp = await ac.get(f"/api/jobs/{job_id}")
"source_id": source_id, assert get_resp.status_code == 404
"strategy": "full",
"destination_path": "/tmp/backups", @pytest.mark.asyncio
"exclude_patterns": [], async def test_run_job():
"enabled": True async with AsyncClient(app=app, base_url="http://test") as ac:
}) # Create source
job_id = job_resp.json()["id"] source_resp = await ac.post("/api/sources/", json={
"name": "Test Source",
response = client.post(f"/api/jobs/{job_id}/run") "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.status_code == 200
assert response.json()["message"] == "Job execution started" assert response.json()["message"] == "Job execution started"
def test_update_job(client): @pytest.mark.asyncio
# Create source async def test_run_job_not_found():
source_resp = client.post("/api/sources/", json={ async with AsyncClient(app=app, base_url="http://test") as ac:
"name": "Test Source", response = await ac.post("/api/jobs/999/run")
"type": "local", assert response.status_code == 404
"config": {"path": "/tmp/test"}
}) @pytest.mark.asyncio
source_id = source_resp.json()["id"] async def test_update_job():
async with AsyncClient(app=app, base_url="http://test") as ac:
# Create job # Create source
job_resp = client.post("/api/jobs/", json={ source_resp = await ac.post("/api/sources/", json={
"name": "Original Name", "name": "Test Source",
"source_id": source_id, "type": "local",
"strategy": "full", "config": {"path": "/tmp/test"}
"destination_path": "/tmp/backups", })
"exclude_patterns": [], source_id = source_resp.json()["id"]
"enabled": True
}) # Create job
job_id = job_resp.json()["id"] job_resp = await ac.post("/api/jobs/", json={
"name": "Original Name",
response = client.put(f"/api/jobs/{job_id}", json={ "source_id": source_id,
"name": "Updated Name" "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.status_code == 200
assert response.json()["name"] == "Updated Name" assert response.json()["name"] == "Updated Name"
assert response.json()["strategy"] == "full" # Unchanged assert response.json()["strategy"] == "full" # Unchanged
def test_get_job_not_found(client): @pytest.mark.asyncio
response = client.get("/api/jobs/99999") 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 assert response.status_code == 404
def test_update_job_not_found(client): @pytest.mark.asyncio
response = client.put("/api/jobs/99999", json={"name": "Test"}) 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 assert response.status_code == 404
def test_delete_job_not_found(client): @pytest.mark.asyncio
response = client.delete("/api/jobs/99999") 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 assert response.status_code == 404