diff --git a/backend/app/models.py b/backend/app/models.py index 99a5478..83c1965 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py index 2878e83..3a7b378 100644 --- a/backend/app/routers/jobs.py +++ b/backend/app/routers/jobs.py @@ -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) diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py index fc7fb83..05bfa21 100644 --- a/backend/tests/test_jobs.py +++ b/backend/tests/test_jobs.py @@ -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