"""Integration tests for the post-print outcome confirmation (#1898). Covers the verdict PATCH (incl. the #1444-style mirror to the latest PrintLogEntry and token retirement), the unauthenticated capability-token endpoint, response defaults, and the verdict-aware statistics. """ import re import pytest from httpx import AsyncClient from sqlalchemy import select from backend.app.models.print_log import PrintLogEntry class TestOutcomeVerdictPatch: @pytest.mark.asyncio @pytest.mark.integration async def test_defaults_present_on_response(self, async_client: AsyncClient, archive_factory, printer_factory): """Archives created without any verdict expose the new fields with their defaults — no verdict, no pending confirmation.""" printer = await printer_factory() archive = await archive_factory(printer.id) response = await async_client.get(f"/api/v1/archives/{archive.id}") assert response.status_code == 200 body = response.json() assert body["user_verdict"] is None assert body["confirm_requested"] is False @pytest.mark.asyncio @pytest.mark.integration async def test_patch_verdict_mirrors_to_latest_log_entry( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): """Setting the verdict via PATCH lands on the archive AND the latest PrintLogEntry (verdict-aware statistics read the log), and retires a pending confirmation token.""" printer = await printer_factory() archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-mirror") response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "reject"}) assert response.status_code == 200 assert response.json()["user_verdict"] == "reject" entry = await db_session.scalar( select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()) ) assert entry is not None assert entry.user_verdict == "reject" await db_session.refresh(archive) assert archive.user_verdict == "reject" # Retired by stamping, not by dropping the value: the link stays # resolvable so a later tap can be told it is already answered. assert archive.confirm_token == "test-token-mirror" assert archive.confirm_token_used_at is not None assert archive.user_verdict_source == "api" @pytest.mark.asyncio @pytest.mark.integration async def test_patch_rejects_unknown_verdict(self, async_client: AsyncClient, archive_factory, printer_factory): printer = await printer_factory() archive = await archive_factory(printer.id) response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "meh"}) assert response.status_code == 422 class TestConfirmTokenEndpoint: @pytest.mark.asyncio @pytest.mark.integration async def test_token_records_verdict_and_retires_token( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): """The one-tap link from a push notification records the verdict without auth, mirrors it to the log entry, and single-uses the token.""" printer = await printer_factory() archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-good") response = await async_client.post("/api/v1/archives/confirm/test-token-good/good") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] await db_session.refresh(archive) assert archive.user_verdict == "good" assert archive.user_verdict_source == "link" assert archive.confirm_token == "test-token-good" assert archive.confirm_token_used_at is not None entry = await db_session.scalar( select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()) ) assert entry is not None assert entry.user_verdict == "good" # Second use of the same token: spent, and said so rather than 404. response = await async_client.get("/api/v1/archives/confirm/test-token-good/reject") assert response.status_code == 200 assert "Already answered" in response.text await db_session.refresh(archive) assert archive.user_verdict == "good" @pytest.mark.asyncio @pytest.mark.integration async def test_spent_link_reports_the_recorded_verdict_without_changing_it( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): """The live-farm case (#1898): the plate-clear default answered the prompt, then the Telegram button was tapped. The link must name the verdict on file, say how it got there, and leave it alone.""" from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good printer = await printer_factory() archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="plate-cleared-token") assert await resolve_pending_confirmation_as_good(db_session, printer.id) == archive.id await db_session.commit() response = await async_client.get("/api/v1/archives/confirm/plate-cleared-token/reject") assert response.status_code == 200 body = response.text assert "Already answered" in body assert "Good part" in body assert "plate was cleared" in body assert f"/archives?confirm={archive.id}" in body await db_session.refresh(archive) assert archive.user_verdict == "good" assert archive.user_verdict_source == "plate_clear" @pytest.mark.asyncio @pytest.mark.integration async def test_spent_link_after_the_verdict_was_cleared_again( self, async_client: AsyncClient, archive_factory, printer_factory ): """Clearing the verdict in the app does not un-spend the link: the capability was used, so the page explains rather than re-opening it.""" printer = await printer_factory() archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="cleared-again-token") assert (await async_client.post("/api/v1/archives/confirm/cleared-again-token/good")).status_code == 200 assert ( await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None}) ).status_code == 200 response = await async_client.get("/api/v1/archives/confirm/cleared-again-token/good") assert response.status_code == 200 assert "Already answered" in response.text assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["user_verdict"] is None @pytest.mark.asyncio @pytest.mark.integration async def test_unknown_token_and_garbage_verdict(self, async_client: AsyncClient): assert (await async_client.get("/api/v1/archives/confirm/no-such-token/good")).status_code == 404 assert (await async_client.get("/api/v1/archives/confirm/whatever/maybe")).status_code == 400 assert (await async_client.post("/api/v1/archives/confirm/no-such-token/good")).status_code == 404 assert (await async_client.post("/api/v1/archives/confirm/whatever/maybe")).status_code == 400 @pytest.mark.asyncio @pytest.mark.integration async def test_a_get_records_nothing_whoever_sends_it( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): """The blocker. Telegram and Slack GET the URLs in a message to build a preview card, mail gateways detonate them before delivery, proxies and browsers prefetch. While GET was the route that recorded, any of those settled the outcome before the operator read the question — always towards 'good', because good_url came first — and spent the token, so the real tap landed on "already answered". A scrap part counted as a success for good, in the statistics this branch adds. The heuristic below decides how the page behaves, not whether the verdict is written: nothing a GET can say records anything. """ printer = await printer_factory() archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="unfurler-token") for agent in ( "TelegramBot (like TwitterBot)", "Slackbot-LinkExpanding 1.0", "Mimecast-Link-Protect", # The one that used to be allowed straight through to the write. "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", ): response = await async_client.get( "/api/v1/archives/confirm/unfurler-token/good", headers={"user-agent": agent} ) assert response.status_code == 200, agent assert "Confirm this outcome" in response.text, agent assert "
archive.confirm_token_used_at page = await async_client.get(f"/api/v1/archives/confirm/{archive.confirm_token}/good") assert page.status_code == 200 # SQLite hands back naive datetimes; they are already UTC, which is how # the route formats them too. def _as_shown(value): if value.tzinfo is not None: value = value.astimezone(timezone.utc) return value.strftime("%Y-%m-%d %H:%M UTC") shown = _as_shown(archive.user_verdict_at) stale = _as_shown(archive.confirm_token_used_at) assert shown in page.text assert stale not in page.text # And the link still changed nothing. await db_session.refresh(archive) assert archive.user_verdict == "reject" async def test_a_re_sent_prompt_carries_a_live_token(self, archive_factory, printer_factory, db_session): """A spent token must never be re-used for a new prompt. Since a verdict keeps the token value on the row, "has a token" stopped meaning "answerable": without minting a fresh one, every button in the new message would land on the already-answered page. """ from backend.app.main import dispatch_outcome_confirmation printer = await printer_factory() archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="spent-token") from backend.app.services.print_confirmation import retire_confirm_token retire_confirm_token(archive) await db_session.commit() assert archive.confirm_token_used_at is not None await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None) await db_session.refresh(archive) assert archive.confirm_token != "spent-token" assert archive.confirm_token_used_at is None async def test_clearing_the_verdict_drops_the_source( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): printer = await printer_factory() archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="drop-source-token") await async_client.patch( f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"} ) await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None}) await db_session.refresh(archive) assert archive.user_verdict is None assert archive.user_verdict_source is None # ...but the capability stays spent. assert archive.confirm_token_used_at is not None