more test coverage
Continuous Integration / backend-tests (push) Successful in 32s
Continuous Integration / frontend-check (push) Successful in 17s
Continuous Integration / e2e-tests (push) Successful in 6m37s

This commit is contained in:
2026-05-05 13:51:51 -04:00
parent efa5e5d54e
commit 1dc501f50f
4 changed files with 804 additions and 0 deletions
+51
View File
@@ -58,3 +58,54 @@ def test_backup_history_populated(client, db_session):
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json()) == 1 assert len(response.json()) == 1
assert response.json()[0]["status"] == "COMPLETED" assert response.json()[0]["status"] == "COMPLETED"
def test_trigger_backup_already_running(client, db_session):
"""Tests triggering backup when one is already active returns 400."""
media = models.StorageMedia(
media_type="hdd", identifier="DISK_1", capacity=1000, status="active"
)
db_session.add(media)
db_session.flush()
# Create an active backup job
active_job = models.Job(job_type="BACKUP", status="RUNNING", is_cancelled=False)
db_session.add(active_job)
db_session.commit()
response = client.post(f"/backups/trigger/{media.id}")
assert response.status_code == 400
assert "already running" in response.json()["detail"]
def test_trigger_auto_backup_no_media(client):
"""Tests auto backup with no active media returns 400."""
response = client.post("/backups/trigger/auto")
assert response.status_code == 400
assert "No active media" in response.json()["detail"]
def test_trigger_auto_backup_already_running(client, db_session):
"""Tests auto backup when one is already active returns 400."""
active_job = models.Job(job_type="BACKUP", status="PENDING", is_cancelled=False)
db_session.add(active_job)
db_session.commit()
response = client.post("/backups/trigger/auto")
assert response.status_code == 400
assert "already running" in response.json()["detail"]
def test_trigger_auto_backup_success(client, db_session):
"""Tests triggering auto backup creates a job."""
media = models.StorageMedia(
media_type="hdd", identifier="AUTO_DISK", capacity=1000000000, status="active"
)
db_session.add(media)
db_session.commit()
response = client.post("/backups/trigger/auto")
assert response.status_code == 200
data = response.json()
assert "job_id" in data
assert "Auto-archival job submitted" in data["message"]
+445
View File
@@ -0,0 +1,445 @@
from datetime import datetime, timezone
from app.db import models
# ── list_discrepancies ──
def test_list_discrepancies_empty(client):
"""Tests listing discrepancies when none exist."""
response = client.get("/system/discrepancies")
assert response.status_code == 200
assert response.json() == []
def test_list_discrepancies_deleted_file(client, db_session):
"""Tests listing a confirmed-deleted file in discrepancies."""
file_record = models.FilesystemState(
file_path="/data/old.txt",
size=100,
mtime=1000,
is_deleted=True,
is_ignored=False,
sha256_hash=None,
)
db_session.add(file_record)
db_session.commit()
response = client.get("/system/discrepancies")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["path"] == "/data/old.txt"
assert data[0]["is_deleted"] is True
def test_list_discrepancies_unhashed_missing(client, db_session, tmp_path):
"""Tests listing an unhashed file that is missing from disk."""
# File path that does not exist on disk
missing_path = str(tmp_path / "nonexistent" / "file.txt")
file_record = models.FilesystemState(
file_path=missing_path,
size=100,
mtime=1000,
sha256_hash=None,
is_deleted=False,
is_ignored=False,
)
db_session.add(file_record)
db_session.commit()
response = client.get("/system/discrepancies")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["path"] == missing_path
assert data[0]["is_deleted"] is False
def test_list_discrepancies_ignored_excluded(client, db_session):
"""Tests that ignored files are excluded from discrepancies."""
file_record = models.FilesystemState(
file_path="/data/ignored.txt",
size=100,
mtime=1000,
is_deleted=True,
is_ignored=True,
)
db_session.add(file_record)
db_session.commit()
response = client.get("/system/discrepancies")
assert response.status_code == 200
assert response.json() == []
def test_list_discrepancies_acknowledged_excluded(client, db_session):
"""Tests that acknowledged discrepancies are excluded."""
file_record = models.FilesystemState(
file_path="/data/dismissed.txt",
size=100,
mtime=1000,
is_deleted=True,
is_ignored=False,
missing_acknowledged_at=datetime.now(timezone.utc),
)
db_session.add(file_record)
db_session.commit()
response = client.get("/system/discrepancies")
assert response.status_code == 200
assert response.json() == []
def test_list_discrepancies_has_versions_flag(client, db_session):
"""Tests that has_versions is set based on active/full media."""
active_media = models.StorageMedia(
media_type="hdd", identifier="M1", capacity=1000, status="active"
)
db_session.add(active_media)
db_session.flush()
file1 = models.FilesystemState(
file_path="/data/deleted_with_backup.txt",
size=100,
mtime=1000,
is_deleted=True,
is_ignored=False,
)
db_session.add(file1)
db_session.flush()
db_session.add(
models.FileVersion(
filesystem_state_id=file1.id,
media_id=active_media.id,
file_number="1",
offset_start=0,
offset_end=100,
)
)
db_session.commit()
response = client.get("/system/discrepancies")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["has_versions"] is True
# ── confirm_discrepancy ──
def test_confirm_discrepancy(client, db_session):
"""Tests confirming a file as deleted."""
file_record = models.FilesystemState(
file_path="/data/verify.txt", size=50, mtime=2000, is_deleted=False
)
db_session.add(file_record)
db_session.commit()
response = client.post(f"/system/discrepancies/{file_record.id}/confirm")
assert response.status_code == 200
assert "marked as deleted" in response.json()["message"]
db_session.expire_all()
db_session.refresh(file_record)
assert file_record.is_deleted is True
def test_confirm_discrepancy_not_found(client):
"""Tests confirming a non-existent file returns 404."""
response = client.post("/system/discrepancies/9999/confirm")
assert response.status_code == 404
# ── dismiss_discrepancy ──
def test_dismiss_discrepancy(client, db_session):
"""Tests dismissing a deleted file."""
file_record = models.FilesystemState(
file_path="/data/dismiss.txt", size=50, mtime=2000, is_deleted=True
)
db_session.add(file_record)
db_session.commit()
response = client.post(f"/system/discrepancies/{file_record.id}/dismiss")
assert response.status_code == 200
assert "dismissed" in response.json()["message"]
db_session.expire_all()
db_session.refresh(file_record)
assert file_record.missing_acknowledged_at is not None
def test_undo_dismiss_discrepancy(client, db_session):
"""Tests undoing a dismissed discrepancy."""
file_record = models.FilesystemState(
file_path="/data/undo.txt",
size=50,
mtime=2000,
is_deleted=True,
missing_acknowledged_at=datetime.now(timezone.utc),
)
db_session.add(file_record)
db_session.commit()
response = client.post(f"/system/discrepancies/{file_record.id}/undo-dismiss")
assert response.status_code == 200
assert "undo" in response.json()["message"].lower()
db_session.expire_all()
db_session.refresh(file_record)
assert file_record.missing_acknowledged_at is None
# ── delete_discrepancy ──
def test_delete_discrepancy_hard_delete(client, db_session):
"""Tests hard-deleting a file record and its versions/cart entries."""
media = models.StorageMedia(
media_type="hdd", identifier="M1", capacity=1000, status="active"
)
db_session.add(media)
db_session.flush()
file_record = models.FilesystemState(
file_path="/data/hard_delete.txt", size=100, mtime=1000, is_deleted=True
)
db_session.add(file_record)
db_session.flush()
db_session.add(
models.FileVersion(
filesystem_state_id=file_record.id,
media_id=media.id,
file_number="1",
offset_start=0,
offset_end=100,
)
)
db_session.add(models.RestoreCart(filesystem_state_id=file_record.id))
db_session.commit()
file_id = file_record.id
response = client.delete(f"/system/discrepancies/{file_id}")
assert response.status_code == 200
db_session.expire_all()
assert db_session.get(models.FilesystemState, file_id) is None
assert (
db_session.query(models.FileVersion)
.filter_by(filesystem_state_id=file_id)
.first()
is None
)
assert (
db_session.query(models.RestoreCart)
.filter_by(filesystem_state_id=file_id)
.first()
is None
)
def test_delete_discrepancy_not_found(client):
"""Tests deleting a non-existent discrepancy returns 404."""
response = client.delete("/system/discrepancies/9999")
assert response.status_code == 404
# ── batch operations ──
def test_batch_confirm_discrepancies(client, db_session):
"""Tests batch confirming files as deleted."""
file1 = models.FilesystemState(
file_path="/data/batch1.txt", size=100, mtime=1000, is_deleted=False
)
file2 = models.FilesystemState(
file_path="/data/batch2.txt", size=200, mtime=1000, is_deleted=False
)
db_session.add_all([file1, file2])
db_session.commit()
response = client.post(
"/system/discrepancies/batch/confirm",
json={"ids": [file1.id, file2.id]},
)
assert response.status_code == 200
assert response.json()["count"] == 2
db_session.expire_all()
assert db_session.get(models.FilesystemState, file1.id).is_deleted is True
assert db_session.get(models.FilesystemState, file2.id).is_deleted is True
def test_batch_dismiss_discrepancies(client, db_session):
"""Tests batch dismissing discrepancies."""
file1 = models.FilesystemState(
file_path="/data/dismiss1.txt", size=100, mtime=1000, is_deleted=True
)
db_session.add(file1)
db_session.commit()
response = client.post(
"/system/discrepancies/batch/dismiss",
json={"ids": [file1.id]},
)
assert response.status_code == 200
assert response.json()["count"] == 1
db_session.expire_all()
assert (
db_session.get(models.FilesystemState, file1.id).missing_acknowledged_at
is not None
)
def test_batch_delete_discrepancies(client, db_session):
"""Tests batch hard-deleting discrepancy records."""
file1 = models.FilesystemState(
file_path="/data/del1.txt", size=100, mtime=1000, is_deleted=True
)
db_session.add(file1)
db_session.commit()
response = client.post(
"/system/discrepancies/batch/delete",
json={"ids": [file1.id]},
)
assert response.status_code == 200
assert response.json()["count"] == 1
db_session.expire_all()
assert db_session.get(models.FilesystemState, file1.id) is None
def test_batch_resolve_discrepancies(client, db_session):
"""Tests smart batch resolve: recoverable -> queue, lost -> confirm delete."""
media = models.StorageMedia(
media_type="hdd", identifier="M1", capacity=1000, status="active"
)
db_session.add(media)
db_session.flush()
# File with backup (recoverable)
file_recover = models.FilesystemState(
file_path="/data/recover.txt", size=100, mtime=1000, is_deleted=True
)
# File without backup (lost)
file_lost = models.FilesystemState(
file_path="/data/lost.txt", size=200, mtime=1000, is_deleted=True
)
db_session.add_all([file_recover, file_lost])
db_session.flush()
db_session.add(
models.FileVersion(
filesystem_state_id=file_recover.id,
media_id=media.id,
file_number="1",
offset_start=0,
offset_end=100,
)
)
db_session.commit()
response = client.post(
"/system/discrepancies/batch/resolve",
json={"ids": [file_recover.id, file_lost.id]},
)
assert response.status_code == 200
data = response.json()
assert data["recovered_count"] == 1
assert data["lost_count"] == 1
assert "/data/recover.txt" in data["recovered_paths"]
assert "/data/lost.txt" in data["lost_paths"]
db_session.expire_all()
# Recoverable should be in restore cart
assert (
db_session.query(models.RestoreCart)
.filter_by(filesystem_state_id=file_recover.id)
.first()
is not None
)
# Lost should be marked deleted and dismissed
lost_record = db_session.get(models.FilesystemState, file_lost.id)
assert lost_record.is_deleted is True
assert lost_record.missing_acknowledged_at is not None
def test_batch_resolve_by_path_prefix(client, db_session):
"""Tests batch resolve using path_prefix instead of ids."""
file1 = models.FilesystemState(
file_path="/data/lost1.txt", size=100, mtime=1000, is_deleted=True
)
db_session.add(file1)
db_session.commit()
response = client.post(
"/system/discrepancies/batch/resolve",
json={"path_prefix": "/data"},
)
assert response.status_code == 200
assert response.json()["lost_count"] == 1
def test_batch_action_no_ids_or_prefix(client):
"""Tests batch actions without ids or path_prefix return 400."""
for endpoint in ["confirm", "dismiss", "delete", "resolve"]:
response = client.post(f"/system/discrepancies/batch/{endpoint}", json={})
assert response.status_code == 400
# ── discrepancy tree ──
def test_discrepancy_tree_empty(client):
"""Tests discrepancy tree when no discrepancies exist."""
response = client.get("/system/discrepancies/tree")
assert response.status_code == 200
assert response.json() == []
def test_discrepancy_tree_root(client, db_session):
"""Tests discrepancy tree at ROOT level."""
file1 = models.FilesystemState(
file_path="data/file1.txt", size=100, mtime=1000, is_deleted=True
)
db_session.add(file1)
db_session.commit()
response = client.get("/system/discrepancies/tree")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
# ── discrepancy browse ──
def test_discrepancy_browse_empty(client):
"""Tests discrepancy browse when no discrepancies exist."""
response = client.get("/system/discrepancies/browse")
assert response.status_code == 200
data = response.json()
assert data["files"] == []
def test_discrepancy_browse_with_files(client, db_session):
"""Tests discrepancy browse returns files and directories."""
file1 = models.FilesystemState(
file_path="data/sub/file1.txt", size=100, mtime=1000, is_deleted=True
)
db_session.add(file1)
db_session.commit()
response = client.get("/system/discrepancies/browse?path=ROOT")
assert response.status_code == 200
data = response.json()
assert "files" in data
+134
View File
@@ -560,3 +560,137 @@ def test_update_status_to_retired_purges_versions(client, db_session):
{"media_id": media.id}, {"media_id": media.id},
).scalar() ).scalar()
assert result == 0 assert result == 0
# ── Archive Tree ──
def test_archive_tree_with_versions(client, db_session):
"""Tests archive tree returns source roots with versions."""
db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"])))
db_session.flush()
media = models.StorageMedia(
media_type="hdd", identifier="M1", capacity=1000, status="active"
)
db_session.add(media)
db_session.flush()
file1 = models.FilesystemState(file_path="data/file1.txt", size=100, mtime=1000)
db_session.add(file1)
db_session.flush()
db_session.add(
models.FileVersion(
filesystem_state_id=file1.id,
media_id=media.id,
file_number="1",
offset_start=0,
offset_end=100,
)
)
db_session.commit()
response = client.get("/archive/tree")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "data"
assert data[0]["has_children"] is True
def test_archive_tree_nested_directories(client, db_session):
"""Tests archive tree with nested directories."""
db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"])))
db_session.flush()
media = models.StorageMedia(
media_type="hdd", identifier="M1", capacity=1000, status="active"
)
db_session.add(media)
db_session.flush()
file1 = models.FilesystemState(
file_path="data/subdir/file1.txt", size=100, mtime=1000
)
db_session.add(file1)
db_session.flush()
db_session.add(
models.FileVersion(
filesystem_state_id=file1.id,
media_id=media.id,
file_number="1",
offset_start=0,
offset_end=100,
)
)
db_session.commit()
response = client.get("/archive/tree")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "data"
def test_archive_tree_empty_no_versions(client, db_session):
"""Tests archive tree excludes roots with no versions."""
db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"])))
db_session.flush()
# Add file but no versions
file1 = models.FilesystemState(file_path="data/file1.txt", size=100, mtime=1000)
db_session.add(file1)
db_session.commit()
response = client.get("/archive/tree")
assert response.status_code == 200
assert response.json() == []
# ── Metadata Directory ──
def test_metadata_directory(client, db_session):
"""Tests metadata endpoint for a directory returns aggregated stats."""
db_session.add(models.SystemSetting(key="source_roots", value=json.dumps(["data"])))
db_session.flush()
media = models.StorageMedia(
media_type="hdd", identifier="M1", capacity=1000, status="active"
)
db_session.add(media)
db_session.flush()
file1 = models.FilesystemState(file_path="data/sub/file1.txt", size=100, mtime=1000)
file2 = models.FilesystemState(file_path="data/sub/file2.txt", size=200, mtime=1000)
db_session.add_all([file1, file2])
db_session.flush()
db_session.add(
models.FileVersion(
filesystem_state_id=file1.id,
media_id=media.id,
file_number="1",
offset_start=0,
offset_end=100,
)
)
db_session.add(
models.FileVersion(
filesystem_state_id=file2.id,
media_id=media.id,
file_number="2",
offset_start=0,
offset_end=200,
)
)
db_session.commit()
response = client.get("/archive/metadata?path=data/sub")
assert response.status_code == 200
data = response.json()
assert data["type"] == "directory"
assert data["child_count"] == 2
assert data["size"] == 300
+174
View File
@@ -504,3 +504,177 @@ def test_update_existing_secret(client):
response = client.get("/system/secrets/ rotating-key ") response = client.get("/system/secrets/ rotating-key ")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["value"] == "new-value" assert response.json()["value"] == "new-value"
# ── Filesystem Browse ──
def test_filesystem_browse_root(client, db_session):
"""Tests browsing the filesystem at ROOT level."""
db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]'))
db_session.commit()
response = client.get("/system/browse?path=ROOT")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 1
assert data["files"][0]["path"] == "/source_data"
assert data["files"][0]["type"] == "directory"
def test_filesystem_browse_subdirectory(client, db_session):
"""Tests browsing a subdirectory of the filesystem index."""
db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]'))
db_session.flush()
file1 = models.FilesystemState(
file_path="/source_data/subdir/file1.txt", size=100, mtime=1000
)
db_session.add(file1)
db_session.commit()
response = client.get("/system/browse?path=/source_data/subdir")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 1
assert data["files"][0]["name"] == "file1.txt"
assert data["files"][0]["type"] == "file"
def test_filesystem_browse_outside_roots(client, db_session):
"""Tests browsing outside configured roots returns 403."""
db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]'))
db_session.commit()
response = client.get("/system/browse?path=/etc")
assert response.status_code == 403
# ── Filesystem Search ──
def test_filesystem_search_too_short(client):
"""Tests search with query < 3 chars returns empty list."""
response = client.get("/system/search?q=ab")
assert response.status_code == 200
assert response.json() == []
# ── Hardware Discover ──
def test_discover_hardware_empty(client):
"""Tests hardware discovery endpoint returns a list (may contain real mounts)."""
response = client.get("/system/hardware/discover")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# Each discovered node should have required fields if present
for node in data:
assert "type" in node
assert "identifier" in node
assert "is_registered" in node
# ── Hardware Ignore ──
def test_ignore_hardware_duplicate(client):
"""Tests ignoring the same hardware twice is idempotent."""
client.post("/system/hardware/ignore", json={"identifier": "DISK_DUP"})
response = client.post("/system/hardware/ignore", json={"identifier": "DISK_DUP"})
assert response.status_code == 200
response = client.get("/system/settings")
assert response.json()["ignored_hardware"].count("DISK_DUP") == 1
# ── Database Export ──
def test_database_export(client):
"""Tests database export endpoint returns a file response."""
response = client.get("/system/database/export")
# May return 200 with file or 404 if db path not found
assert response.status_code in (200, 404)
# ── Tracking Batch ──
def test_batch_track_include(client, db_session):
"""Tests batch tracking include action sets is_ignored=0."""
file1 = models.FilesystemState(
file_path="/data/important.txt", size=100, mtime=1000, is_ignored=True
)
db_session.add(file1)
db_session.commit()
response = client.post(
"/system/track/batch",
json={"tracks": ["/data/important.txt"], "untracks": []},
)
assert response.status_code == 200
assert "synchronized" in response.json()["message"]
db_session.expire_all()
assert db_session.get(models.FilesystemState, file1.id).is_ignored is False
# Verify tracked source record was created
ts = (
db_session.query(models.TrackedSource)
.filter_by(path="/data/important.txt")
.first()
)
assert ts is not None
assert ts.action == "include"
def test_batch_track_exclude(client, db_session):
"""Tests batch tracking exclude action sets is_ignored=1."""
file1 = models.FilesystemState(
file_path="/data/temp.txt", size=100, mtime=1000, is_ignored=False
)
db_session.add(file1)
db_session.commit()
response = client.post(
"/system/track/batch",
json={"tracks": [], "untracks": ["/data/temp.txt"]},
)
assert response.status_code == 200
db_session.expire_all()
assert db_session.get(models.FilesystemState, file1.id).is_ignored is True
ts = db_session.query(models.TrackedSource).filter_by(path="/data/temp.txt").first()
assert ts is not None
assert ts.action == "exclude"
def test_batch_track_empty(client):
"""Tests batch track with empty lists succeeds."""
response = client.post("/system/track/batch", json={"tracks": [], "untracks": []})
assert response.status_code == 200
# ── Archive Tree ──
def test_archive_tree_empty(client):
"""Tests archive tree when index is empty."""
response = client.get("/archive/tree")
assert response.status_code == 200
assert response.json() == []
# ── Notifications ──
def test_test_notification_invalid_url(client):
"""Tests test notification with invalid URL returns 500."""
response = client.post(
"/system/notifications/test", json={"url": "not-a-valid-url"}
)
# Notification manager may succeed or fail depending on apprise parsing
assert response.status_code in (200, 500)