forked from retoor/devplacepy
Also streamline the top navigation: drop the Tools dropdown, make Quizzes and Battles icon-only entries, and remove the Workspace, Containers and Editor entry points from the project detail page.
156 lines
5.4 KiB
Python
156 lines
5.4 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from devplacepy.services.backup import store
|
|
|
|
|
|
def _make_backup(target, *, offloaded, missing_local=False, schedule_uid=""):
|
|
job_uid = f"job-{store.generate_uid()}"
|
|
uid = store.create_backup(
|
|
target=target, created_by="test", job_uid=job_uid, schedule_uid=schedule_uid
|
|
)
|
|
path = Path(tempfile.gettempdir()) / f"backup-store-test-{uid}.tar.gz"
|
|
if not missing_local:
|
|
path.write_bytes(b"x" * 10)
|
|
store.finalize_backup(
|
|
uid,
|
|
filename=path.name,
|
|
local_path=str(path),
|
|
stats={"bytes_out": 10, "bytes_in": 10, "file_count": 1, "dir_count": 0, "sha256": "abc"},
|
|
)
|
|
if offloaded:
|
|
store.mark_remote_uploaded(uid, f"remote:{target}/{path.name}")
|
|
return uid, path
|
|
|
|
|
|
def test_prune_orphans_spares_offloaded_backups_with_purged_local_copies(local_db):
|
|
offloaded_uid, _ = _make_backup("database", offloaded=True)
|
|
store.mark_local_purged(offloaded_uid)
|
|
orphan_uid, _ = _make_backup("database", offloaded=False, missing_local=True)
|
|
|
|
try:
|
|
removed = store.prune_orphans()
|
|
|
|
remaining = {row["uid"] for row in store.list_backups(limit=1000)}
|
|
assert offloaded_uid in remaining
|
|
assert orphan_uid not in remaining
|
|
assert removed >= 1
|
|
finally:
|
|
store.delete_backup(offloaded_uid)
|
|
|
|
|
|
def test_rotate_schedule_never_removes_a_backup_without_a_confirmed_remote_copy(local_db):
|
|
schedule_uid = store.create_schedule(
|
|
name="test-rotate",
|
|
target="database",
|
|
kind="interval",
|
|
every_seconds=86400,
|
|
cron="",
|
|
keep_last=1,
|
|
created_by="test",
|
|
next_run_at="2026-01-01T00:00:00",
|
|
)
|
|
older_uid, _ = _make_backup("database", offloaded=False, schedule_uid=schedule_uid)
|
|
newer_uid, _ = _make_backup("database", offloaded=True, schedule_uid=schedule_uid)
|
|
|
|
try:
|
|
removed = store.rotate_schedule(schedule_uid, keep_last=1)
|
|
|
|
remaining = {row["uid"] for row in store.list_backups(limit=1000)}
|
|
assert removed == 0
|
|
assert older_uid in remaining
|
|
assert newer_uid in remaining
|
|
finally:
|
|
store.delete_backup(older_uid)
|
|
store.delete_backup(newer_uid)
|
|
|
|
|
|
def _point_storage_at(monkeypatch, root: Path):
|
|
uploads = root / "uploads"
|
|
attachments = uploads / "attachments"
|
|
project_files = uploads / "project_files"
|
|
keys = root / "keys"
|
|
zips = root / "zips"
|
|
deepsearch = root / "deepsearch"
|
|
workspaces = root / "container_workspaces"
|
|
backups = root / "backups"
|
|
for path in (
|
|
attachments,
|
|
project_files,
|
|
keys,
|
|
zips,
|
|
deepsearch,
|
|
workspaces,
|
|
backups,
|
|
):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
db_file = root / "devplace.db"
|
|
db_file.write_bytes(b"db")
|
|
tasks = root / "devii_tasks.db"
|
|
tasks.write_bytes(b"t")
|
|
lessons = root / "devii_lessons.db"
|
|
lessons.write_bytes(b"l")
|
|
(project_files / "a.bin").write_bytes(b"x" * 10)
|
|
(attachments / "b.bin").write_bytes(b"y" * 5)
|
|
monkeypatch.setattr(store.config, "DATA_DIR", root)
|
|
monkeypatch.setattr(store.config, "UPLOADS_DIR", uploads)
|
|
monkeypatch.setattr(store.config, "ATTACHMENTS_DIR", attachments)
|
|
monkeypatch.setattr(store.config, "PROJECT_FILES_DIR", project_files)
|
|
monkeypatch.setattr(store.config, "KEYS_DIR", keys)
|
|
monkeypatch.setattr(store.config, "ZIPS_DIR", zips)
|
|
monkeypatch.setattr(store.config, "DEEPSEARCH_DIR", deepsearch)
|
|
monkeypatch.setattr(store.config, "CONTAINER_WORKSPACES_DIR", workspaces)
|
|
monkeypatch.setattr(store.config, "BACKUPS_DIR", backups)
|
|
monkeypatch.setattr(store.config, "DEVII_TASKS_DB", tasks)
|
|
monkeypatch.setattr(store.config, "DEVII_LESSONS_DB", lessons)
|
|
monkeypatch.setattr(store.config, "DATABASE_URL", f"sqlite:///{db_file}")
|
|
store.clear_storage_stats_cache()
|
|
return {
|
|
"uploads": uploads,
|
|
"attachments": attachments,
|
|
"project_files": project_files,
|
|
"db_file": db_file,
|
|
}
|
|
|
|
|
|
def test_disk_usage_does_not_walk(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(store.config, "DATA_DIR", tmp_path)
|
|
store.clear_storage_stats_cache()
|
|
|
|
def boom(*_args, **_kwargs):
|
|
raise AssertionError("disk_usage must not walk the data tree")
|
|
|
|
monkeypatch.setattr(store.os, "walk", boom)
|
|
disk = store.disk_usage()
|
|
assert disk["total_bytes"] > 0
|
|
assert "used_percent" in disk
|
|
|
|
|
|
def test_compute_storage_stats_walks_data_dir_once(local_db, monkeypatch, tmp_path):
|
|
root = tmp_path / "data"
|
|
_point_storage_at(monkeypatch, root)
|
|
walks = []
|
|
real_walk = os.walk
|
|
|
|
def counting_walk(path, *args, **kwargs):
|
|
walks.append(os.path.realpath(path))
|
|
return real_walk(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(store.os, "walk", counting_walk)
|
|
stats = store.compute_storage_stats()
|
|
assert walks == [os.path.realpath(root)]
|
|
by_key = {row["key"]: row for row in stats["paths"]}
|
|
assert by_key["project_files"]["file_count"] == 1
|
|
assert by_key["project_files"]["size_bytes"] == 10
|
|
assert by_key["attachments"]["file_count"] == 1
|
|
assert by_key["uploads"]["file_count"] >= 2
|
|
assert by_key["database"]["file_count"] == 1
|
|
assert stats["data_dir"]["file_count"] >= 3
|
|
walks.clear()
|
|
again = store.compute_storage_stats()
|
|
assert walks == []
|
|
assert again["data_dir"]["file_count"] == stats["data_dir"]["file_count"]
|