Files
bambuddy/backend/tests/integration/test_archives_api.py
T
maziggy 9ba1e34729 fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
  then deleting nine archive entries (to keep the file list tidy) silently
  rewound the totals on the Statistics page — total prints, filament,
  cost, and per-print energy all dropped back to whatever the surviving
  row contributed, as if the other nine prints had never happened.

  Root cause: every metric in get_archive_stats is recomputed live from
  PrintArchive rows via COUNT / SUM, so removing a row removes its
  contribution. Energy in the default "Total" mode already survived
  deletion because it reads the smart-plug lifetime counters — that's
  the architectural shape we now generalise to the rest.

  Fix: soft delete with opt-in hard purge.

  Backend:
  - New nullable, indexed deleted_at column on print_archives, dialect-
    conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
  - ArchiveService.soft_delete_archive flips deleted_at and removes the
    files from disk (still reclaims storage); the path-safety checks were
    extracted into _resolve_archive_dir_for_delete so soft and hard delete
    share the rules.
  - DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
  - Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
    fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
    detection (so a 1-live + 9-soft-deleted group no longer marks the
    survivor as a duplicate), and ArchiveComparisonService's "similar"
    suggestions. GET /stats and GET /slim deliberately do NOT filter so
    Quick Stats and the dashboard widgets keep counting deleted prints.

  Frontend:
  - ConfirmModal gained an optional children slot.
  - ArchivesPage (both card and detail views) own a per-instance
    deletePurgeStats boolean and render an opt-in checkbox in the delete
    dialog; resets to off on every close so the destructive option is
    never sticky.
  - api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
    when the box is ticked.
  - One new i18n key archives.modal.deletePurgeStats added across all 8
    locales (full German, English fallbacks elsewhere).
2026-05-15 09:21:43 +02:00

811 lines
32 KiB
Python

