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")
+10 -7
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:
engine = BackupEngine(session)
await engine.execute_job(job_id, triggered_by="manual") 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)
+148 -26
View File
@@ -1,16 +1,12 @@
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
def test_create_job(client):
# Create a source first (job requires source_id) # Create a source first (job requires source_id)
source_resp = client.post("/api/sources/", json={ source_resp = await ac.post("/api/sources/", json={
"name": "Test Source", "name": "Test Source",
"type": "local", "type": "local",
"config": {"path": "/tmp/test"} "config": {"path": "/tmp/test"}
@@ -18,7 +14,7 @@ def test_create_job(client):
assert source_resp.status_code == 200 assert source_resp.status_code == 200
source_id = source_resp.json()["id"] source_id = source_resp.json()["id"]
response = client.post("/api/jobs/", json={ response = await ac.post("/api/jobs/", json={
"name": "Test Job", "name": "Test Job",
"source_id": source_id, "source_id": source_id,
"strategy": "full", "strategy": "full",
@@ -33,17 +29,42 @@ 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:
# Create source and job
source_resp = await ac.post("/api/sources/", json={
"name": "Test Source", "name": "Test Source",
"type": "local", "type": "local",
"config": {"path": "/tmp/test"} "config": {"path": "/tmp/test"}
}) })
source_id = source_resp.json()["id"] source_id = source_resp.json()["id"]
# Create job await ac.post("/api/jobs/", json={
job_resp = client.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", "name": "Test Job",
"source_id": source_id, "source_id": source_id,
"strategy": "full", "strategy": "full",
@@ -53,13 +74,43 @@ def test_run_job(client):
}) })
job_id = job_resp.json()["id"] job_id = job_resp.json()["id"]
response = client.post(f"/api/jobs/{job_id}/run") response = await ac.get(f"/api/jobs/{job_id}")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["message"] == "Job execution started" assert response.json()["id"] == job_id
def test_update_job(client): @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
# 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 # Create source
source_resp = client.post("/api/sources/", json={ source_resp = await ac.post("/api/sources/", json={
"name": "Test Source", "name": "Test Source",
"type": "local", "type": "local",
"config": {"path": "/tmp/test"} "config": {"path": "/tmp/test"}
@@ -67,7 +118,39 @@ def test_update_job(client):
source_id = source_resp.json()["id"] source_id = source_resp.json()["id"]
# Create job # Create job
job_resp = client.post("/api/jobs/", json={ 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"
@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", "name": "Original Name",
"source_id": source_id, "source_id": source_id,
"strategy": "full", "strategy": "full",
@@ -77,21 +160,60 @@ def test_update_job(client):
}) })
job_id = job_resp.json()["id"] job_id = job_resp.json()["id"]
response = client.put(f"/api/jobs/{job_id}", json={ response = await ac.put(f"/api/jobs/{job_id}", json={
"name": "Updated Name" "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