Add comprehensive automated testing infrastructure

Backend:
  - pytest configuration with async support and coverage
  - Unit tests for notification service (23 tests)
  - Unit tests for smart plug manager (12 tests)
  - Unit tests for archive service (16 tests)
  - Integration tests for API endpoints
  - Fix: notifications now send immediately (digest is summary only)

  Frontend:
  - Vitest configuration with jsdom and coverage
  - MSW for API mocking
  - Component tests for Toggle, Button, Card, ConfirmModal (77 tests)
  - Test utilities with custom render wrapper

  CI/CD:
  - GitHub Actions workflow for automated testing
  - Backend lint, unit tests, integration tests
  - Frontend lint, type-check, unit tests, build
This commit is contained in:
maziggy
2025-12-11 10:03:40 +01:00
parent c3026632da
commit ff53e62ef8
126 changed files with 101955 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
"""Integration tests for BamBuddy API endpoints."""
@@ -0,0 +1,189 @@
"""Integration tests for AMS History API endpoints."""
import pytest
from datetime import datetime, timedelta
from httpx import AsyncClient
class TestAMSHistoryAPI:
"""Integration tests for /api/v1/ams-history endpoints."""
@pytest.fixture
async def ams_history_factory(self, db_session, printer_factory):
"""Factory to create test AMS history records."""
async def _create_history(printer_id=None, ams_id=0, **kwargs):
from backend.app.models.ams_history import AMSSensorHistory
if printer_id is None:
printer = await printer_factory()
printer_id = printer.id
defaults = {
"printer_id": printer_id,
"ams_id": ams_id,
"humidity": 45.0,
"humidity_raw": 4500,
"temperature": 25.0,
"recorded_at": datetime.now(),
}
defaults.update(kwargs)
history = AMSSensorHistory(**defaults)
db_session.add(history)
await db_session.commit()
await db_session.refresh(history)
return history
return _create_history
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_empty(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify empty history returns empty data array."""
printer = await printer_factory()
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
assert data["printer_id"] == printer.id
assert data["ams_id"] == 0
assert data["data"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_with_data(
self, async_client: AsyncClient, ams_history_factory, db_session
):
"""Verify history returns recorded data."""
# Create history records
history = await ams_history_factory()
printer_id = history.printer_id
response = await async_client.get(f"/api/v1/ams-history/{printer_id}/0")
assert response.status_code == 200
data = response.json()
assert len(data["data"]) >= 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_with_stats(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify history includes statistics."""
printer = await printer_factory()
# Create multiple records with different values
await ams_history_factory(printer_id=printer.id, humidity=40.0, temperature=24.0)
await ams_history_factory(printer_id=printer.id, humidity=50.0, temperature=26.0)
await ams_history_factory(printer_id=printer.id, humidity=45.0, temperature=25.0)
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
# Check statistics
assert data["min_humidity"] == 40.0
assert data["max_humidity"] == 50.0
assert data["min_temperature"] == 24.0
assert data["max_temperature"] == 26.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_with_hours_filter(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify hours parameter filters data."""
printer = await printer_factory()
# Create a recent record
await ams_history_factory(
printer_id=printer.id,
recorded_at=datetime.now()
)
# Create an old record (outside default 24h)
await ams_history_factory(
printer_id=printer.id,
recorded_at=datetime.now() - timedelta(hours=48)
)
# Request only last 24 hours (default)
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
# Should only get the recent record
assert len(data["data"]) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_custom_hours(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify custom hours parameter works."""
printer = await printer_factory()
response = await async_client.get(
f"/api/v1/ams-history/{printer.id}/0",
params={"hours": 48}
)
assert response.status_code == 200
data = response.json()
assert data["printer_id"] == printer.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_different_ams_units(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify filtering by AMS unit ID."""
printer = await printer_factory()
await ams_history_factory(printer_id=printer.id, ams_id=0, humidity=40.0)
await ams_history_factory(printer_id=printer.id, ams_id=1, humidity=50.0)
# Get AMS unit 0
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data0 = response.json()
assert len(data0["data"]) == 1
assert data0["data"][0]["humidity"] == 40.0
# Get AMS unit 1
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/1")
assert response.status_code == 200
data1 = response.json()
assert len(data1["data"]) == 1
assert data1["data"][0]["humidity"] == 50.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_old_history(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify old history can be deleted."""
printer = await printer_factory()
# Create an old record
await ams_history_factory(
printer_id=printer.id,
recorded_at=datetime.now() - timedelta(days=60)
)
# Delete records older than 30 days
response = await async_client.delete(
f"/api/v1/ams-history/{printer.id}",
params={"days": 30}
)
assert response.status_code == 200
data = response.json()
assert data["deleted"] >= 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_old_history_no_records(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify delete with no old records returns 0."""
printer = await printer_factory()
response = await async_client.delete(
f"/api/v1/ams-history/{printer.id}",
params={"days": 30}
)
assert response.status_code == 200
data = response.json()
assert data["deleted"] == 0
@@ -0,0 +1,294 @@
"""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
# ========================================================================
# 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
# ========================================================================
# 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 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
@@ -0,0 +1,184 @@
"""Integration tests for External Links API endpoints."""
import pytest
from httpx import AsyncClient
class TestExternalLinksAPI:
"""Integration tests for /api/v1/external-links endpoints."""
@pytest.fixture
async def link_factory(self, db_session):
"""Factory to create test external links."""
_counter = [0]
async def _create_link(**kwargs):
from backend.app.models.external_link import ExternalLink
_counter[0] += 1
counter = _counter[0]
defaults = {
"name": f"Test Link {counter}",
"url": f"https://example.com/{counter}",
"icon": "Link",
"sort_order": counter,
}
defaults.update(kwargs)
link = ExternalLink(**defaults)
db_session.add(link)
await db_session.commit()
await db_session.refresh(link)
return link
return _create_link
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_external_links_empty(self, async_client: AsyncClient):
"""Verify empty list when no links exist."""
response = await async_client.get("/api/v1/external-links/")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_external_links_with_data(
self, async_client: AsyncClient, link_factory, db_session
):
"""Verify list returns existing links."""
await link_factory(name="My Link")
response = await async_client.get("/api/v1/external-links/")
assert response.status_code == 200
data = response.json()
assert any(link["name"] == "My Link" for link in data)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_external_link(self, async_client: AsyncClient):
"""Verify external link can be created."""
data = {
"name": "New Link",
"url": "https://new-link.example.com",
"icon": "ExternalLink",
}
response = await async_client.post("/api/v1/external-links/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "New Link"
assert result["url"] == "https://new-link.example.com"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_external_link(
self, async_client: AsyncClient, link_factory, db_session
):
"""Verify single link can be retrieved."""
link = await link_factory(name="Get Test Link")
response = await async_client.get(f"/api/v1/external-links/{link.id}")
assert response.status_code == 200
assert response.json()["name"] == "Get Test Link"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_external_link_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent link."""
response = await async_client.get("/api/v1/external-links/9999")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_external_link(
self, async_client: AsyncClient, link_factory, db_session
):
"""Verify link can be updated."""
link = await link_factory(name="Original")
response = await async_client.patch(
f"/api/v1/external-links/{link.id}",
json={"name": "Updated", "url": "https://updated.example.com"}
)
assert response.status_code == 200
result = response.json()
assert result["name"] == "Updated"
assert result["url"] == "https://updated.example.com"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_external_link(
self, async_client: AsyncClient, link_factory, db_session
):
"""Verify link can be deleted."""
link = await link_factory()
response = await async_client.delete(f"/api/v1/external-links/{link.id}")
assert response.status_code == 200
# Verify deleted
response = await async_client.get(f"/api/v1/external-links/{link.id}")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reorder_external_links(
self, async_client: AsyncClient, link_factory, db_session
):
"""Verify links can be reordered."""
link1 = await link_factory(name="Link 1")
link2 = await link_factory(name="Link 2")
link3 = await link_factory(name="Link 3")
# Reorder: 3, 1, 2
response = await async_client.put(
"/api/v1/external-links/reorder",
json={"ids": [link3.id, link1.id, link2.id]}
)
assert response.status_code == 200
data = response.json()
# First link should be link3
assert data[0]["id"] == link3.id
assert data[0]["sort_order"] == 0
class TestExternalLinksIconAPI:
"""Tests for external link icon upload/delete."""
@pytest.fixture
async def link_factory(self, db_session):
"""Factory to create test external links."""
async def _create_link(**kwargs):
from backend.app.models.external_link import ExternalLink
defaults = {
"name": "Icon Test Link",
"url": "https://example.com",
"icon": "Link",
"sort_order": 0,
}
defaults.update(kwargs)
link = ExternalLink(**defaults)
db_session.add(link)
await db_session.commit()
await db_session.refresh(link)
return link
return _create_link
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_icon_not_set(
self, async_client: AsyncClient, link_factory, db_session
):
"""Verify 404 when no custom icon is set."""
link = await link_factory()
response = await async_client.get(f"/api/v1/external-links/{link.id}/icon")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_icon_when_none(
self, async_client: AsyncClient, link_factory, db_session
):
"""Verify deleting non-existent icon succeeds silently."""
link = await link_factory()
response = await async_client.delete(f"/api/v1/external-links/{link.id}/icon")
assert response.status_code == 200
@@ -0,0 +1,117 @@
"""Integration tests for Filaments API endpoints."""
import pytest
from httpx import AsyncClient
class TestFilamentsAPI:
"""Integration tests for /api/v1/filaments/ endpoints."""
@pytest.fixture
async def filament_factory(self, db_session):
"""Factory to create test filaments."""
async def _create_filament(**kwargs):
from backend.app.models.filament import Filament
defaults = {
"name": "Test PLA",
"type": "PLA",
"color": "Red",
"color_hex": "#FF0000",
"brand": "Generic",
"cost_per_kg": 25.0,
}
defaults.update(kwargs)
filament = Filament(**defaults)
db_session.add(filament)
await db_session.commit()
await db_session.refresh(filament)
return filament
return _create_filament
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_filaments_empty(self, async_client: AsyncClient):
"""Verify empty list when no filaments exist."""
response = await async_client.get("/api/v1/filaments/")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_filaments_with_data(
self, async_client: AsyncClient, filament_factory, db_session
):
"""Verify list returns existing filaments."""
await filament_factory(name="Test Filament")
response = await async_client.get("/api/v1/filaments/")
assert response.status_code == 200
data = response.json()
assert any(f["name"] == "Test Filament" for f in data)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_filament(self, async_client: AsyncClient):
"""Verify filament can be created."""
data = {
"name": "New PETG",
"type": "PETG",
"color": "Blue",
"color_hex": "#0000FF",
"brand": "Bambu",
"cost_per_kg": 30.0,
}
response = await async_client.post("/api/v1/filaments/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "New PETG"
assert result["type"] == "PETG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_filament(
self, async_client: AsyncClient, filament_factory, db_session
):
"""Verify single filament can be retrieved."""
filament = await filament_factory(name="Get Test")
response = await async_client.get(f"/api/v1/filaments/{filament.id}")
assert response.status_code == 200
assert response.json()["name"] == "Get Test"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_filament_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent filament."""
response = await async_client.get("/api/v1/filaments/9999")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_filament(
self, async_client: AsyncClient, filament_factory, db_session
):
"""Verify filament can be updated."""
filament = await filament_factory(name="Original")
response = await async_client.patch(
f"/api/v1/filaments/{filament.id}",
json={"name": "Updated", "cost_per_kg": 35.0}
)
assert response.status_code == 200
result = response.json()
assert result["name"] == "Updated"
assert result["cost_per_kg"] == 35.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_filament(
self, async_client: AsyncClient, filament_factory, db_session
):
"""Verify filament can be deleted."""
filament = await filament_factory()
response = await async_client.delete(f"/api/v1/filaments/{filament.id}")
assert response.status_code == 200
# Verify deleted
response = await async_client.get(f"/api/v1/filaments/{filament.id}")
assert response.status_code == 404
@@ -0,0 +1,268 @@
"""Integration tests for Maintenance API endpoints."""
import pytest
from httpx import AsyncClient
class TestMaintenanceTypesAPI:
"""Integration tests for /api/v1/maintenance/types endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_maintenance_types(self, async_client: AsyncClient):
"""Verify maintenance types list returns data with defaults."""
response = await async_client.get("/api/v1/maintenance/types")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# Should have default system types
assert len(data) >= 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_includes_system_types(self, async_client: AsyncClient):
"""Verify default system types are created."""
response = await async_client.get("/api/v1/maintenance/types")
assert response.status_code == 200
data = response.json()
names = [t["name"] for t in data]
# Check for some default types
assert "Lubricate Linear Rails" in names or len(data) > 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_custom_maintenance_type(self, async_client: AsyncClient):
"""Verify custom maintenance type can be created."""
data = {
"name": "Custom Test Task",
"description": "Test description",
"default_interval_hours": 200.0,
"interval_type": "hours",
"icon": "Wrench",
}
response = await async_client.post("/api/v1/maintenance/types", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "Custom Test Task"
assert result["is_system"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_maintenance_type(self, async_client: AsyncClient):
"""Verify maintenance type can be updated."""
# First create a custom type
create_data = {
"name": "Update Test",
"description": "Original",
"default_interval_hours": 100.0,
}
create_response = await async_client.post(
"/api/v1/maintenance/types", json=create_data
)
assert create_response.status_code == 200
type_id = create_response.json()["id"]
# Update it
update_data = {"description": "Updated description"}
response = await async_client.patch(
f"/api/v1/maintenance/types/{type_id}", json=update_data
)
assert response.status_code == 200
assert response.json()["description"] == "Updated description"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_custom_maintenance_type(self, async_client: AsyncClient):
"""Verify custom maintenance type can be deleted."""
# Create a custom type
create_data = {
"name": "Delete Test",
"description": "To be deleted",
"default_interval_hours": 50.0,
}
create_response = await async_client.post(
"/api/v1/maintenance/types", json=create_data
)
type_id = create_response.json()["id"]
# Delete it
response = await async_client.delete(f"/api/v1/maintenance/types/{type_id}")
assert response.status_code == 200
class TestPrinterMaintenanceAPI:
"""Integration tests for /api/v1/maintenance/printers endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_printer_maintenance_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent printer."""
response = await async_client.get("/api/v1/maintenance/printers/9999")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_printer_maintenance(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify maintenance overview for a printer."""
printer = await printer_factory(name="Maintenance Test Printer")
response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}")
assert response.status_code == 200
data = response.json()
assert data["printer_id"] == printer.id
assert data["printer_name"] == "Maintenance Test Printer"
assert "maintenance_items" in data
assert "total_print_hours" in data
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_all_maintenance_overview(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify overview endpoint returns all printers."""
await printer_factory(name="Overview Printer 1")
await printer_factory(name="Overview Printer 2")
response = await async_client.get("/api/v1/maintenance/overview")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_maintenance_summary(self, async_client: AsyncClient):
"""Verify summary endpoint returns counts."""
response = await async_client.get("/api/v1/maintenance/summary")
assert response.status_code == 200
data = response.json()
assert "total_due" in data
assert "total_warning" in data
assert "printers_with_issues" in data
class TestMaintenanceItemsAPI:
"""Integration tests for /api/v1/maintenance/items endpoints."""
@pytest.fixture
async def maintenance_item(self, async_client: AsyncClient, printer_factory, db_session):
"""Create a maintenance item for testing."""
printer = await printer_factory(name="Item Test Printer")
# Get the printer's maintenance overview to create items
response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}")
assert response.status_code == 200
data = response.json()
# Return the first maintenance item
if data["maintenance_items"]:
return data["maintenance_items"][0]
return None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_maintenance_item(
self, async_client: AsyncClient, maintenance_item
):
"""Verify maintenance item can be updated."""
if not maintenance_item:
pytest.skip("No maintenance items available")
item_id = maintenance_item["id"]
response = await async_client.patch(
f"/api/v1/maintenance/items/{item_id}",
json={"custom_interval_hours": 150.0}
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_disable_maintenance_item(
self, async_client: AsyncClient, maintenance_item
):
"""Verify maintenance item can be disabled."""
if not maintenance_item:
pytest.skip("No maintenance items available")
item_id = maintenance_item["id"]
response = await async_client.patch(
f"/api/v1/maintenance/items/{item_id}",
json={"enabled": False}
)
assert response.status_code == 200
assert response.json()["enabled"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_perform_maintenance(
self, async_client: AsyncClient, maintenance_item
):
"""Verify maintenance can be marked as performed."""
if not maintenance_item:
pytest.skip("No maintenance items available")
item_id = maintenance_item["id"]
response = await async_client.post(
f"/api/v1/maintenance/items/{item_id}/perform",
json={"notes": "Test maintenance performed"}
)
assert response.status_code == 200
data = response.json()
assert data["last_performed_at"] is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_maintenance_history(
self, async_client: AsyncClient, maintenance_item
):
"""Verify maintenance history can be retrieved."""
if not maintenance_item:
pytest.skip("No maintenance items available")
item_id = maintenance_item["id"]
# First perform maintenance to create history
await async_client.post(
f"/api/v1/maintenance/items/{item_id}/perform",
json={"notes": "History test"}
)
response = await async_client.get(f"/api/v1/maintenance/items/{item_id}/history")
assert response.status_code == 200
history = response.json()
assert isinstance(history, list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_maintenance_item_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent maintenance item."""
response = await async_client.patch(
"/api/v1/maintenance/items/9999",
json={"enabled": False}
)
assert response.status_code == 404
class TestPrinterHoursAPI:
"""Integration tests for /api/v1/maintenance/printers/{id}/hours endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_set_printer_hours(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify printer hours can be set."""
printer = await printer_factory(name="Hours Test Printer")
response = await async_client.patch(
f"/api/v1/maintenance/printers/{printer.id}/hours",
params={"total_hours": 500.0}
)
assert response.status_code == 200
data = response.json()
assert data["total_hours"] == 500.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_set_printer_hours_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent printer."""
response = await async_client.patch(
"/api/v1/maintenance/printers/9999/hours",
params={"total_hours": 100.0}
)
assert response.status_code == 404
@@ -0,0 +1,445 @@
"""Integration tests for Notifications API endpoints.
Tests the full request/response cycle for /api/v1/notifications/ endpoints.
"""
import pytest
from httpx import AsyncClient
class TestNotificationsAPI:
"""Integration tests for /api/v1/notifications/ endpoints."""
# ========================================================================
# List endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_notification_providers_empty(
self, async_client: AsyncClient
):
"""Verify empty list is returned when no providers exist."""
response = await async_client.get("/api/v1/notifications/")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_notification_providers_with_data(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify list returns existing providers."""
provider = await notification_provider_factory(name="Test Provider")
response = await async_client.get("/api/v1/notifications/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
assert any(p["name"] == "Test Provider" for p in data)
# ========================================================================
# Create endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_callmebot_provider(self, async_client: AsyncClient):
"""Verify callmebot notification provider can be created."""
data = {
"name": "Test CallMeBot",
"provider_type": "callmebot",
"enabled": True,
"config": {"phone_number": "+1234567890", "api_key": "test-api-key"},
"on_print_start": True,
"on_print_complete": True,
"on_print_failed": True,
"on_print_stopped": False,
}
response = await async_client.post("/api/v1/notifications/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "Test CallMeBot"
assert result["provider_type"] == "callmebot"
assert result["on_print_start"] is True
assert result["on_print_stopped"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_ntfy_provider(self, async_client: AsyncClient):
"""Verify ntfy notification provider can be created."""
data = {
"name": "Test Ntfy",
"provider_type": "ntfy",
"enabled": True,
"config": {
"server": "https://ntfy.sh",
"topic": "test-topic",
},
"on_print_complete": True,
}
response = await async_client.post("/api/v1/notifications/", json=data)
assert response.status_code == 200
result = response.json()
assert result["provider_type"] == "ntfy"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_provider_with_printer(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify provider can be linked to specific printer."""
printer = await printer_factory(name="Test Printer")
data = {
"name": "Printer Ntfy",
"provider_type": "ntfy",
"config": {"server": "https://ntfy.sh", "topic": "test-topic"},
"printer_id": printer.id,
}
response = await async_client.post("/api/v1/notifications/", json=data)
assert response.status_code == 200
result = response.json()
assert result["printer_id"] == printer.id
# ========================================================================
# Get single endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_notification_provider(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify single provider can be retrieved."""
provider = await notification_provider_factory(name="Get Test Provider")
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
assert response.status_code == 200
result = response.json()
assert result["id"] == provider.id
assert result["name"] == "Get Test Provider"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_provider_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent provider."""
response = await async_client.get("/api/v1/notifications/9999")
assert response.status_code == 404
# ========================================================================
# Update endpoints (CRITICAL - toggle persistence)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_event_toggles(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""CRITICAL: Verify notification event toggles persist correctly."""
provider = await notification_provider_factory(
on_print_start=True,
on_print_complete=True,
on_print_stopped=False,
)
# Toggle on_print_stopped to True
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={"on_print_stopped": True}
)
assert response.status_code == 200
assert response.json()["on_print_stopped"] is True
# Verify change persisted
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
assert response.json()["on_print_stopped"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_ams_alarm_toggles(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""CRITICAL: Verify AMS alarm toggles persist correctly."""
provider = await notification_provider_factory(
on_ams_humidity_high=False,
on_ams_temperature_high=False,
)
# Enable AMS alarms
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={
"on_ams_humidity_high": True,
"on_ams_temperature_high": True,
}
)
assert response.status_code == 200
result = response.json()
assert result["on_ams_humidity_high"] is True
assert result["on_ams_temperature_high"] is True
# Verify persistence
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
result = response.json()
assert result["on_ams_humidity_high"] is True
assert result["on_ams_temperature_high"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_enable_disable_provider(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify provider can be enabled/disabled."""
provider = await notification_provider_factory(enabled=True)
# Disable
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={"enabled": False}
)
assert response.status_code == 200
assert response.json()["enabled"] is False
# Enable
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={"enabled": True}
)
assert response.status_code == 200
assert response.json()["enabled"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_quiet_hours(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify quiet hours can be configured."""
provider = await notification_provider_factory(quiet_hours_enabled=False)
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={
"quiet_hours_enabled": True,
"quiet_hours_start": "22:00",
"quiet_hours_end": "07:00",
}
)
assert response.status_code == 200
result = response.json()
assert result["quiet_hours_enabled"] is True
assert result["quiet_hours_start"] == "22:00"
assert result["quiet_hours_end"] == "07:00"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_daily_digest(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify daily digest can be configured."""
provider = await notification_provider_factory(daily_digest_enabled=False)
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={
"daily_digest_enabled": True,
"daily_digest_time": "09:00",
}
)
assert response.status_code == 200
result = response.json()
assert result["daily_digest_enabled"] is True
assert result["daily_digest_time"] == "09:00"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_multiple_event_toggles(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify multiple event toggles can be updated at once."""
provider = await notification_provider_factory(
on_print_start=True,
on_print_complete=True,
on_print_failed=True,
on_print_stopped=False,
on_printer_offline=False,
)
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={
"on_print_start": False,
"on_print_stopped": True,
"on_printer_offline": True,
}
)
assert response.status_code == 200
result = response.json()
assert result["on_print_start"] is False
assert result["on_print_stopped"] is True
assert result["on_printer_offline"] is True
# Unchanged fields should remain
assert result["on_print_complete"] is True
assert result["on_print_failed"] is True
# ========================================================================
# Test notification endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_test_notification(
self, async_client: AsyncClient, notification_provider_factory,
mock_httpx_client, db_session
):
"""Verify test notification can be sent."""
provider = await notification_provider_factory()
response = await async_client.post(
f"/api/v1/notifications/{provider.id}/test"
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_test_notification_disabled_provider(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify test notification works even for disabled provider."""
provider = await notification_provider_factory(enabled=False)
response = await async_client.post(
f"/api/v1/notifications/{provider.id}/test"
)
# Test should still work for disabled providers
assert response.status_code == 200
# ========================================================================
# Delete endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_notification_provider(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Verify notification provider can be deleted."""
provider = await notification_provider_factory()
provider_id = provider.id
response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
assert response.status_code == 200
# Verify deleted
response = await async_client.get(f"/api/v1/notifications/{provider_id}")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_nonexistent_provider(self, async_client: AsyncClient):
"""Verify deleting non-existent provider returns 404."""
response = await async_client.delete("/api/v1/notifications/9999")
assert response.status_code == 404
class TestNotificationTemplatesAPI:
"""Integration tests for /api/v1/notification-templates/ endpoints."""
@pytest.fixture
async def seeded_templates(self, db_session):
"""Seed notification templates for tests."""
from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
templates = []
for template_data in DEFAULT_TEMPLATES:
template = NotificationTemplate(**template_data)
db_session.add(template)
templates.append(template)
await db_session.commit()
for template in templates:
await db_session.refresh(template)
return templates
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_templates(self, async_client: AsyncClient, seeded_templates):
"""Verify default templates are seeded and can be listed."""
response = await async_client.get("/api/v1/notification-templates/")
assert response.status_code == 200
templates = response.json()
# Should have default templates seeded
assert len(templates) >= 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_template_by_id(self, async_client: AsyncClient, seeded_templates):
"""Verify template can be retrieved by ID."""
# Get first template ID from seeded data
template_id = seeded_templates[0].id
response = await async_client.get(
f"/api/v1/notification-templates/{template_id}"
)
assert response.status_code == 200
template = response.json()
assert template["id"] == template_id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_template(self, async_client: AsyncClient, seeded_templates):
"""Verify template can be updated."""
# Get first template
template_id = seeded_templates[0].id
# Update it (route uses PUT, not PATCH)
response = await async_client.put(
f"/api/v1/notification-templates/{template_id}",
json={
"title_template": "Custom Title: {printer}",
"body_template": "Custom body for {filename}",
}
)
assert response.status_code == 200
result = response.json()
assert result["title_template"] == "Custom Title: {printer}"
assert result["body_template"] == "Custom body for {filename}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_template_to_default(self, async_client: AsyncClient, seeded_templates):
"""Verify template can be reset to default."""
template_id = seeded_templates[0].id
response = await async_client.post(
f"/api/v1/notification-templates/{template_id}/reset"
)
assert response.status_code == 200
result = response.json()
assert result["is_default"] is True
@@ -0,0 +1,284 @@
"""Integration tests for Printers API endpoints.
Tests the full request/response cycle for /api/v1/printers/ endpoints.
"""
import pytest
from httpx import AsyncClient
from unittest.mock import patch, MagicMock, AsyncMock
class TestPrintersAPI:
"""Integration tests for /api/v1/printers/ endpoints."""
# ========================================================================
# List endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_printers_empty(self, async_client: AsyncClient):
"""Verify empty list is returned when no printers exist."""
response = await async_client.get("/api/v1/printers/")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_printers_with_data(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify list returns existing printers."""
printer = await printer_factory(name="Test Printer")
response = await async_client.get("/api/v1/printers/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
assert any(p["name"] == "Test Printer" for p in data)
# ========================================================================
# Create endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_printer(self, async_client: AsyncClient):
"""Verify printer can be created."""
data = {
"name": "New Printer",
"serial_number": "00M09A111111111",
"ip_address": "192.168.1.100",
"access_code": "12345678",
"is_active": True,
"model": "X1C",
}
response = await async_client.post("/api/v1/printers/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "New Printer"
assert result["serial_number"] == "00M09A111111111"
assert result["model"] == "X1C"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_printer_duplicate_serial(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify duplicate serial number is rejected."""
await printer_factory(serial_number="00M09A222222222")
data = {
"name": "Duplicate Printer",
"serial_number": "00M09A222222222",
"ip_address": "192.168.1.101",
"access_code": "12345678",
}
response = await async_client.post("/api/v1/printers/", json=data)
# Should fail due to duplicate serial
assert response.status_code in [400, 409, 422, 500]
# ========================================================================
# Get single endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_printer(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify single printer can be retrieved."""
printer = await printer_factory(name="Get Test Printer")
response = await async_client.get(f"/api/v1/printers/{printer.id}")
assert response.status_code == 200
result = response.json()
assert result["id"] == printer.id
assert result["name"] == "Get Test Printer"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_printer_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent printer."""
response = await async_client.get("/api/v1/printers/9999")
assert response.status_code == 404
# ========================================================================
# Update endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_printer_name(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify printer name can be updated."""
printer = await printer_factory(name="Original Name")
response = await async_client.patch(
f"/api/v1/printers/{printer.id}",
json={"name": "Updated Name"}
)
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_printer_active_status(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify printer active status can be updated."""
printer = await printer_factory(is_active=True)
response = await async_client.patch(
f"/api/v1/printers/{printer.id}",
json={"is_active": False}
)
assert response.status_code == 200
assert response.json()["is_active"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_printer_auto_archive(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify auto_archive setting can be updated."""
printer = await printer_factory(auto_archive=True)
response = await async_client.patch(
f"/api/v1/printers/{printer.id}",
json={"auto_archive": False}
)
assert response.status_code == 200
assert response.json()["auto_archive"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_nonexistent_printer(self, async_client: AsyncClient):
"""Verify updating non-existent printer returns 404."""
response = await async_client.patch(
"/api/v1/printers/9999",
json={"name": "New Name"}
)
assert response.status_code == 404
# ========================================================================
# Delete endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_printer(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify printer can be deleted."""
printer = await printer_factory()
printer_id = printer.id
response = await async_client.delete(f"/api/v1/printers/{printer_id}")
assert response.status_code == 200
# Verify deleted
response = await async_client.get(f"/api/v1/printers/{printer_id}")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_nonexistent_printer(self, async_client: AsyncClient):
"""Verify deleting non-existent printer returns 404."""
response = await async_client.delete("/api/v1/printers/9999")
assert response.status_code == 404
# ========================================================================
# Status endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_printer_status(
self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session
):
"""Verify printer status can be retrieved."""
printer = await printer_factory()
response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
assert response.status_code == 200
result = response.json()
assert "connected" in result
assert "state" in result
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_printer_status_not_found(self, async_client: AsyncClient):
"""Verify 404 for status of non-existent printer."""
response = await async_client.get("/api/v1/printers/9999/status")
assert response.status_code == 404
# ========================================================================
# Test connection endpoint
# ========================================================================
class TestPrinterDataIntegrity:
"""Tests for printer data integrity."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_printer_stores_all_fields(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify printer stores all fields correctly."""
printer = await printer_factory(
name="Full Test Printer",
serial_number="00M09A444444444",
ip_address="192.168.1.150",
model="P1S",
is_active=True,
auto_archive=False,
)
response = await async_client.get(f"/api/v1/printers/{printer.id}")
assert response.status_code == 200
result = response.json()
assert result["name"] == "Full Test Printer"
assert result["serial_number"] == "00M09A444444444"
assert result["ip_address"] == "192.168.1.150"
assert result["model"] == "P1S"
assert result["is_active"] is True
assert result["auto_archive"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_printer_update_persists(
self, async_client: AsyncClient, printer_factory, db_session
):
"""CRITICAL: Verify printer updates persist."""
printer = await printer_factory(name="Original", is_active=True)
# Update
await async_client.patch(
f"/api/v1/printers/{printer.id}",
json={"name": "Updated", "is_active": False}
)
# Verify persistence
response = await async_client.get(f"/api/v1/printers/{printer.id}")
result = response.json()
assert result["name"] == "Updated"
assert result["is_active"] is False
@@ -0,0 +1,160 @@
"""Integration tests for Projects API endpoints."""
import pytest
from httpx import AsyncClient
class TestProjectsAPI:
"""Integration tests for /api/v1/projects endpoints."""
@pytest.fixture
async def project_factory(self, db_session):
"""Factory to create test projects."""
_counter = [0]
async def _create_project(**kwargs):
from backend.app.models.project import Project
_counter[0] += 1
counter = _counter[0]
defaults = {
"name": f"Test Project {counter}",
"description": "Test project description",
"color": "#FF0000",
}
defaults.update(kwargs)
project = Project(**defaults)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
return project
return _create_project
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_projects_empty(self, async_client: AsyncClient):
"""Verify empty list when no projects exist."""
response = await async_client.get("/api/v1/projects/")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_projects_with_data(
self, async_client: AsyncClient, project_factory, db_session
):
"""Verify list returns existing projects."""
await project_factory(name="My Project")
response = await async_client.get("/api/v1/projects/")
assert response.status_code == 200
data = response.json()
assert any(p["name"] == "My Project" for p in data)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project(self, async_client: AsyncClient):
"""Verify project can be created."""
data = {
"name": "New Project",
"description": "A new project",
"color": "#00FF00",
}
response = await async_client.post("/api/v1/projects/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "New Project"
assert result["color"] == "#00FF00"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_project(
self, async_client: AsyncClient, project_factory, db_session
):
"""Verify single project can be retrieved."""
project = await project_factory(name="Get Test Project")
response = await async_client.get(f"/api/v1/projects/{project.id}")
assert response.status_code == 200
assert response.json()["name"] == "Get Test Project"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_project_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent project."""
response = await async_client.get("/api/v1/projects/9999")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_project(
self, async_client: AsyncClient, project_factory, db_session
):
"""Verify project can be updated."""
project = await project_factory(name="Original")
response = await async_client.patch(
f"/api/v1/projects/{project.id}",
json={"name": "Updated", "description": "Updated description"}
)
assert response.status_code == 200
result = response.json()
assert result["name"] == "Updated"
assert result["description"] == "Updated description"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_project(
self, async_client: AsyncClient, project_factory, db_session
):
"""Verify project can be deleted."""
project = await project_factory()
response = await async_client.delete(f"/api/v1/projects/{project.id}")
assert response.status_code == 200
data = response.json()
assert data["message"] == "Project deleted"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_project_not_found(self, async_client: AsyncClient):
"""Verify 404 for deleting non-existent project."""
response = await async_client.delete("/api/v1/projects/9999")
assert response.status_code == 404
class TestProjectArchivesAPI:
"""Tests for project-archive relationships."""
@pytest.fixture
async def project_factory(self, db_session):
"""Factory to create test projects."""
async def _create_project(**kwargs):
from backend.app.models.project import Project
defaults = {
"name": "Archive Test Project",
"description": "Test project",
"color": "#0000FF",
}
defaults.update(kwargs)
project = Project(**defaults)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
return project
return _create_project
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_project_with_archives(
self, async_client: AsyncClient, project_factory, db_session
):
"""Verify project can be retrieved with archive count."""
project = await project_factory()
response = await async_client.get(f"/api/v1/projects/{project.id}")
assert response.status_code == 200
# Project should have an archive count (may be 0)
data = response.json()
assert "name" in data
@@ -0,0 +1,215 @@
"""Integration tests for Settings API endpoints.
Tests the full request/response cycle for /api/v1/settings/ endpoints.
"""
import pytest
from httpx import AsyncClient
class TestSettingsAPI:
"""Integration tests for /api/v1/settings/ endpoints."""
# ========================================================================
# Get settings
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_settings(self, async_client: AsyncClient):
"""Verify settings can be retrieved."""
response = await async_client.get("/api/v1/settings/")
assert response.status_code == 200
result = response.json()
# Check for actual settings fields
assert "auto_archive" in result
assert "currency" in result
assert "date_format" in result
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_settings_has_defaults(self, async_client: AsyncClient):
"""Verify default settings values are returned."""
response = await async_client.get("/api/v1/settings/")
assert response.status_code == 200
result = response.json()
# Verify some default values
assert isinstance(result["auto_archive"], bool)
assert isinstance(result["currency"], str)
# ========================================================================
# Update settings
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_auto_archive(self, async_client: AsyncClient):
"""Verify auto_archive can be updated."""
# First get current value
response = await async_client.get("/api/v1/settings/")
original = response.json()["auto_archive"]
# Update to opposite value
new_value = not original
response = await async_client.put(
"/api/v1/settings/",
json={"auto_archive": new_value}
)
assert response.status_code == 200
assert response.json()["auto_archive"] == new_value
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_currency(self, async_client: AsyncClient):
"""Verify currency can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"currency": "EUR"}
)
assert response.status_code == 200
assert response.json()["currency"] == "EUR"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_date_format(self, async_client: AsyncClient):
"""Verify date format can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"date_format": "eu"}
)
assert response.status_code == 200
assert response.json()["date_format"] == "eu"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_time_format(self, async_client: AsyncClient):
"""Verify time format can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"time_format": "24h"}
)
assert response.status_code == 200
assert response.json()["time_format"] == "24h"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_filament_cost(self, async_client: AsyncClient):
"""Verify default filament cost can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"default_filament_cost": 30.0}
)
assert response.status_code == 200
assert response.json()["default_filament_cost"] == 30.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_energy_cost(self, async_client: AsyncClient):
"""Verify energy cost can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"energy_cost_per_kwh": 0.20}
)
assert response.status_code == 200
assert response.json()["energy_cost_per_kwh"] == 0.20
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_multiple_settings(self, async_client: AsyncClient):
"""Verify multiple settings can be updated at once."""
response = await async_client.put(
"/api/v1/settings/",
json={
"currency": "GBP",
"date_format": "iso",
"time_format": "12h",
"save_thumbnails": False,
}
)
assert response.status_code == 200
result = response.json()
assert result["currency"] == "GBP"
assert result["date_format"] == "iso"
assert result["time_format"] == "12h"
assert result["save_thumbnails"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_spoolman_settings(self, async_client: AsyncClient):
"""Verify Spoolman settings can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={
"spoolman_enabled": True,
"spoolman_url": "http://localhost:7912",
"spoolman_sync_mode": "manual",
}
)
assert response.status_code == 200
result = response.json()
assert result["spoolman_enabled"] is True
assert result["spoolman_url"] == "http://localhost:7912"
assert result["spoolman_sync_mode"] == "manual"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_ams_thresholds(self, async_client: AsyncClient):
"""Verify AMS threshold settings can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={
"ams_humidity_good": 35,
"ams_humidity_fair": 55,
"ams_temp_good": 25.0,
"ams_temp_fair": 32.0,
}
)
assert response.status_code == 200
result = response.json()
assert result["ams_humidity_good"] == 35
assert result["ams_humidity_fair"] == 55
assert result["ams_temp_good"] == 25.0
assert result["ams_temp_fair"] == 32.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_notification_language(self, async_client: AsyncClient):
"""Verify notification language can be updated."""
response = await async_client.put(
"/api/v1/settings/",
json={"notification_language": "de"}
)
assert response.status_code == 200
assert response.json()["notification_language"] == "de"
# ========================================================================
# Settings persistence tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_settings_persist_after_update(self, async_client: AsyncClient):
"""CRITICAL: Verify settings changes persist across requests."""
# Update settings
await async_client.put(
"/api/v1/settings/",
json={"currency": "JPY", "check_updates": False}
)
# Verify persistence in new request
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert result["currency"] == "JPY"
assert result["check_updates"] is False
@@ -0,0 +1,388 @@
"""Integration tests for Smart Plugs API endpoints.
Tests the full request/response cycle for /api/v1/smart-plugs/ endpoints.
"""
import pytest
from httpx import AsyncClient
class TestSmartPlugsAPI:
"""Integration tests for /api/v1/smart-plugs/ endpoints."""
# ========================================================================
# List endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_smart_plugs_empty(self, async_client: AsyncClient):
"""Verify empty list is returned when no plugs exist."""
response = await async_client.get("/api/v1/smart-plugs/")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_smart_plugs_with_data(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify list returns existing plugs."""
plug = await smart_plug_factory(name="Test Plug 1")
response = await async_client.get("/api/v1/smart-plugs/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
assert any(p["name"] == "Test Plug 1" for p in data)
# ========================================================================
# Create endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_smart_plug(self, async_client: AsyncClient):
"""Verify smart plug can be created."""
data = {
"name": "New Plug",
"ip_address": "192.168.1.100",
"enabled": True,
"auto_on": True,
"auto_off": False,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "New Plug"
assert result["ip_address"] == "192.168.1.100"
assert result["auto_off"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_smart_plug_with_printer(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify smart plug can be linked to a printer."""
printer = await printer_factory(name="Test Printer")
data = {
"name": "Printer Plug",
"ip_address": "192.168.1.101",
"printer_id": printer.id,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["printer_id"] == printer.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_plug_with_invalid_printer_id(
self, async_client: AsyncClient
):
"""Verify creating plug with non-existent printer fails."""
data = {
"name": "Test Plug",
"ip_address": "192.168.1.100",
"printer_id": 9999,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 400
assert "Printer not found" in response.json()["detail"]
# ========================================================================
# Get single endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_smart_plug(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify single plug can be retrieved."""
plug = await smart_plug_factory(name="Get Test Plug")
response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
assert response.status_code == 200
result = response.json()
assert result["id"] == plug.id
assert result["name"] == "Get Test Plug"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_smart_plug_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent plug."""
response = await async_client.get("/api/v1/smart-plugs/9999")
assert response.status_code == 404
# ========================================================================
# Update endpoints (CRITICAL - toggle persistence)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_auto_off_toggle(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""CRITICAL: Verify auto_off toggle persists correctly.
This tests the regression scenario where toggling auto_off
wasn't being saved properly.
"""
# Create plug with auto_off=True
plug = await smart_plug_factory(auto_off=True)
# Verify initial state
response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
assert response.status_code == 200
assert response.json()["auto_off"] is True
# Toggle auto_off to False
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={"auto_off": False}
)
assert response.status_code == 200
assert response.json()["auto_off"] is False
# Verify change persisted by fetching again
response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
assert response.json()["auto_off"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_auto_on_toggle(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify auto_on toggle persists correctly."""
plug = await smart_plug_factory(auto_on=True)
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={"auto_on": False}
)
assert response.status_code == 200
assert response.json()["auto_on"] is False
# Verify persistence
response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
assert response.json()["auto_on"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_enabled_toggle(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify enabled toggle persists correctly."""
plug = await smart_plug_factory(enabled=True)
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={"enabled": False}
)
assert response.status_code == 200
assert response.json()["enabled"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_off_delay_mode(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify off_delay_mode can be changed."""
plug = await smart_plug_factory(off_delay_mode="time")
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={"off_delay_mode": "temperature", "off_temp_threshold": 50}
)
assert response.status_code == 200
result = response.json()
assert result["off_delay_mode"] == "temperature"
assert result["off_temp_threshold"] == 50
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_schedule_settings(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify schedule settings can be updated."""
plug = await smart_plug_factory(schedule_enabled=False)
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={
"schedule_enabled": True,
"schedule_on_time": "08:00",
"schedule_off_time": "22:00",
}
)
assert response.status_code == 200
result = response.json()
assert result["schedule_enabled"] is True
assert result["schedule_on_time"] == "08:00"
assert result["schedule_off_time"] == "22:00"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_multiple_fields(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify multiple fields can be updated at once."""
plug = await smart_plug_factory(
name="Old Name",
auto_on=True,
auto_off=True,
)
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={
"name": "New Name",
"auto_on": False,
"auto_off": False,
}
)
assert response.status_code == 200
result = response.json()
assert result["name"] == "New Name"
assert result["auto_on"] is False
assert result["auto_off"] is False
# ========================================================================
# Control endpoints
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_control_smart_plug_on(
self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
):
"""Verify smart plug can be turned on."""
plug = await smart_plug_factory()
response = await async_client.post(
f"/api/v1/smart-plugs/{plug.id}/control",
json={"action": "on"}
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert result["action"] == "on"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_control_smart_plug_off(
self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
):
"""Verify smart plug can be turned off."""
plug = await smart_plug_factory()
response = await async_client.post(
f"/api/v1/smart-plugs/{plug.id}/control",
json={"action": "off"}
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert result["action"] == "off"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_control_smart_plug_toggle(
self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
):
"""Verify smart plug can be toggled."""
plug = await smart_plug_factory()
response = await async_client.post(
f"/api/v1/smart-plugs/{plug.id}/control",
json={"action": "toggle"}
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert result["action"] == "toggle"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_control_invalid_action(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify invalid action returns error."""
plug = await smart_plug_factory()
response = await async_client.post(
f"/api/v1/smart-plugs/{plug.id}/control",
json={"action": "invalid"}
)
# FastAPI returns 422 for pydantic validation errors
assert response.status_code == 422
# ========================================================================
# Status endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_smart_plug_status(
self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
):
"""Verify smart plug status can be retrieved."""
plug = await smart_plug_factory()
response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}/status")
assert response.status_code == 200
result = response.json()
assert result["state"] == "ON"
assert result["reachable"] is True
# ========================================================================
# Delete endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_smart_plug(
self, async_client: AsyncClient, smart_plug_factory, db_session
):
"""Verify smart plug can be deleted."""
plug = await smart_plug_factory()
plug_id = plug.id
response = await async_client.delete(f"/api/v1/smart-plugs/{plug_id}")
assert response.status_code == 200
# Verify deleted
response = await async_client.get(f"/api/v1/smart-plugs/{plug_id}")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_nonexistent_plug(self, async_client: AsyncClient):
"""Verify deleting non-existent plug returns 404."""
response = await async_client.delete("/api/v1/smart-plugs/9999")
assert response.status_code == 404