mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill landed and pointed at the next inconsistency: the per- archive delete dialog has had a "Also remove this print from Quick Stats" checkbox since #1343, but the "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. From the user side this looked like "automatically deleted from statistics without any warning" — half- true, and the inconsistency was real either way. The actual current shape (before this fix): - POST /archives/purge -> archive_purge_service.purge_older_than -> ArchiveService.delete_archive (hard). Archive row dropped. Linked PrintLogEntry rows have ON DELETE SET NULL so they survive as orphans with archive_id=NULL. Quick Stats keeps the filament / cost / energy contribution because the log rows are still there, but the archive-list-iterating widgets (Filament Trends, By Material, Color Distribution, Printer Stats) lose the row, and Time Accuracy loses its join target. Visibly inconsistent. - Scheduled _maybe_run_auto_purge -> same code path, same effect. - Single-archive DELETE /archives/{id} -> already takes purge_stats=true|false (default false=soft) and routes either soft_delete_archive (keeps everything, flips deleted_at) or deletes PrintLogEntry rows first + hard-deletes archive. The fix threads the same purge_stats flag through every bulk surface with soft as the default, matching the single-archive default: Backend: - archive_purge_service.purge_older_than(..., purge_stats=False) -> per-row soft_delete_archive when False, per-row PrintLogEntry deletion + delete_archive when True. Each runs in its own session (same pattern the sweeper already used). - preview_purge gains the same kwarg so the eligible-count matches what an actual run would touch: soft mode excludes already-soft-deleted rows, hard mode counts them as eligible for promotion. - get_settings / set_settings now persist archive_auto_purge_stats (default False). _maybe_run_auto_purge reads it on every tick. - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings schemas extended. - Route /archives/purge accepts the body flag, /purge/preview accepts the query param, /purge/settings GET + PUT echo the setting. Frontend: - "Purge old archives" modal: new "Also remove from statistics" checkbox under the preview, unchecked by default. Plumbed into the preview query key + the execute mutation. - Settings -> Archives auto-purge card: matching toggle next to the days slider, disabled when auto-purge itself is off. - api.previewArchivePurge / api.executeArchivePurge accept the flag; ArchivePurgeSettings type gains purge_stats. - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get new purgeStatsLabel / purgeStatsHint / purgeStatsDescription keys, plus rewritten effect / warning copy in archivePurge and archiveAutoPurge to reflect that the default no longer "permanently removes from the database" but instead hides the row + removes files while keeping Quick Stats intact. i18n parity check clean: 4814 keys across all 8 locales, no fallback. Behaviour change for existing users on auto-purge: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade those installs start *preserving* more data in Quick Stats rather than losing it — safer direction of the two, but worth the explicit call-out. Anyone who wants the old behaviour ticks the new toggle once and it persists.
258 lines
9.9 KiB
Python
258 lines
9.9 KiB
Python
"""Integration tests for archive auto-purge (#1008 follow-up)."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_settings_defaults_when_unset(async_client: AsyncClient):
|
|
"""GET /archives/purge/settings returns sensible defaults on a fresh install."""
|
|
resp = await async_client.get("/api/v1/archives/purge/settings")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["enabled"] is False
|
|
assert body["days"] == 365
|
|
# #1390: default soft-delete — preserves Quick Stats contribution.
|
|
assert body["purge_stats"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_settings_roundtrip(async_client: AsyncClient):
|
|
"""PUT persists, GET returns the saved values, days is clamped."""
|
|
resp = await async_client.put(
|
|
"/api/v1/archives/purge/settings",
|
|
json={"enabled": True, "days": 180, "purge_stats": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
|
|
|
|
resp = await async_client.get("/api/v1/archives/purge/settings")
|
|
assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_settings_rejects_out_of_range_days(async_client: AsyncClient):
|
|
"""days below MIN or above MAX is rejected."""
|
|
resp = await async_client.put(
|
|
"/api/v1/archives/purge/settings",
|
|
json={"enabled": True, "days": 1},
|
|
)
|
|
# Pydantic validation returns 422; explicit bound check returns 400.
|
|
assert resp.status_code in (400, 422)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_preview_counts_old_archives(async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
|
"""Preview returns the count + total bytes of archives older than the threshold."""
|
|
printer = await printer_factory()
|
|
old = await archive_factory(printer.id, print_name="Old", file_size=1000)
|
|
fresh = await archive_factory(printer.id, print_name="Fresh", file_size=2000)
|
|
|
|
old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.get("/api/v1/archives/purge/preview?older_than_days=365")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["count"] == 1
|
|
assert body["total_bytes"] == 1000
|
|
assert "Old" in body["sample_filenames"][0] or old.filename in body["sample_filenames"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_preview_ignores_recently_reprinted_archives(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Reprints update completed_at but leave created_at pinned; purge must honour that."""
|
|
printer = await printer_factory()
|
|
reprinted = await archive_factory(printer.id, print_name="Reprinted", file_size=1000)
|
|
|
|
# Originally printed 400 days ago, but a reprint last week refreshed completed_at.
|
|
reprinted.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
reprinted.started_at = datetime.now(timezone.utc) - timedelta(days=7)
|
|
reprinted.completed_at = datetime.now(timezone.utc) - timedelta(days=7)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.get("/api/v1/archives/purge/preview?older_than_days=365")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["count"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_manual_purge_soft_deletes_by_default(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""#1390: POST /archives/purge with no body flag soft-deletes — files
|
|
off disk, ``deleted_at`` set, archive row survives so Quick Stats keeps
|
|
every contribution. Matches the single-archive delete default from #1343."""
|
|
from backend.app.models.archive import PrintArchive
|
|
|
|
printer = await printer_factory()
|
|
old = await archive_factory(printer.id, print_name="Old")
|
|
fresh = await archive_factory(printer.id, print_name="Fresh")
|
|
|
|
old_id = old.id
|
|
fresh_id = fresh.id
|
|
old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.post(
|
|
"/api/v1/archives/purge",
|
|
json={"older_than_days": 365},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["deleted"] == 1
|
|
assert body["purge_stats"] is False
|
|
|
|
db_session.expire_all()
|
|
# Old row still exists in DB but is soft-deleted.
|
|
old_row = await db_session.get(PrintArchive, old_id)
|
|
assert old_row is not None
|
|
assert old_row.deleted_at is not None
|
|
fresh_row = await db_session.get(PrintArchive, fresh_id)
|
|
assert fresh_row is not None
|
|
assert fresh_row.deleted_at is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_manual_purge_hard_deletes_when_purge_stats_set(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""#1390: when ``purge_stats=true`` is sent in the body, the bulk purge
|
|
hard-deletes the archive AND the linked PrintLogEntry rows so the
|
|
contribution drops from /stats — matches the single-archive route's
|
|
``?purge_stats=true`` semantics."""
|
|
from backend.app.models.archive import PrintArchive
|
|
|
|
printer = await printer_factory()
|
|
old = await archive_factory(printer.id, print_name="Old")
|
|
old_id = old.id
|
|
old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.post(
|
|
"/api/v1/archives/purge",
|
|
json={"older_than_days": 365, "purge_stats": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["deleted"] == 1
|
|
assert body["purge_stats"] is True
|
|
|
|
db_session.expire_all()
|
|
assert await db_session.get(PrintArchive, old_id) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_auto_purge_soft_deletes_by_default(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""#1390: scheduled auto-purge defaults to soft-delete — Quick Stats
|
|
preserved unless the admin explicitly opts into hard-delete via the
|
|
settings toggle.
|
|
|
|
``async_client`` is included solely so its fixture activates the module-level
|
|
``async_session`` patches that let :meth:`purge_older_than`'s per-row
|
|
delete sessions reach the in-memory test database.
|
|
"""
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.services.archive_purge import archive_purge_service
|
|
|
|
printer = await printer_factory()
|
|
stale = await archive_factory(printer.id, print_name="Stale")
|
|
stale_id = stale.id
|
|
stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
await db_session.commit()
|
|
|
|
await archive_purge_service.set_settings(db_session, enabled=True, days=365)
|
|
|
|
deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
|
|
assert deleted >= 1
|
|
|
|
db_session.expire_all()
|
|
stale_row = await db_session.get(PrintArchive, stale_id)
|
|
assert stale_row is not None
|
|
assert stale_row.deleted_at is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_auto_purge_hard_deletes_when_settings_opts_in(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""#1390: scheduled auto-purge honours the ``purge_stats`` setting —
|
|
when True the sweeper hard-deletes archive rows AND linked PrintLogEntry
|
|
rows, dropping every contribution from /stats."""
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.services.archive_purge import archive_purge_service
|
|
|
|
printer = await printer_factory()
|
|
stale = await archive_factory(printer.id, print_name="Stale")
|
|
stale_id = stale.id
|
|
stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
await db_session.commit()
|
|
|
|
await archive_purge_service.set_settings(db_session, enabled=True, days=365, purge_stats=True)
|
|
|
|
deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
|
|
assert deleted >= 1
|
|
|
|
db_session.expire_all()
|
|
assert await db_session.get(PrintArchive, stale_id) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_auto_purge_throttles_within_24h(async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
|
"""A recent last-run timestamp blocks the sweeper for 24h."""
|
|
from backend.app.services.archive_purge import archive_purge_service
|
|
|
|
printer = await printer_factory()
|
|
stale = await archive_factory(printer.id, print_name="Stale")
|
|
stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
await db_session.commit()
|
|
|
|
await archive_purge_service.set_settings(db_session, enabled=True, days=365)
|
|
# Stamp a last-run time 1h ago — should block the sweeper for another 23h.
|
|
await archive_purge_service._stamp_last_run(db_session, datetime.now(timezone.utc) - timedelta(hours=1))
|
|
|
|
deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
|
|
assert deleted == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_auto_purge_skipped_when_disabled(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""When the toggle is off, old archives stay put."""
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.services.archive_purge import archive_purge_service
|
|
|
|
printer = await printer_factory()
|
|
stale = await archive_factory(printer.id, print_name="Stale")
|
|
stale_id = stale.id
|
|
stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
|
|
await db_session.commit()
|
|
|
|
await archive_purge_service.set_settings(db_session, enabled=False, days=365)
|
|
deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
|
|
assert deleted == 0
|
|
|
|
db_session.expire_all()
|
|
assert await db_session.get(PrintArchive, stale_id) is not None
|