"""Integration tests for Archives API endpoints.
Tests the full request/response cycle for /api/v1/archives/ endpoints.
"""
import pytest
from httpx import AsyncClient
class TestArchivesAPI:
"""Integration tests for /api/v1/archives/ endpoints."""
# ========================================================================
# List endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_archives_empty(self, async_client: AsyncClient):
"""Verify empty list is returned when no archives exist."""
response = await async_client.get("/api/v1/archives/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_archives_with_data(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify list returns existing archives."""
printer = await printer_factory()
await archive_factory(printer.id, print_name="Test Archive")
response = await async_client.get("/api/v1/archives/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
assert any(a["print_name"] == "Test Archive" for a in data)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_archives_pagination(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify pagination works correctly."""
printer = await printer_factory()
# Create 5 archives
for i in range(5):
await archive_factory(printer.id, print_name=f"Archive {i}")
# Get first page with limit 2
response = await async_client.get("/api/v1/archives/?limit=2&offset=0")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_archives_filter_by_printer(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify filtering by printer_id works."""
printer1 = await printer_factory(name="Printer 1", serial_number="00M09A000000001")
printer2 = await printer_factory(name="Printer 2", serial_number="00M09A000000002")
await archive_factory(printer1.id, print_name="Printer 1 Archive")
await archive_factory(printer2.id, print_name="Printer 2 Archive")
response = await async_client.get(f"/api/v1/archives/?printer_id={printer1.id}")
assert response.status_code == 200
data = response.json()
assert all(a["printer_id"] == printer1.id for a in data)
# ========================================================================
# Get single endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify single archive can be retrieved."""
printer = await printer_factory()
archive = await archive_factory(printer.id, print_name="Get Test Archive")
response = await async_client.get(f"/api/v1/archives/{archive.id}")
assert response.status_code == 200
result = response.json()
assert result["id"] == archive.id
assert result["print_name"] == "Get Test Archive"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_archive_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent archive."""
response = await async_client.get("/api/v1/archives/9999")
assert response.status_code == 404
# ========================================================================
# Update endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify archive name can be updated."""
printer = await printer_factory()
archive = await archive_factory(printer.id, print_name="Original Name")
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"print_name": "Updated Name"})
assert response.status_code == 200
assert response.json()["print_name"] == "Updated Name"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify archive notes can be updated."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Great print!"})
assert response.status_code == 200
assert response.json()["notes"] == "Great print!"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_archive_favorite(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify archive favorite status can be updated."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"is_favorite": True})
assert response.status_code == 200
assert response.json()["is_favorite"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_archive_external_url(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify archive external_url can be updated."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
response = await async_client.patch(
f"/api/v1/archives/{archive.id}", json={"external_url": "https://printables.com/model/12345"}
)
assert response.status_code == 200
assert response.json()["external_url"] == "https://printables.com/model/12345"
# Verify it can be cleared
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"external_url": None})
assert response.status_code == 200
assert response.json()["external_url"] is None
# ========================================================================
# Delete endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify archive can be deleted."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
archive_id = archive.id
response = await async_client.delete(f"/api/v1/archives/{archive_id}")
assert response.status_code == 200
# Verify deleted
response = await async_client.get(f"/api/v1/archives/{archive_id}")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_nonexistent_archive(self, async_client: AsyncClient):
"""Verify deleting non-existent archive returns 404."""
response = await async_client.delete("/api/v1/archives/9999")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_soft_delete_preserves_stats_contribution(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""#1343: deleting an archive without ``purge_stats`` keeps its
contribution in Quick Stats. The row vanishes from listings but the
filament / time / cost totals stay intact.
"""
printer = await printer_factory()
await archive_factory(
printer.id,
status="completed",
print_time_seconds=3600,
filament_used_grams=50.0,
cost=1.50,
)
archive_to_delete = await archive_factory(
printer.id,
status="completed",
print_time_seconds=7200,
filament_used_grams=100.0,
cost=3.00,
)
# Pre-delete: stats include both archives.
pre = (await async_client.get("/api/v1/archives/stats")).json()
assert pre["total_prints"] == 2
assert pre["total_filament_grams"] == 150.0
assert pre["total_cost"] == 4.50
# Soft delete (default — no purge_stats param).
resp = await async_client.delete(f"/api/v1/archives/{archive_to_delete.id}")
assert resp.status_code == 200
body = resp.json()
assert body["purged_from_stats"] is False
# Listing hides the deleted archive…
listing = (await async_client.get("/api/v1/archives/")).json()
assert all(a["id"] != archive_to_delete.id for a in listing)
# …but stats still reflect both prints (the whole point of #1343).
post = (await async_client.get("/api/v1/archives/stats")).json()
assert post["total_prints"] == 2
assert post["total_filament_grams"] == 150.0
assert post["total_cost"] == 4.50
@pytest.mark.asyncio
@pytest.mark.integration
async def test_purge_stats_drops_archive_from_quick_stats(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""#1343: deleting with ``?purge_stats=true`` hard-deletes the row,
dropping its contribution from Quick Stats (the original behaviour,
now opt-in)."""
printer = await printer_factory()
keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
assert resp.status_code == 200
assert resp.json()["purged_from_stats"] is True
stats = (await async_client.get("/api/v1/archives/stats")).json()
assert stats["total_prints"] == 1
assert stats["total_filament_grams"] == 50.0
# The kept archive is still listed.
listing = (await async_client.get("/api/v1/archives/")).json()
assert [a["id"] for a in listing] == [keep.id]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_soft_deleted_archive_404_on_detail(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""A soft-deleted archive must 404 on GET — a stale bookmark or
direct URL should not expose a row the user has already removed."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
await async_client.delete(f"/api/v1/archives/{archive.id}")
resp = await async_client.get(f"/api/v1/archives/{archive.id}")
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_soft_deleted_archive_hidden_from_search(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Search must skip soft-deleted archives. Uses the LIKE fallback by
querying a single-character pattern that the SQLite FTS5 rejects, so
the test covers the fallback path that the production FTS path also
respects."""
printer = await printer_factory()
archive = await archive_factory(printer.id, print_name="UniqueSoftDeleteCandidate")
await async_client.delete(f"/api/v1/archives/{archive.id}")
resp = await async_client.get("/api/v1/archives/search?q=UniqueSoftDeleteCandidate")
assert resp.status_code == 200
assert resp.json() == []
# ========================================================================
# Statistics endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify archive statistics can be retrieved."""
printer = await printer_factory()
await archive_factory(
printer.id,
status="completed",
print_time_seconds=3600,
filament_used_grams=50.0,
)
await archive_factory(
printer.id,
status="completed",
print_time_seconds=7200,
filament_used_grams=100.0,
)
response = await async_client.get("/api/v1/archives/stats")
assert response.status_code == 200
result = response.json()
# Check for actual stats fields
assert "total_prints" in result
assert "successful_prints" in result
class TestArchivesSlimAPI:
"""Integration tests for /api/v1/archives/slim endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_empty(self, async_client: AsyncClient):
"""Verify empty list when no archives exist."""
response = await async_client.get("/api/v1/archives/slim")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_returns_only_expected_fields(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify response contains only slim fields, not full archive data."""
printer = await printer_factory()
await archive_factory(
printer.id,
print_name="Slim Test",
status="completed",
filament_type="PLA",
filament_color="#FF0000",
filament_used_grams=50.0,
print_time_seconds=3600,
cost=1.50,
quantity=2,
)
response = await async_client.get("/api/v1/archives/slim")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
item = data[0]
# Expected fields present
assert item["printer_id"] == printer.id
assert item["print_name"] == "Slim Test"
assert item["status"] == "completed"
assert item["filament_type"] == "PLA"
assert item["filament_color"] == "#FF0000"
assert item["filament_used_grams"] == 50.0
assert item["print_time_seconds"] == 3600
assert item["cost"] == 1.50
assert item["quantity"] == 2
assert "created_at" in item
# Full archive fields must NOT be present
assert "id" not in item
assert "filename" not in item
assert "file_path" not in item
assert "file_size" not in item
assert "extra_data" not in item
assert "notes" not in item
assert "tags" not in item
assert "photos" not in item
assert "thumbnail_path" not in item
assert "content_hash" not in item
assert "duplicates" not in item
assert "duplicate_count" not in item
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_computes_actual_time(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify actual_time_seconds is computed from started_at/completed_at."""
from datetime import datetime, timezone
printer = await printer_factory()
started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # 2 hours = 7200s
await archive_factory(
printer.id,
status="completed",
started_at=started,
completed_at=completed,
)
response = await async_client.get("/api/v1/archives/slim")
assert response.status_code == 200
item = response.json()[0]
assert item["actual_time_seconds"] == 7200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_actual_time_null_for_failed(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify actual_time_seconds is null for non-completed prints."""
from datetime import datetime, timezone
printer = await printer_factory()
await archive_factory(
printer.id,
status="failed",
started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
)
response = await async_client.get("/api/v1/archives/slim")
assert response.status_code == 200
item = response.json()[0]
assert item["actual_time_seconds"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify date_from and date_to filters work."""
from datetime import datetime, timezone
printer = await printer_factory()
await archive_factory(
printer.id,
print_name="Old Print",
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
await archive_factory(
printer.id,
print_name="New Print",
created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
)
# Filter to only June 2024
response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["print_name"] == "New Print"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify limit and offset work."""
printer = await printer_factory()
for i in range(5):
await archive_factory(printer.id, print_name=f"Print {i}")
response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
assert response.status_code == 200
assert len(response.json()) == 2
class TestArchiveDataIntegrity:
"""Tests for archive data integrity."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_linked_to_printer(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify archive is properly linked to printer."""
printer = await printer_factory(name="My Printer")
archive = await archive_factory(printer.id)
response = await async_client.get(f"/api/v1/archives/{archive.id}")
assert response.status_code == 200
result = response.json()
assert result["printer_id"] == printer.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_stores_print_data(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify archive stores all print data correctly."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
print_name="Test Print",
filename="test.3mf",
status="completed",
filament_type="PLA",
filament_used_grams=75.5,
print_time_seconds=5400,
)
response = await async_client.get(f"/api/v1/archives/{archive.id}")
assert response.status_code == 200
result = response.json()
assert result["print_name"] == "Test Print"
assert result["filename"] == "test.3mf"
assert result["status"] == "completed"
assert result["filament_type"] == "PLA"
assert result["filament_used_grams"] == 75.5
assert result["print_time_seconds"] == 5400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_update_persists(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""CRITICAL: Verify archive updates persist."""
printer = await printer_factory()
archive = await archive_factory(printer.id, notes="Original notes")
# Update
await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
# Verify persistence
response = await async_client.get(f"/api/v1/archives/{archive.id}")
result = response.json()
assert result["notes"] == "Updated notes"
assert result["is_favorite"] is True
class TestArchiveF3DEndpoints:
"""Tests for F3D (Fusion 360 design file) attachment endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_response_includes_f3d_path(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify f3d_path is included in archive response."""
printer = await printer_factory()
archive = await archive_factory(printer.id, f3d_path="archives/test/design.f3d")
response = await async_client.get(f"/api/v1/archives/{archive.id}")
assert response.status_code == 200
result = response.json()
assert "f3d_path" in result
assert result["f3d_path"] == "archives/test/design.f3d"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_response_f3d_path_null_when_not_set(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify f3d_path is null when no F3D file attached."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
response = await async_client.get(f"/api/v1/archives/{archive.id}")
assert response.status_code == 200
result = response.json()
assert "f3d_path" in result
assert result["f3d_path"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):
"""Verify 404 when uploading F3D to non-existent archive."""
# Create a minimal file-like upload
files = {"file": ("design.f3d", b"fake f3d content", "application/octet-stream")}
response = await async_client.post("/api/v1/archives/9999/f3d", files=files)
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_download_f3d_not_found_when_no_file(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify 404 when downloading F3D from archive without F3D file."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
response = await async_client.get(f"/api/v1/archives/{archive.id}/f3d")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):
"""Verify 404 when downloading F3D from non-existent archive."""
response = await async_client.get("/api/v1/archives/9999/f3d")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):
"""Verify 404 when deleting F3D from non-existent archive."""
response = await async_client.delete("/api/v1/archives/9999/f3d")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_f3d_when_no_file(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify 404 when deleting F3D from archive without F3D file."""
printer = await printer_factory()
archive = await archive_factory(printer.id)
response = await async_client.delete(f"/api/v1/archives/{archive.id}/f3d")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_archives_includes_f3d_path(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify f3d_path is included in archive list responses."""
printer = await printer_factory()
await archive_factory(printer.id, print_name="With F3D", f3d_path="archives/test/design.f3d")
await archive_factory(printer.id, print_name="Without F3D")
response = await async_client.get("/api/v1/archives/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 2
with_f3d = next((a for a in data if a["print_name"] == "With F3D"), None)
without_f3d = next((a for a in data if a["print_name"] == "Without F3D"), None)
assert with_f3d is not None
assert with_f3d["f3d_path"] == "archives/test/design.f3d"
assert without_f3d is not None
assert without_f3d["f3d_path"] is None
# ========================================================================
# Multi-Plate 3MF endpoints (Issue #93)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_archive_plates_not_found(self, async_client: AsyncClient):
"""Verify 404 when fetching plates for non-existent archive."""
response = await async_client.get("/api/v1/archives/999999/plates")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):
"""Verify 404 when fetching plate thumbnail for non-existent archive."""
response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_filament_requirements_not_found(self, async_client: AsyncClient):
"""Verify filament-requirements returns 404 for non-existent archive."""
response = await async_client.get("/api/v1/archives/999999/filament-requirements")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):
"""Verify filament-requirements with plate_id returns 404 for non-existent archive."""
response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
assert response.status_code == 404
# ========================================================================
# Tag Management endpoints (Issue #183)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_tags_empty(self, async_client: AsyncClient):
"""Verify empty list when no tags exist."""
response = await async_client.get("/api/v1/archives/tags")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify tags are returned with counts."""
printer = await printer_factory()
await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
await archive_factory(printer.id, print_name="Archive 3", tags="test")
response = await async_client.get("/api/v1/archives/tags")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# Convert to dict for easier lookup
tags_dict = {t["name"]: t["count"] for t in data}
assert tags_dict.get("functional") == 2
assert tags_dict.get("test") == 2
assert tags_dict.get("calibration") == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_tags_sorted_by_count(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify tags are sorted by count descending, then by name."""
printer = await printer_factory()
await archive_factory(printer.id, tags="alpha")
await archive_factory(printer.id, tags="beta, alpha")
await archive_factory(printer.id, tags="gamma, beta, alpha")
response = await async_client.get("/api/v1/archives/tags")
assert response.status_code == 200
data = response.json()
# alpha=3, beta=2, gamma=1
assert data[0]["name"] == "alpha"
assert data[0]["count"] == 3
assert data[1]["name"] == "beta"
assert data[1]["count"] == 2
assert data[2]["name"] == "gamma"
assert data[2]["count"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify renaming a tag updates all archives."""
printer = await printer_factory()
a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
await archive_factory(printer.id, print_name="Archive 3", tags="different")
response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
assert response.status_code == 200
data = response.json()
assert data["affected"] == 2
# Verify the archives were updated
response = await async_client.get(f"/api/v1/archives/{a1.id}")
assert "new-tag" in response.json()["tags"]
assert "old-tag" not in response.json()["tags"]
response = await async_client.get(f"/api/v1/archives/{a2.id}")
assert response.json()["tags"] == "new-tag"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_tag_no_change(self, async_client: AsyncClient):
"""Verify renaming to same name returns 0 affected."""
response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
assert response.status_code == 200
assert response.json()["affected"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
"""Verify renaming to empty name returns error."""
response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify deleting a tag removes it from all archives."""
printer = await printer_factory()
a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
await archive_factory(printer.id, print_name="Archive 3", tags="different")
response = await async_client.delete("/api/v1/archives/tags/delete-me")
assert response.status_code == 200
data = response.json()
assert data["affected"] == 2
# Verify the archives were updated
response = await async_client.get(f"/api/v1/archives/{a1.id}")
assert response.json()["tags"] == "keep"
response = await async_client.get(f"/api/v1/archives/{a2.id}")
# Should be None or empty when last tag is removed
assert response.json()["tags"] is None or response.json()["tags"] == ""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_tag_not_found(self, async_client: AsyncClient):
"""Verify deleting non-existent tag returns 0 affected."""
response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
assert response.status_code == 200
assert response.json()["affected"] == 0