From ee4e845c8924520a4d7909e50ffda4b524b0498b Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 31 Jul 2026 15:50:40 +0200 Subject: [PATCH] Add user bug reporting with admin triage (open/read/resolved) Turns the /report-bug placeholder into a real form (POST /bug-reports, optionally attributed to the logged-in user) and adds a "Segnalazioni bug" section to /admin to view and triage them. A logged-in reporter can also check their own report's status via GET /bug-reports/mine, since anonymous submissions have no user to show a history to. Status is a three-state lifecycle (open -> read -> resolved) rather than a plain boolean, so an admin can acknowledge a report distinctly from actually fixing it. The schema went through two migrations because the first one (add bug_reports table) had already been applied against the running instance with a `resolved` boolean before the three-state design was decided, so a follow-up migration backfills it into `status` instead of rewriting already-applied history. Co-Authored-By: Claude Sonnet 5 --- app/api/routes/admin.py | 73 ++++++- app/api/routes/bug_reports.py | 79 ++++++++ app/db/models.py | 18 ++ app/main.py | 2 + app/static/admin.css | 8 + app/static/admin.html | 17 ++ app/static/admin.js | 49 ++++- app/static/report-bug.html | 104 +++++++++- app/static/style.css | 23 ++- ...4e_replace_bug_reports_resolved_with_a_.py | 41 ++++ .../ee8508d98d34_add_bug_reports_table.py | 41 ++++ tests/unit/test_bug_reports.py | 181 ++++++++++++++++++ 12 files changed, 628 insertions(+), 8 deletions(-) create mode 100644 app/api/routes/bug_reports.py create mode 100644 migrations/versions/be71fdac734e_replace_bug_reports_resolved_with_a_.py create mode 100644 migrations/versions/ee8508d98d34_add_bug_reports_table.py create mode 100644 tests/unit/test_bug_reports.py diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 6651485..81b1d39 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -10,7 +10,7 @@ from app.api.timeutil import isoformat_utc from app.audit.log import write_audit_log from app.auth.security import hash_password from app.config import settings -from app.db.models import AuditLog, PendingTransaction, Round, User +from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User from app.db.session import get_session from app.rounds.config import get_round_config from app.wallet.address import is_valid_plm_address @@ -346,3 +346,74 @@ async def list_pending_transactions( ) for p in entries ] + + +_BUG_REPORT_STATUSES = ("open", "read", "resolved") + + +class AdminBugReportResponse(BaseModel): + id: int + description: str + contact: str | None + user_id: int | None + username: str | None + status: str + created_at: str + + +def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse: + return AdminBugReportResponse( + id=report.id, + description=report.description, + contact=report.contact, + user_id=report.user_id, + username=username, + status=report.status, + created_at=isoformat_utc(report.created_at), + ) + + +@router.get( + "/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)] +) +async def list_bug_reports( + session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500) +) -> list[AdminBugReportResponse]: + reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all() + user_ids = {r.user_id for r in reports if r.user_id is not None} + usernames = {} + if user_ids: + users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all() + usernames = {u.id: u.username for u in users} + + return [ + _bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None) + for r in reports + ] + + +class BugReportStatusUpdate(BaseModel): + status: str = Field(pattern="^(open|read|resolved)$") + + +@router.post( + "/bug-reports/{report_id}/status", + response_model=AdminBugReportResponse, + dependencies=[Depends(require_admin)], +) +async def update_bug_report_status( + report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session) +) -> AdminBugReportResponse: + report = await session.get(BugReport, report_id) + if report is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found") + + report.status = body.status + await session.commit() + + username = None + if report.user_id is not None: + user = await session.get(User, report.user_id) + username = user.username if user is not None else None + + return _bug_report_response(report, username) diff --git a/app/api/routes/bug_reports.py b/app/api/routes/bug_reports.py new file mode 100644 index 0000000..6522cef --- /dev/null +++ b/app/api/routes/bug_reports.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Depends, status +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.timeutil import isoformat_utc +from app.auth.dependencies import get_current_user, get_optional_user +from app.db.models import BugReport, User +from app.db.session import get_session + +router = APIRouter(prefix="/bug-reports", tags=["bug-reports"]) + + +class BugReportCreate(BaseModel): + description: str = Field(min_length=1, max_length=5000) + contact: str | None = Field(default=None, max_length=256) + + @field_validator("description") + @classmethod + def _description_not_blank(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("description must not be blank") + return value + + @field_validator("contact") + @classmethod + def _contact_stripped(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + +class BugReportResponse(BaseModel): + id: int + + +@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED) +async def create_bug_report( + body: BugReportCreate, + user: User | None = Depends(get_optional_user), + session: AsyncSession = Depends(get_session), +) -> BugReportResponse: + report = BugReport( + description=body.description, + contact=body.contact, + user_id=user.id if user is not None else None, + ) + session.add(report) + await session.commit() + return BugReportResponse(id=report.id) + + +class MyBugReportResponse(BaseModel): + id: int + description: str + status: str + created_at: str + + +@router.get("/mine", response_model=list[MyBugReportResponse]) +async def list_my_bug_reports( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> list[MyBugReportResponse]: + """The one user-facing history view for bug reports (anonymous submissions have + no user to attribute this to, so this only ever covers ones filed while logged in).""" + reports = ( + await session.scalars( + select(BugReport).where(BugReport.user_id == user.id).order_by(BugReport.id.desc()) + ) + ).all() + return [ + MyBugReportResponse( + id=r.id, description=r.description, status=r.status, created_at=isoformat_utc(r.created_at) + ) + for r in reports + ] diff --git a/app/db/models.py b/app/db/models.py index f4e672f..911085f 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -188,6 +188,24 @@ class Withdrawal(Base): confirmed_at: Mapped[datetime | None] = mapped_column(default=None) +class BugReport(Base): + __tablename__ = "bug_reports" + + id: Mapped[int] = mapped_column(primary_key=True) + description: Mapped[str] = mapped_column(Text) + contact: Mapped[str | None] = mapped_column(String(256), default=None) + # Set when the reporter was logged in at submission time; the report page is + # reachable both logged-in and logged-out (like GET /rounds/current), so this + # stays nullable rather than requiring auth just to file a report. + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None) + # open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a + # distinct step from "resolved" so a reporter checking their own status (only + # possible when logged in — see GET /bug-reports/mine) can tell "an admin has + # seen this" apart from "this has actually been fixed". + status: Mapped[str] = mapped_column(String(16), default="open") + created_at: Mapped[datetime] = mapped_column(default=utcnow) + + class AuditLog(Base): __tablename__ = "audit_log" diff --git a/app/main.py b/app/main.py index 82c87bd..6166bc7 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmati import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler) from app.api.routes.admin import router as admin_router from app.api.routes.bets import router as bets_router +from app.api.routes.bug_reports import router as bug_reports_router from app.api.routes.qr import router as qr_router from app.api.routes.rounds import router as rounds_router from app.api.routes.users import router as users_router @@ -99,6 +100,7 @@ app.include_router(users_router) app.include_router(bets_router) app.include_router(withdrawals_router) app.include_router(admin_router) +app.include_router(bug_reports_router) app.include_router(qr_router) app.include_router(rounds_router) diff --git a/app/static/admin.css b/app/static/admin.css index 49e91d0..9261680 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -133,6 +133,7 @@ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; } th { color: var(--color-muted-foreground); font-weight: 500; } td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; } +td.payload-cell { max-width: 360px; white-space: pre-wrap; word-break: break-word; } .table-wrap { overflow-x: auto; } .badge { @@ -144,6 +145,13 @@ td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; m background: #FEF3C7; color: #92400E; border-color: #F59E0B; } +.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; } +.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); } +.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); } + +.bug-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.bug-actions button { width: auto; margin-top: 0; min-height: 30px; padding: 4px 10px; font-size: 0.78rem; } + button.reveal { width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem; background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive); diff --git a/app/static/admin.html b/app/static/admin.html index fd20a0a..729c486 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -30,6 +30,7 @@ Round Transazioni pendenti Audit log + Segnalazioni bug @@ -153,6 +154,22 @@ +
+

