"""Comprehensive tests for Announcement CRUD, permissions, and edge cases.""" import pytest from httpx import AsyncClient from tests.conftest import auth_header BASE = "/api/v1/announcements" # ─── CREATE (POST /announcements) ────────────────────────────────────── PAYLOAD = { "title": "系统维护通知", "content": "将于本周六凌晨进行系统维护,届时服务将暂停。", } @pytest.mark.asyncio async def test_create_announcement(client: AsyncClient, admin_token: str): resp = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) assert resp.status_code == 201 data = resp.json() assert data["title"] == PAYLOAD["title"] assert data["content"] == PAYLOAD["content"] assert data["is_published"] is False assert data["published_at"] is None assert "id" in data assert "created_at" in data assert "updated_at" in data @pytest.mark.asyncio async def test_create_announcement_empty_title(client: AsyncClient, admin_token: str): resp = await client.post( BASE, json={"title": "", "content": "内容"}, headers=auth_header(admin_token) ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_create_announcement_title_too_long(client: AsyncClient, admin_token: str): resp = await client.post( BASE, json={"title": "x" * 201, "content": "内容"}, headers=auth_header(admin_token) ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_create_announcement_empty_content(client: AsyncClient, admin_token: str): resp = await client.post( BASE, json={"title": "标题", "content": ""}, headers=auth_header(admin_token) ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_create_announcement_missing_fields(client: AsyncClient, admin_token: str): resp = await client.post(BASE, json={}, headers=auth_header(admin_token)) assert resp.status_code == 422 # ─── LIST (GET /announcements) ───────────────────────────────────────── @pytest.mark.asyncio async def test_list_announcements_empty(client: AsyncClient, admin_token: str): resp = await client.get(BASE, headers=auth_header(admin_token)) assert resp.status_code == 200 assert resp.json() == [] @pytest.mark.asyncio async def test_list_announcements_returns_all(client: AsyncClient, admin_token: str): for i in range(3): await client.post( BASE, json={"title": f"公告{i}", "content": f"内容{i}"}, headers=auth_header(admin_token), ) resp = await client.get(BASE, headers=auth_header(admin_token)) assert resp.status_code == 200 items = resp.json() assert len(items) == 3 # ordered by id desc assert items[0]["title"] == "公告2" assert items[2]["title"] == "公告0" @pytest.mark.asyncio async def test_list_announcements_requires_auth(client: AsyncClient): resp = await client.get(BASE) assert resp.status_code == 401 # ─── UPDATE (PUT /announcements/{id}) ────────────────────────────────── @pytest.mark.asyncio async def test_update_announcement_title(client: AsyncClient, admin_token: str): create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) aid = create.json()["id"] resp = await client.put( f"{BASE}/{aid}", json={"title": "更新后的标题"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 assert resp.json()["title"] == "更新后的标题" # content unchanged assert resp.json()["content"] == PAYLOAD["content"] @pytest.mark.asyncio async def test_update_announcement_content(client: AsyncClient, admin_token: str): create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) aid = create.json()["id"] resp = await client.put( f"{BASE}/{aid}", json={"content": "新内容"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 assert resp.json()["content"] == "新内容" @pytest.mark.asyncio async def test_update_announcement_publish(client: AsyncClient, admin_token: str): create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) aid = create.json()["id"] assert create.json()["is_published"] is False resp = await client.put( f"{BASE}/{aid}", json={"is_published": True}, headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["is_published"] is True # published_at should be set on first publish assert data["published_at"] is not None @pytest.mark.asyncio async def test_update_announcement_publish_idempotent(client: AsyncClient, admin_token: str): """Publishing an already-published announcement should not change published_at.""" create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) aid = create.json()["id"] first = await client.put( f"{BASE}/{aid}", json={"is_published": True}, headers=auth_header(admin_token), ) first_published_at = first.json()["published_at"] # Update title while staying published second = await client.put( f"{BASE}/{aid}", json={"title": "改标题不改发布时间"}, headers=auth_header(admin_token), ) assert second.json()["published_at"] == first_published_at @pytest.mark.asyncio async def test_update_announcement_unpublish(client: AsyncClient, admin_token: str): create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) aid = create.json()["id"] # publish first await client.put( f"{BASE}/{aid}", json={"is_published": True}, headers=auth_header(admin_token), ) # then unpublish resp = await client.put( f"{BASE}/{aid}", json={"is_published": False}, headers=auth_header(admin_token), ) assert resp.status_code == 200 assert resp.json()["is_published"] is False @pytest.mark.asyncio async def test_update_nonexistent_announcement(client: AsyncClient, admin_token: str): resp = await client.put( f"{BASE}/99999", json={"title": "不存在"}, headers=auth_header(admin_token), ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_update_announcement_empty_title(client: AsyncClient, admin_token: str): create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) aid = create.json()["id"] resp = await client.put( f"{BASE}/{aid}", json={"title": ""}, headers=auth_header(admin_token), ) assert resp.status_code == 422 # ─── DELETE (DELETE /announcements/{id}) ─────────────────────────────── @pytest.mark.asyncio async def test_delete_announcement(client: AsyncClient, admin_token: str): create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) aid = create.json()["id"] resp = await client.delete(f"{BASE}/{aid}", headers=auth_header(admin_token)) assert resp.status_code == 200 assert "message" in resp.json() # verify it's gone from list items = (await client.get(BASE, headers=auth_header(admin_token))).json() assert all(a["id"] != aid for a in items) @pytest.mark.asyncio async def test_delete_nonexistent_announcement(client: AsyncClient, admin_token: str): resp = await client.delete(f"{BASE}/99999", headers=auth_header(admin_token)) assert resp.status_code == 404 # ─── PERMISSION CHECKS ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_create_requires_admin(client: AsyncClient, dba_token: str): resp = await client.post(BASE, json=PAYLOAD, headers=auth_header(dba_token)) assert resp.status_code == 403 @pytest.mark.asyncio async def test_update_requires_admin(client: AsyncClient, dba_token: str): resp = await client.put( f"{BASE}/1", json={"title": "x"}, headers=auth_header(dba_token) ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_delete_requires_admin(client: AsyncClient, developer_token: str): resp = await client.delete(f"{BASE}/1", headers=auth_header(developer_token)) assert resp.status_code == 403 @pytest.mark.asyncio async def test_list_allows_any_authenticated_user( client: AsyncClient, developer_token: str ): """All logged-in users can list announcements (not admin-only).""" resp = await client.get(BASE, headers=auth_header(developer_token)) assert resp.status_code == 200 @pytest.mark.asyncio async def test_guest_can_list_announcements(client: AsyncClient, guest_token: str): resp = await client.get(BASE, headers=auth_header(guest_token)) assert resp.status_code == 200 # ─── EDGE CASES ───────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_no_auth_returns_401(client: AsyncClient): for method, url in [ ("get", BASE), ("post", BASE), ("put", f"{BASE}/1"), ("delete", f"{BASE}/1"), ]: resp = await getattr(client, method)(url) assert resp.status_code == 401, f"{method.upper()} {url} should be 401, got {resp.status_code}" @pytest.mark.asyncio async def test_create_and_full_lifecycle(client: AsyncClient, admin_token: str): """Create → list → update → publish → list → delete → 404.""" # create create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) assert create.status_code == 201 aid = create.json()["id"] # list contains it items = (await client.get(BASE, headers=auth_header(admin_token))).json() assert any(a["id"] == aid for a in items) # update title upd = await client.put( f"{BASE}/{aid}", json={"title": "新标题"}, headers=auth_header(admin_token) ) assert upd.json()["title"] == "新标题" # publish pub = await client.put( f"{BASE}/{aid}", json={"is_published": True}, headers=auth_header(admin_token) ) assert pub.json()["is_published"] is True # delete await client.delete(f"{BASE}/{aid}", headers=auth_header(admin_token)) # update non-existent → 404 resp = await client.put( f"{BASE}/{aid}", json={"title": "x"}, headers=auth_header(admin_token) ) assert resp.status_code == 404