"""POST /api/v1/feed/admin/announcements/batch-status — batch publish/archive. Covers the platform batch-endpoint convention: * all rows succeed → ``succeeded`` counts, statuses actually flip in DB * mixed batch → already-in-target-status row is ``skipped``, missing id is ``failed``, the good row is ``ok`` — row isolation, no poisoning * >500 ids → 422 at the schema layer (nothing touched) * employee (non-admin) → 403 * exactly ONE aggregate audit row (``feed.announcements.batch_status``) per call, with total/succeeded/skipped/failed counters in detail """ from __future__ import annotations import uuid import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.domains.feed.models import FeedAnnouncement from app.identity.person_models import IdentityPerson async def _mk_announcement( db: AsyncSession, author: IdentityPerson, *, status: str = "draft", title: str = "batch test announcement", ) -> FeedAnnouncement: ann = FeedAnnouncement( id=str(uuid.uuid4()), title=title, body_markdown="batch status test body", category="general", color="default", is_pinned=False, status=status, author_person_id=str(author.id), ) db.add(ann) await db.commit() return ann async def _post_batch(client, token: str, ids: list[str], status: str): return await client.post( "/api/v1/feed/admin/announcements/batch-status", json={"ids": ids, "status": status}, headers={"Authorization": f"Bearer {token}"}, ) @pytest.mark.asyncio async def test_batch_publish_all_success(client, admin_user, db_session): admin, token = admin_user a = await _mk_announcement(db_session, admin, status="draft") b = await _mk_announcement(db_session, admin, status="draft") resp = await _post_batch(client, token, [a.id, b.id], "published") assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 2 assert data["skipped"] == 0 assert data["failed"] == 0 assert {r["status"] for r in data["results"]} == {"ok"} await db_session.refresh(a) await db_session.refresh(b) assert a.status == "published" assert b.status == "published" @pytest.mark.asyncio async def test_batch_archive_mixed_results(client, admin_user, db_session): """One already-archived (skipped), one missing (failed), one ok.""" admin, token = admin_user already = await _mk_announcement(db_session, admin, status="archived") good = await _mk_announcement(db_session, admin, status="published") missing_id = str(uuid.uuid4()) resp = await _post_batch( client, token, [already.id, missing_id, good.id], "archived", ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 1 assert data["skipped"] == 1 assert data["failed"] == 1 by_id = {r["id"]: r for r in data["results"]} assert by_id[already.id]["status"] == "skipped" assert by_id[missing_id]["status"] == "failed" assert by_id[missing_id]["error"] assert by_id[good.id]["status"] == "ok" await db_session.refresh(good) assert good.status == "archived" await db_session.refresh(already) assert already.status == "archived" @pytest.mark.asyncio async def test_batch_over_500_ids_rejected(client, admin_user): _, token = admin_user ids = [str(uuid.uuid4()) for _ in range(501)] resp = await _post_batch(client, token, ids, "published") assert resp.status_code == 422 @pytest.mark.asyncio async def test_employee_forbidden(client, employee_user, admin_user, db_session): admin, _ = admin_user ann = await _mk_announcement(db_session, admin, status="draft") _, emp_token = employee_user resp = await _post_batch(client, emp_token, [ann.id], "published") assert resp.status_code == 403 await db_session.refresh(ann) assert ann.status == "draft" @pytest.mark.asyncio async def test_batch_writes_single_aggregate_audit_row( client, admin_user, db_session, ): admin, token = admin_user a = await _mk_announcement(db_session, admin, status="draft") b = await _mk_announcement(db_session, admin, status="published") missing_id = str(uuid.uuid4()) resp = await _post_batch( client, token, [a.id, b.id, missing_id], "published", ) assert resp.status_code == 200, resp.text audits = ( await db_session.execute( select(AuditLog).where( AuditLog.action == "feed.announcements.batch_status", AuditLog.actor_user_id == admin.id, ) ) ).scalars().all() assert len(audits) == 1 # ONE aggregate row, not one per id detail = audits[0].detail assert detail["target_status"] == "published" assert detail["total"] == 3 assert detail["succeeded"] == 1 assert detail["skipped"] == 1 assert detail["failed"] == 1 assert detail["ids"] == [a.id, b.id, missing_id] @pytest.mark.asyncio async def test_batch_validation_error_returns_stable_code( client, admin_user, db_session, monkeypatch, caplog, ): from app.domains.feed.service import FeedService secret = "password=announcement-secret at /srv/private/feed.py:55" async def _boom(*args, **kwargs): raise ValueError(secret) monkeypatch.setattr(FeedService, "update_announcement", _boom) admin, token = admin_user announcement = await _mk_announcement(db_session, admin, status="draft") response = await _post_batch(client, token, [announcement.id], "published") assert response.status_code == 200 assert response.json()["data"]["results"][0] == { "id": announcement.id, "status": "failed", "error": "VALIDATION_ERROR", } assert secret not in response.text assert secret not in caplog.text