more test coverage
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user