Segnalazioni bug

+

Segnalazioni inviate dagli utenti tramite la pagina "Segnala un bug".

+ +
+
+ + + + + +
IDDescrizioneContattoUtenteQuandoStato
+
+
+
+ diff --git a/app/static/admin.js b/app/static/admin.js index 4ebca8b..1307746 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -89,8 +89,8 @@ function stopChainStatusPolling() { chainStatusInterval = null; } -const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit']; -const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog }; +const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports']; +const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports }; let currentAdminView = 'parametri'; function switchView(name) { @@ -109,7 +109,7 @@ function showDashboard() { } async function loadDashboard() { - await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]); + await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]); } async function adminLogin() { @@ -346,6 +346,49 @@ async function loadAuditLog() { } } +const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Letta', resolved: 'Risolta' }; + +function bugReportBadge(status) { + return `${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}`; +} + +async function loadBugReports() { + try { + const reports = await callAdmin('GET', '/admin/bug-reports'); + const tbody = document.getElementById('bugreports-tbody'); + tbody.innerHTML = reports.map((r) => ` + + ${r.id} + ${escapeHtml(r.description)} + ${r.contact ? escapeHtml(r.contact) : '—'} + ${r.username ? escapeHtml(r.username) : '—'} + ${fmtDate(r.created_at)} + + ${bugReportBadge(r.status)} +
+ ${r.status === 'open' ? `` : ''} + ${r.status !== 'resolved' ? `` : ''} + ${r.status !== 'open' ? `` : ''} +
+ + + `).join('') || 'Nessuna segnalazione ricevuta.'; + } catch (e) { + toast('Errore nel caricamento segnalazioni: ' + e.message, 'error'); + } +} + +async function setBugReportStatus(reportId, newStatus, button) { + await withLoading(button, '…', async () => { + try { + await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus }); + await loadBugReports(); + } catch (e) { + toast('Errore: ' + e.message, 'error'); + } + }); +} + document.getElementById('admin-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') adminLogin(); }); diff --git a/app/static/report-bug.html b/app/static/report-bug.html index 5e7a9b1..3beb1b9 100644 --- a/app/static/report-bug.html +++ b/app/static/report-bug.html @@ -9,8 +9,110 @@

Segnala un bug

-

Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.

+

Hai trovato un problema? Descrivilo qui sotto: la segnalazione arriva direttamente al pannello di amministrazione.

+ +
+ + + + + + + +
+ + +

← Torna alla home

+ +
+ + diff --git a/app/static/style.css b/app/static/style.css index fde37e2..5275e53 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -74,16 +74,17 @@ h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; } label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; } label:first-child { margin-top: 0; } -input { +input, textarea { width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface); color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms; } -input:focus { +textarea { resize: vertical; } +input:focus, textarea:focus { outline: none; border-color: var(--color-ring); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent); } -input:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); } +input:disabled, textarea:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); } button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; @@ -253,6 +254,22 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground); .toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); } @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +.badge { + display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px; + border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border); +} +.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; } +.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); } +.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); } + +.my-report-row { + display: flex; flex-wrap: wrap; align-items: center; gap: 10px; + padding: 10px 0; border-bottom: 1px solid var(--color-border); +} +.my-report-row:last-child { border-bottom: none; } +.my-report-desc { flex: 1 1 200px; font-size: 0.9rem; } +.my-report-date { font-size: 0.8rem; color: var(--color-muted-foreground); white-space: nowrap; } + /* --- landing hero (shown only when logged out) --- */ body { position: relative; diff --git a/migrations/versions/be71fdac734e_replace_bug_reports_resolved_with_a_.py b/migrations/versions/be71fdac734e_replace_bug_reports_resolved_with_a_.py new file mode 100644 index 0000000..3b3f06b --- /dev/null +++ b/migrations/versions/be71fdac734e_replace_bug_reports_resolved_with_a_.py @@ -0,0 +1,41 @@ +"""replace bug_reports.resolved with a three-state status + +Revision ID: be71fdac734e +Revises: ee8508d98d34 +Create Date: 2026-07-31 15:33:51.780062 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'be71fdac734e' +down_revision: Union[str, Sequence[str], None] = 'ee8508d98d34' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema, preserving existing rows: resolved=True -> 'resolved', else 'open'. + + 'read' has no equivalent in the old boolean, so nothing backfills into it — + every previously-open report starts the new lifecycle at 'open', which is + correct (nobody had acknowledged it yet).""" + op.add_column('bug_reports', sa.Column('status', sa.String(length=16), nullable=True)) + op.execute("UPDATE bug_reports SET status = CASE WHEN resolved THEN 'resolved' ELSE 'open' END") + with op.batch_alter_table('bug_reports') as batch_op: + batch_op.alter_column('status', nullable=False) + batch_op.drop_column('resolved') + + +def downgrade() -> None: + """Downgrade schema. 'read' collapses back into resolved=False — the same loss + of information any boolean-from-enum downgrade has.""" + op.add_column('bug_reports', sa.Column('resolved', sa.BOOLEAN(), nullable=True)) + op.execute("UPDATE bug_reports SET resolved = (status = 'resolved')") + with op.batch_alter_table('bug_reports') as batch_op: + batch_op.alter_column('resolved', nullable=False) + batch_op.drop_column('status') diff --git a/migrations/versions/ee8508d98d34_add_bug_reports_table.py b/migrations/versions/ee8508d98d34_add_bug_reports_table.py new file mode 100644 index 0000000..7d1c7d9 --- /dev/null +++ b/migrations/versions/ee8508d98d34_add_bug_reports_table.py @@ -0,0 +1,41 @@ +"""add bug_reports table + +Revision ID: ee8508d98d34 +Revises: 87a0c640355c +Create Date: 2026-07-31 15:14:14.288552 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ee8508d98d34' +down_revision: Union[str, Sequence[str], None] = '87a0c640355c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('bug_reports', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('contact', sa.String(length=256), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('resolved', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('bug_reports') + # ### end Alembic commands ### diff --git a/tests/unit/test_bug_reports.py b/tests/unit/test_bug_reports.py new file mode 100644 index 0000000..88324ba --- /dev/null +++ b/tests/unit/test_bug_reports.py @@ -0,0 +1,181 @@ +import pytest +from cryptography.fernet import Fernet +from httpx import ASGITransport, AsyncClient + +from app.config import settings + + +@pytest.fixture +async def client(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db") + monkeypatch.setattr(settings, "admin_token", "test-admin-token") + monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret") + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + + import app.wallet.hd as hd + + hd._account_key = None + hd.generate_master_key() + + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app.db import base as db_base + + import app.db.models # noqa: F401 + + db_base.engine = create_async_engine(settings.database_url) + db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False) + + from app.db import session as db_session + + db_session.AsyncSessionLocal = db_base.AsyncSessionLocal + + async with db_base.engine.begin() as conn: + await conn.run_sync(db_base.Base.metadata.create_all) + + from fastapi import FastAPI + + from app.api.routes.admin import router as admin_router + from app.api.routes.bug_reports import router as bug_reports_router + from app.auth.routes import router as auth_router + from app.electrum.listener import ElectrumListener + + app = FastAPI() + app.include_router(auth_router) + app.include_router(bug_reports_router) + app.include_router(admin_router) + app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + await db_base.engine.dispose() + + +_ADMIN_HEADERS = {"X-Admin-Token": "test-admin-token"} + + +async def _register(client, username="alice", password="original-password"): + resp = await client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 201 + return resp.json()["access_token"] + + +async def test_anonymous_bug_report_has_no_user(client): + resp = await client.post("/bug-reports", json={"description": "the bet button does nothing"}) + assert resp.status_code == 201 + + resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS) + assert resp.status_code == 200 + reports = resp.json() + assert len(reports) == 1 + assert reports[0]["description"] == "the bet button does nothing" + assert reports[0]["user_id"] is None + assert reports[0]["username"] is None + assert reports[0]["status"] == "open" + + +async def test_logged_in_bug_report_is_attributed_to_the_user(client): + token = await _register(client) + + resp = await client.post( + "/bug-reports", + headers={"Authorization": f"Bearer {token}"}, + json={"description": "withdrawal amount looks wrong", "contact": "alice@example.com"}, + ) + assert resp.status_code == 201 + + resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS) + reports = resp.json() + assert reports[0]["username"] == "alice" + assert reports[0]["contact"] == "alice@example.com" + + +async def test_user_can_see_own_report_status(client): + token = await _register(client) + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.post("/bug-reports", headers=headers, json={"description": "some bug"}) + report_id = resp.json()["id"] + + resp = await client.get("/bug-reports/mine", headers=headers) + assert resp.status_code == 200 + reports = resp.json() + assert len(reports) == 1 + assert reports[0]["id"] == report_id + assert reports[0]["status"] == "open" + + await client.post( + f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"} + ) + resp = await client.get("/bug-reports/mine", headers=headers) + assert resp.json()[0]["status"] == "read" + + +async def test_bug_reports_mine_requires_auth(client): + resp = await client.get("/bug-reports/mine") + assert resp.status_code == 401 + + +async def test_bug_reports_mine_only_returns_own_reports(client): + alice_token = await _register(client, username="alice") + bob_token = await _register(client, username="bob", password="bob-password") + + await client.post( + "/bug-reports", headers={"Authorization": f"Bearer {alice_token}"}, json={"description": "alice's bug"} + ) + + resp = await client.get("/bug-reports/mine", headers={"Authorization": f"Bearer {bob_token}"}) + assert resp.json() == [] + + +async def test_blank_description_is_rejected(client): + resp = await client.post("/bug-reports", json={"description": " "}) + assert resp.status_code == 422 + + +async def test_admin_bug_reports_requires_token(client): + resp = await client.get("/admin/bug-reports") + assert resp.status_code == 403 + + +async def test_admin_can_move_through_open_read_resolved(client): + resp = await client.post("/bug-reports", json={"description": "some bug"}) + report_id = resp.json()["id"] + + resp = await client.post( + f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"} + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "read" + + resp = await client.post( + f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"} + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "resolved" + + resp = await client.post( + f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"} + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "open" + + +async def test_update_status_rejects_unknown_value(client): + resp = await client.post("/bug-reports", json={"description": "some bug"}) + report_id = resp.json()["id"] + + resp = await client.post( + f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "bogus"} + ) + assert resp.status_code == 422 + + +async def test_update_status_unknown_report_is_404(client): + resp = await client.post( + "/admin/bug-reports/999/status", headers=_ADMIN_HEADERS, json={"status": "read"} + ) + assert resp.status_code == 404