Initial pve cloud backup app
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_db(tmp_path, monkeypatch):
|
||||
from app import db as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "DB_PATH", tmp_path / "app.db")
|
||||
from app.migrations import run_migrations
|
||||
|
||||
run_migrations()
|
||||
return tmp_path / "app.db"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def no_notifications(monkeypatch):
|
||||
async def noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
import app.backup_service as backup_service
|
||||
|
||||
monkeypatch.setattr(backup_service, "notify", noop)
|
||||
@@ -0,0 +1,394 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import db, utc_now
|
||||
from app.jobs import create_job
|
||||
from app.settings_store import set_settings
|
||||
|
||||
|
||||
def _settings(tmp_path):
|
||||
return {
|
||||
"setup_complete": False,
|
||||
"proxmox_node": "pve",
|
||||
"proxmox_storage": "hitachi",
|
||||
"local_backup_dir": str(tmp_path),
|
||||
"rclone_path": "/usr/bin/rclone",
|
||||
"rclone_remote": "onedrive",
|
||||
"rclone_remote_path": "Backups/Proxmox",
|
||||
"discord_webhook_url": "",
|
||||
"allowed_cors_origins": [],
|
||||
"default_compression": "zstd",
|
||||
"default_backup_mode": "snapshot",
|
||||
"default_retention_type": "days",
|
||||
"default_retention_value": 30,
|
||||
"max_concurrent_backups": 1,
|
||||
}
|
||||
|
||||
|
||||
def _job():
|
||||
return create_job(
|
||||
{
|
||||
"guest_vmid": 110,
|
||||
"guest_name": "adguard",
|
||||
"guest_type": "vm",
|
||||
"node": "pve",
|
||||
"enabled": True,
|
||||
"cron_schedule": "0 2 * * *",
|
||||
"proxmox_storage": "hitachi",
|
||||
"backup_mode": "snapshot",
|
||||
"compression": "zstd",
|
||||
"retention_type": "latest",
|
||||
"retention_value": 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_backup_state_transitions_complete_and_delete_local_safely(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
backup = backup_service.queue_backup_for_job(job)
|
||||
archive = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
|
||||
archive.write_bytes(b"backup")
|
||||
|
||||
monkeypatch.setattr(commands, "start_proxmox_backup", lambda **kwargs: "UPID:pve:test")
|
||||
monkeypatch.setattr(commands, "get_task_status", lambda node, upid: {"status": "stopped", "exitstatus": "OK"})
|
||||
monkeypatch.setattr(commands, "discover_archive", lambda *args: archive)
|
||||
monkeypatch.setattr(commands, "rclone_copyto", lambda *args: None)
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
|
||||
monkeypatch.setattr(backup_service, "apply_retention_for_job", lambda job_id: asyncio.sleep(0))
|
||||
|
||||
asyncio.run(backup_service.process_backup(backup["id"]))
|
||||
final = backup_service.get_backup(backup["id"])
|
||||
|
||||
assert final["state"] == "completed"
|
||||
assert final["upid"] == "UPID:pve:test"
|
||||
assert final["remote_path"].startswith("onedrive:Backups/Proxmox/")
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_only_one_worker_can_claim_queued_backup(isolated_db, no_notifications, tmp_path):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
backup = backup_service.queue_backup_for_job(job)
|
||||
|
||||
first = backup_service._claim_queued_backup(backup["id"])
|
||||
second = backup_service._claim_queued_backup(backup["id"])
|
||||
|
||||
assert first is not None
|
||||
assert first["state"] == "pve_running"
|
||||
assert second is None
|
||||
|
||||
|
||||
def test_pve_running_without_upid_fails_without_polling_none(isolated_db, no_notifications, monkeypatch):
|
||||
from app import backup_service, commands
|
||||
|
||||
poll_calls = []
|
||||
monkeypatch.setattr(commands, "get_task_status", lambda *args: poll_calls.append(args))
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
|
||||
VALUES ('missing-upid', 110, 'adguard', 'lxc', 'astrid', 'pve_running', ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.process_backup("missing-upid"))
|
||||
final = backup_service.get_backup("missing-upid")
|
||||
|
||||
assert final["state"] == "failed"
|
||||
assert "no Proxmox UPID" in final["error_message"]
|
||||
assert poll_calls == []
|
||||
|
||||
|
||||
def test_local_archive_is_not_deleted_when_remote_verification_fails(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
backup = backup_service.queue_backup_for_job(job)
|
||||
archive = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
|
||||
archive.write_bytes(b"backup")
|
||||
|
||||
monkeypatch.setattr(commands, "start_proxmox_backup", lambda **kwargs: "UPID:pve:test")
|
||||
monkeypatch.setattr(commands, "get_task_status", lambda node, upid: {"status": "stopped", "exitstatus": "OK"})
|
||||
monkeypatch.setattr(commands, "discover_archive", lambda *args: archive)
|
||||
monkeypatch.setattr(commands, "rclone_copyto", lambda *args: None)
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: False)
|
||||
|
||||
asyncio.run(backup_service.process_backup(backup["id"]))
|
||||
final = backup_service.get_backup(backup["id"])
|
||||
|
||||
assert final["state"] == "failed"
|
||||
assert archive.exists()
|
||||
|
||||
|
||||
def test_retention_latest_selects_expired_remote_backups(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
deleted = []
|
||||
|
||||
async def fake_delete_remote_backup(backup, reason):
|
||||
deleted.append((backup["id"], reason))
|
||||
|
||||
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
|
||||
with db() as conn:
|
||||
for index in range(3):
|
||||
backup_id = f"backup-{index}"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, job_id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, 110, 'adguard', 'vm', 'pve', 'completed', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
backup_id,
|
||||
job["id"],
|
||||
f"crypt:backup/{index}",
|
||||
f"2026-01-01T00:0{index}:00+00:00",
|
||||
f"2026-01-01T00:0{index}:30+00:00",
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.apply_retention_for_job(job["id"]))
|
||||
|
||||
assert deleted == [
|
||||
("backup-1", "retention latest=1"),
|
||||
("backup-0", "retention latest=1"),
|
||||
]
|
||||
|
||||
|
||||
def test_startup_recovery_resumes_known_states(isolated_db, no_notifications, monkeypatch):
|
||||
from app import backup_service
|
||||
|
||||
resumed = []
|
||||
deleted = []
|
||||
|
||||
async def fake_process_backup(backup_id):
|
||||
resumed.append(backup_id)
|
||||
|
||||
async def fake_delete_remote_backup(backup, reason):
|
||||
deleted.append((backup["id"], reason))
|
||||
|
||||
monkeypatch.setattr(backup_service, "process_backup", fake_process_backup)
|
||||
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
|
||||
|
||||
with db() as conn:
|
||||
for backup_id, state in (("b1", "local_ready"), ("b2", "deleting"), ("b3", "completed")):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
|
||||
VALUES (?, 110, 'adguard', 'vm', 'pve', ?, ?, ?)
|
||||
""",
|
||||
(backup_id, state, utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.recover_unfinished())
|
||||
|
||||
assert resumed == ["b1"]
|
||||
assert deleted == [("b2", "recovery delete resume")]
|
||||
|
||||
|
||||
def test_retention_sweep_applies_all_jobs_with_completed_remote_backups(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
swept = []
|
||||
|
||||
async def fake_apply_retention_for_job(job_id):
|
||||
swept.append(job_id)
|
||||
|
||||
monkeypatch.setattr(backup_service, "apply_retention_for_job", fake_apply_retention_for_job)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, job_id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES ('sweep-test', ?, 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
'onedrive:pve-cloud-backup/sweep-test/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(job["id"], utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.retention_sweep())
|
||||
|
||||
assert swept == [job["id"]]
|
||||
|
||||
|
||||
def test_manual_backup_delete_removes_remote_and_known_local_file(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
local_file = tmp_path / "orphaned-local.tar.zst"
|
||||
local_file.write_text("backup")
|
||||
deleted_files = []
|
||||
removed_dirs = []
|
||||
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
|
||||
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: deleted_files.append(args))
|
||||
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, guest_vmid, guest_name, guest_type, node, state,
|
||||
local_path, remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES ('manual-delete', 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
?, 'onedrive:pve-cloud-backup/manual-delete/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(str(local_file), utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
deleted = asyncio.run(backup_service.delete_backup_manually("manual-delete"))
|
||||
|
||||
assert deleted["state"] == "deleted"
|
||||
assert not local_file.exists()
|
||||
assert deleted_files == [
|
||||
(
|
||||
"/usr/bin/rclone",
|
||||
"onedrive:pve-cloud-backup/manual-delete/archive.tar.zst",
|
||||
)
|
||||
]
|
||||
assert removed_dirs == [
|
||||
(
|
||||
"/usr/bin/rclone",
|
||||
"onedrive:pve-cloud-backup/manual-delete",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_manual_backup_delete_removes_empty_remote_folder_when_file_is_already_missing(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
removed_dirs = []
|
||||
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: False)
|
||||
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: pytest.fail("deletefile should not run"))
|
||||
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES ('missing-remote-file', 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
'onedrive:pve-cloud-backup/missing-remote-file/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
deleted = asyncio.run(backup_service.delete_backup_manually("missing-remote-file"))
|
||||
|
||||
assert deleted["state"] == "deleted"
|
||||
assert removed_dirs == [
|
||||
(
|
||||
"/usr/bin/rclone",
|
||||
"onedrive:pve-cloud-backup/missing-remote-file",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_manual_backup_delete_refuses_active_backup(isolated_db, no_notifications):
|
||||
from app import backup_service
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
|
||||
VALUES ('active-delete', 110, 'adguard', 'vm', 'pve', 'uploading', ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(backup_service.delete_backup_manually("active-delete"))
|
||||
|
||||
|
||||
def test_deleting_already_deleted_backup_purges_metadata_row(isolated_db, no_notifications):
|
||||
from app import backup_service
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, deleted_at, updated_at
|
||||
)
|
||||
VALUES ('purge-deleted', 110, 'adguard', 'vm', 'pve', 'deleted',
|
||||
'onedrive:pve-cloud-backup/purge-deleted/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
result = asyncio.run(backup_service.delete_backup_manually("purge-deleted"))
|
||||
|
||||
assert result == {"id": "purge-deleted", "purged": True}
|
||||
with db() as conn:
|
||||
assert conn.execute("SELECT COUNT(*) AS count FROM backups WHERE id = 'purge-deleted'").fetchone()["count"] == 0
|
||||
|
||||
|
||||
def test_delete_job_deletes_backups_before_removing_job(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
deleted = []
|
||||
|
||||
async def fake_delete_backup_manually(backup_id, reason="manual deletion"):
|
||||
deleted.append((backup_id, reason))
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE backups SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?", (utc_now(), utc_now(), backup_id))
|
||||
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
monkeypatch.setattr(backup_service, "delete_backup_manually", fake_delete_backup_manually)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, job_id, guest_vmid, guest_name, guest_type, node, state, remote_path, started_at, completed_at, updated_at)
|
||||
VALUES ('job-delete-backup', ?, 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
'onedrive:pve-cloud-backup/job-delete-backup/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(job["id"], utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
result = asyncio.run(backup_service.delete_job_with_backups(job["id"]))
|
||||
|
||||
assert result == {"job_id": job["id"], "deleted_backups": ["job-delete-backup"]}
|
||||
assert deleted == [("job-delete-backup", f"job {job['id']} deleted")]
|
||||
with db() as conn:
|
||||
assert conn.execute("SELECT COUNT(*) AS count FROM backup_jobs WHERE id = ?", (job["id"],)).fetchone()["count"] == 0
|
||||
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import commands
|
||||
|
||||
|
||||
def test_start_proxmox_backup_parses_upid(monkeypatch):
|
||||
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
|
||||
|
||||
def fake_run(command, timeout=None):
|
||||
assert command[:3] == ["pvesh", "create", "/nodes/pve/vzdump"]
|
||||
assert "--output-format" in command
|
||||
return commands.CommandResult(stdout=json.dumps(expected), stderr="")
|
||||
|
||||
monkeypatch.setattr(commands, "run", fake_run)
|
||||
|
||||
assert commands.start_proxmox_backup(
|
||||
node="pve",
|
||||
vmid=110,
|
||||
storage="hitachi",
|
||||
mode="snapshot",
|
||||
compression="zstd",
|
||||
) == expected
|
||||
|
||||
|
||||
def test_start_proxmox_backup_parses_plain_upid(monkeypatch):
|
||||
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
|
||||
|
||||
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=expected, stderr=""))
|
||||
|
||||
assert commands.start_proxmox_backup(
|
||||
node="pve",
|
||||
vmid=110,
|
||||
storage="hitachi",
|
||||
mode="snapshot",
|
||||
compression="zstd",
|
||||
) == expected
|
||||
|
||||
|
||||
def test_start_proxmox_backup_parses_upid_after_info_lines(monkeypatch):
|
||||
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
|
||||
output = f"INFO: starting new backup job\nINFO: Backup job finished successfully\n\"{expected}\""
|
||||
|
||||
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=output, stderr=""))
|
||||
|
||||
assert commands.start_proxmox_backup(
|
||||
node="pve",
|
||||
vmid=110,
|
||||
storage="hitachi",
|
||||
mode="snapshot",
|
||||
compression="zstd",
|
||||
) == expected
|
||||
|
||||
|
||||
def test_list_guests_normalizes_vm_and_lxc(monkeypatch):
|
||||
payload = [
|
||||
{"vmid": "110", "name": "adguard", "type": "qemu", "node": "pve", "status": "running"},
|
||||
{"vmid": "111", "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(commands, "pvesh_json", lambda args: payload)
|
||||
|
||||
guests = commands.list_guests()
|
||||
|
||||
assert guests == [
|
||||
{"vmid": 110, "name": "adguard", "type": "vm", "node": "pve", "status": "running"},
|
||||
{"vmid": 111, "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
|
||||
]
|
||||
|
||||
|
||||
def test_discover_archive_finds_newest_matching_file(tmp_path):
|
||||
old_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
|
||||
new_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.vma.zst"
|
||||
log_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.log"
|
||||
wrong_guest = tmp_path / "vzdump-qemu-999-2026_01_01-00_01_00.vma.zst"
|
||||
for path in (old_file, new_file, log_file, wrong_guest):
|
||||
path.write_text("data")
|
||||
old_file.touch()
|
||||
new_file.touch()
|
||||
log_file.touch()
|
||||
|
||||
found = commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
|
||||
|
||||
assert found == new_file
|
||||
|
||||
|
||||
def test_discover_archive_raises_when_missing(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
|
||||
|
||||
|
||||
def test_rclone_commands_use_exact_objects(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_run(command, timeout=None):
|
||||
calls.append(command)
|
||||
return commands.CommandResult(stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(commands, "run", fake_run)
|
||||
|
||||
commands.rclone_copyto("/usr/bin/rclone", "/tmp/archive.zst", "crypt:backup/archive.zst")
|
||||
commands.rclone_delete_file("/usr/bin/rclone", "crypt:backup/archive.zst")
|
||||
commands.rclone_rmdir("/usr/bin/rclone", "crypt:backup")
|
||||
|
||||
assert calls == [
|
||||
["/usr/bin/rclone", "copyto", "/tmp/archive.zst", "crypt:backup/archive.zst"],
|
||||
["/usr/bin/rclone", "deletefile", "crypt:backup/archive.zst"],
|
||||
["/usr/bin/rclone", "rmdir", "crypt:backup"],
|
||||
]
|
||||
assert all("sync" not in call for call in calls for call in call)
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.jobs import next_run, validate_cron
|
||||
|
||||
|
||||
def test_cron_validation_accepts_standard_expression():
|
||||
validate_cron("0 2 * * *")
|
||||
assert next_run("0 2 * * *")
|
||||
|
||||
|
||||
def test_cron_validation_rejects_invalid_expression():
|
||||
with pytest.raises(ValueError):
|
||||
validate_cron("not a cron")
|
||||
|
||||
|
||||
def test_next_run_interprets_cron_in_configured_timezone():
|
||||
base = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
assert next_run("0 2 * * *", base=base, timezone_name="Europe/Berlin") == "2026-01-01T01:00:00+00:00"
|
||||
@@ -0,0 +1,42 @@
|
||||
from app import commands
|
||||
from app.main import _validate_settings
|
||||
|
||||
|
||||
def test_plain_onedrive_remote_is_accepted(monkeypatch):
|
||||
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
|
||||
|
||||
_validate_settings(
|
||||
{
|
||||
"setup_complete": True,
|
||||
"proxmox_node": "pve",
|
||||
"proxmox_storage": "hitachi",
|
||||
"local_backup_dir": "/mnt/pve/hitachi/dump",
|
||||
"rclone_path": "/usr/bin/rclone",
|
||||
"rclone_remote": "onedrive",
|
||||
"rclone_remote_path": "Backups/Proxmox",
|
||||
"max_concurrent_backups": 1,
|
||||
"timezone": "Europe/Berlin",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_timezone_is_rejected(monkeypatch):
|
||||
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_settings(
|
||||
{
|
||||
"setup_complete": True,
|
||||
"proxmox_node": "pve",
|
||||
"proxmox_storage": "hitachi",
|
||||
"local_backup_dir": "/mnt/pve/hitachi/dump",
|
||||
"rclone_path": "/usr/bin/rclone",
|
||||
"rclone_remote": "onedrive",
|
||||
"rclone_remote_path": "Backups/Proxmox",
|
||||
"max_concurrent_backups": 1,
|
||||
"timezone": "Not/AZone",
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user