"""Integration tests for Notifications API endpoints. Tests the full request/response cycle for /api/v1/notifications/ endpoints. """ import pytest from httpx import AsyncClient from sqlalchemy import text from backend.tests._fixtures.notification_toggles import EVENT_TOGGLE_COLUMNS, TOGGLE_TARGET 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) @pytest.mark.asyncio @pytest.mark.integration async def test_a_row_with_null_event_flags_is_still_listable( self, async_client: AsyncClient, notification_provider_factory, db_session ): """A legacy row whose flag columns were never backfilled must not 500 the list. Every on_* column is nullable with no server default, so a row created before a flag existed keeps NULL there until a migration backfills it -- and #1184's ALTER ... DEFAULT false silently did not, on any install where create_all() had already added the column. Declaring those flags on the response schema in #2827 turned those NULLs into a hard failure: pydantic rejects None for a bool, so every provider row failed at once and the list came back empty to the UI. Written against the two flags that actually broke, but the whole set is checked -- the next flag added to the schema has the same exposure. """ provider = await notification_provider_factory(name="Legacy Provider") flags = ["on_stock_reorder_alert", "on_stock_break_alert"] # nosec B608 - the only interpolated fragments are built from `flags`, # the literal list directly above. A column name cannot be a bind # parameter, which is why it is written into the string at all; the id, # which is the one caller-supplied value here, is bound. null_assignments = ", ".join(f"{f} = NULL" for f in flags) await db_session.execute( text(f"UPDATE notification_providers SET {null_assignments} WHERE id = :id"), # nosec B608 {"id": provider.id}, ) await db_session.commit() columns = ", ".join(flags) stored = await db_session.execute( text(f"SELECT {columns} FROM notification_providers WHERE id = :id"), # nosec B608 {"id": provider.id}, ) assert all(value is None for value in stored.one()), "row under test must actually hold NULLs" response = await async_client.get("/api/v1/notifications/") assert response.status_code == 200 listed = next(p for p in response.json() if p["name"] == "Legacy Provider") # Off, not the field default: the sender selects on `.is_(True)`, so a # NULL flag never sent anything, and repairing the read must not switch # a notification on. assert all(listed[flag] is False for flag in flags) # The single-provider route reads through the same schema. single = await async_client.get(f"/api/v1/notifications/{provider.id}") assert single.status_code == 200 assert all(single.json()[flag] is False for flag in flags) # ======================================================================== # 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 @pytest.mark.asyncio @pytest.mark.integration async def test_create_provider_with_first_layer_complete(self, async_client: AsyncClient): """Verify first layer complete toggle persists on create.""" data = { "name": "First Layer Test", "provider_type": "ntfy", "config": {"server": "https://ntfy.sh", "topic": "test"}, "on_first_layer_complete": True, } response = await async_client.post("/api/v1/notifications/", json=data) assert response.status_code == 200 result = response.json() assert result["on_first_layer_complete"] is True @pytest.mark.asyncio @pytest.mark.integration async def test_update_first_layer_complete_toggle( self, async_client: AsyncClient, notification_provider_factory, db_session ): """CRITICAL: Verify first layer complete toggle persists correctly.""" provider = await notification_provider_factory(on_first_layer_complete=False) response = await async_client.patch( f"/api/v1/notifications/{provider.id}", json={"on_first_layer_complete": True}, ) assert response.status_code == 200 assert response.json()["on_first_layer_complete"] is True # Verify persistence response = await async_client.get(f"/api/v1/notifications/{provider.id}") assert response.json()["on_first_layer_complete"] is True @pytest.mark.asyncio @pytest.mark.integration async def test_first_layer_complete_independent_from_other_toggles( self, async_client: AsyncClient, notification_provider_factory, db_session ): """Verify first layer complete is independent from bed cooled and print complete.""" provider = await notification_provider_factory( on_print_complete=True, on_bed_cooled=False, on_first_layer_complete=True, ) response = await async_client.get(f"/api/v1/notifications/{provider.id}") result = response.json() assert result["on_print_complete"] is True assert result["on_bed_cooled"] is False assert result["on_first_layer_complete"] is True @pytest.mark.asyncio @pytest.mark.integration async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient): """Verify missing spool assignment toggle persists on create.""" data = { "name": "Missing Spool Assignment Test", "provider_type": "ntfy", "config": {"server": "https://ntfy.sh", "topic": "test"}, "on_print_missing_spool_assignment": True, } response = await async_client.post("/api/v1/notifications/", json=data) assert response.status_code == 200 result = response.json() assert result["on_print_missing_spool_assignment"] is True @pytest.mark.asyncio @pytest.mark.integration async def test_update_missing_spool_assignment_toggle( self, async_client: AsyncClient, notification_provider_factory, db_session ): """CRITICAL: Verify missing spool assignment toggle persists correctly.""" provider = await notification_provider_factory(on_print_missing_spool_assignment=False) response = await async_client.patch( f"/api/v1/notifications/{provider.id}", json={"on_print_missing_spool_assignment": True}, ) assert response.status_code == 200 assert response.json()["on_print_missing_spool_assignment"] is True response = await async_client.get(f"/api/v1/notifications/{provider.id}") assert response.json()["on_print_missing_spool_assignment"] is True @pytest.mark.asyncio @pytest.mark.integration async def test_update_billing_charge_failed_toggle( self, async_client: AsyncClient, notification_provider_factory, db_session ): """Billing alerts can be enabled independently for each provider.""" provider = await notification_provider_factory(on_billing_charge_failed=True) response = await async_client.patch( f"/api/v1/notifications/{provider.id}", json={"on_billing_charge_failed": False}, ) assert response.status_code == 200 assert response.json()["on_billing_charge_failed"] is False response = await async_client.get(f"/api/v1/notifications/{provider.id}") assert response.json()["on_billing_charge_failed"] is False # Every per-event toggle, across the hand-maintained field maps. # # These have to be exercised through the route, not the ORM: both # directions of notifications.py are hand-maintained field-by-field maps, # and a column missing from either one is invisible to any test that # builds NotificationProvider objects directly. The failure mode is # silent — NotificationProviderResponse inherits the field from # NotificationProviderBase, so FastAPI serialises the schema default # (False) instead of raising on the missing key, and the UI reads a # toggle that is on in the database as off. # # The Home Assistant pair (#1148, #2824) was the first to be caught this # way. The stock pair was caught by the same reasoning: its columns, its # templates, its sending code and its whole UI shipped, but the schema # never carried the fields, so Pydantic dropped them from every payload and # the toggles could not be turned on at all. @pytest.mark.asyncio @pytest.mark.integration @pytest.mark.parametrize("field", EVENT_TOGGLE_COLUMNS) async def test_create_persists_and_returns_the_toggle(self, async_client: AsyncClient, field: str): # Driven to whatever the column does not default to. Nine of these # default to True on the model and on the response schema, so sending # True and asserting True is answered by the default alone -- a field # dropped by the create constructor still reads back True, and the # mutation that would prove the constructor load-bearing survives. target = TOGGLE_TARGET[field] response = await async_client.post( "/api/v1/notifications/", json={ "name": "Sensor Alert Test", "provider_type": "ntfy", "config": {"server": "https://ntfy.sh", "topic": "test"}, field: target, }, ) assert response.status_code == 200 assert response.json()[field] is target # Re-read it: a value dropped by the create constructor but echoed # from the request body would still pass the assertion above. provider_id = response.json()["id"] response = await async_client.get(f"/api/v1/notifications/{provider_id}") assert response.json()[field] is target @pytest.mark.asyncio @pytest.mark.integration @pytest.mark.parametrize("field", EVENT_TOGGLE_COLUMNS) async def test_patch_is_reflected_by_every_read_route( self, async_client: AsyncClient, notification_provider_factory, field: str ): """PATCH already persisted (generic setattr loop) — the reads were the broken half. Seeded at the target's opposite and driven to the target, so the value asserted is never the one the column would have supplied on its own. With ``True`` on both sides the nine True-default columns could not see the half this test exists for: deleting a column from the read map left them passing. """ target = TOGGLE_TARGET[field] provider = await notification_provider_factory(**{field: not target}) response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={field: target}) assert response.status_code == 200 assert response.json()[field] is target response = await async_client.get(f"/api/v1/notifications/{provider.id}") assert response.json()[field] is target response = await async_client.get("/api/v1/notifications/") listed = next(p for p in response.json() if p["id"] == provider.id) assert listed[field] is target 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 DEFAULT_TEMPLATES, NotificationTemplate 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 class TestHomeAssistantNotificationProvider: """Integration tests for Home Assistant notification provider.""" @pytest.mark.asyncio @pytest.mark.integration async def test_create_homeassistant_provider(self, async_client: AsyncClient): """Verify homeassistant notification provider can be created with empty config.""" data = { "name": "HA Notifications", "provider_type": "homeassistant", "enabled": True, "config": {}, "on_print_complete": True, "on_print_failed": True, } response = await async_client.post("/api/v1/notifications/", json=data) assert response.status_code == 200 result = response.json() assert result["name"] == "HA Notifications" assert result["provider_type"] == "homeassistant" assert result["on_print_complete"] is True assert result["on_print_failed"] is True @pytest.mark.asyncio @pytest.mark.integration async def test_update_homeassistant_provider( self, async_client: AsyncClient, notification_provider_factory, db_session ): """Verify homeassistant provider can be updated.""" provider = await notification_provider_factory( name="HA Test", provider_type="homeassistant", config="{}", ) response = await async_client.patch( f"/api/v1/notifications/{provider.id}", json={"on_print_start": True, "on_printer_offline": True}, ) assert response.status_code == 200 result = response.json() assert result["on_print_start"] is True assert result["on_printer_offline"] is True @pytest.mark.asyncio @pytest.mark.integration async def test_test_homeassistant_config_without_ha_settings(self, async_client: AsyncClient): """Verify test-config returns error when HA is not configured.""" response = await async_client.post( "/api/v1/notifications/test-config", json={"provider_type": "homeassistant", "config": {}}, ) assert response.status_code == 200 result = response.json() assert result["success"] is False assert "not configured" in result["message"].lower() or "Home Assistant" in result["message"]