From 9f9c7042dcb8bf4d54d81de0a81e4ff74ffd2888 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 17:05:34 +0200 Subject: [PATCH] fix(api): convert destructive GETs to POST (CSRF), fix batch routes and N+1 - CSRF: reset/all, enable/disable, log/clear, program start/stop/restart/shutdown, rss refresh are now POST; frontend api calls updated to match - Batch delete/disable: type list[int], guard empty input, aggregate response, register /many before /{id} - auto_tag_torrents: load bangumi once and match in memory (was N+1 per torrent) - Type bangumi_id path params as int; correct 406->404/400; drop false SSE response_model; remove dead imports/handlers Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- backend/src/module/api/__init__.py | 2 +- backend/src/module/api/bangumi.py | 90 ++++++++++++------- backend/src/module/api/downloader.py | 69 +++++++------- backend/src/module/api/log.py | 6 +- backend/src/module/api/program.py | 8 +- backend/src/module/api/rss.py | 15 ++-- backend/src/module/api/search.py | 7 +- backend/src/module/database/bangumi.py | 27 ++++++ backend/src/test/test_api_bangumi.py | 12 +-- backend/src/test/test_api_bangumi_extended.py | 14 ++- backend/src/test/test_api_downloader.py | 6 +- backend/src/test/test_api_log.py | 19 ++-- backend/src/test/test_api_program.py | 42 ++++----- backend/src/test/test_api_rss.py | 18 ++-- backend/src/test/test_api_search.py | 5 +- webui/src/api/auth.ts | 2 +- webui/src/api/bangumi.ts | 84 +++++------------ webui/src/api/log.ts | 2 +- webui/src/api/program.ts | 8 +- webui/src/api/rss.ts | 4 +- 20 files changed, 226 insertions(+), 214 deletions(-) diff --git a/backend/src/module/api/__init__.py b/backend/src/module/api/__init__.py index 8bd10e75..cd68605a 100644 --- a/backend/src/module/api/__init__.py +++ b/backend/src/module/api/__init__.py @@ -5,12 +5,12 @@ from .bangumi import router as bangumi_router from .config import router as config_router from .downloader import router as downloader_router from .log import router as log_router +from .notification import router as notification_router from .passkey import router as passkey_router from .program import router as program_router from .rss import router as rss_router from .search import router as search_router from .setup import router as setup_router -from .notification import router as notification_router __all__ = "v1" diff --git a/backend/src/module/api/bangumi.py b/backend/src/module/api/bangumi.py index 10aee93e..514908ba 100644 --- a/backend/src/module/api/bangumi.py +++ b/backend/src/module/api/bangumi.py @@ -7,13 +7,13 @@ from pydantic import BaseModel from module.conf import settings from module.database import Database, get_db from module.manager import TorrentManager -from module.models import APIResponse, Bangumi, BangumiUpdate +from module.models import APIResponse, Bangumi, BangumiUpdate, ResponseModel from module.parser.analyser.offset_detector import ( OffsetSuggestion as DetectorSuggestion, ) from module.parser.analyser.offset_detector import detect_offset_mismatch from module.parser.analyser.tmdb_parser import tmdb_parser -from module.security.api import UNAUTHORIZED, get_current_user +from module.security.api import get_current_user from .response import u_response @@ -85,7 +85,7 @@ async def get_all_data(db: Database = Depends(get_db)): response_model=Bangumi, dependencies=[Depends(get_current_user)], ) -async def get_data(bangumi_id: str, db: Database = Depends(get_db)): +async def get_data(bangumi_id: int, db: Database = Depends(get_db)): manager = TorrentManager(db) resp = await manager.search_one(bangumi_id) return resp @@ -106,66 +106,96 @@ async def update_rule( return u_response(resp) +def _empty_id_list_response() -> JSONResponse: + return u_response( + ResponseModel( + status_code=400, + status=False, + msg_en="No bangumi id provided.", + msg_zh="未提供番剧 id。", + ) + ) + + +def _aggregate_response( + action_en: str, action_zh: str, results: list[ResponseModel] +) -> JSONResponse: + succeeded = sum(1 for r in results if r.status) + all_ok = succeeded == len(results) + return u_response( + ResponseModel( + status_code=200 if all_ok else 500, + status=all_ok, + msg_en=f"{action_en} {succeeded}/{len(results)} rules.", + msg_zh=f"已{action_zh} {succeeded}/{len(results)} 条规则。", + ) + ) + + +# Registered before /delete/{bangumi_id} so "many" is never captured as an id. +@router.post( + path="/delete/many", + response_model=APIResponse, + dependencies=[Depends(get_current_user)], +) +async def delete_many_rule( + bangumi_id: list[int], file: bool = False, db: Database = Depends(get_db) +): + if not bangumi_id: + return _empty_id_list_response() + manager = TorrentManager(db) + results = [await manager.delete_rule(i, file) for i in bangumi_id] + return _aggregate_response("Deleted", "删除", results) + + @router.delete( path="/delete/{bangumi_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)], ) async def delete_rule( - bangumi_id: str, file: bool = False, db: Database = Depends(get_db) + bangumi_id: int, file: bool = False, db: Database = Depends(get_db) ): manager = TorrentManager(db) resp = await manager.delete_rule(bangumi_id, file) return u_response(resp) -@router.delete( - path="/delete/many/", +# Registered before /disable/{bangumi_id} so "many" is never captured as an id. +@router.post( + path="/disable/many", response_model=APIResponse, dependencies=[Depends(get_current_user)], ) -async def delete_many_rule( - bangumi_id: list, file: bool = False, db: Database = Depends(get_db) +async def disable_many_rule( + bangumi_id: list[int], file: bool = False, db: Database = Depends(get_db) ): + if not bangumi_id: + return _empty_id_list_response() manager = TorrentManager(db) - for i in bangumi_id: - resp = await manager.delete_rule(i, file) - return u_response(resp) + results = [await manager.disable_rule(i, file) for i in bangumi_id] + return _aggregate_response("Disabled", "禁用", results) -@router.delete( +@router.post( path="/disable/{bangumi_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)], ) async def disable_rule( - bangumi_id: str, file: bool = False, db: Database = Depends(get_db) + bangumi_id: int, file: bool = False, db: Database = Depends(get_db) ): manager = TorrentManager(db) resp = await manager.disable_rule(bangumi_id, file) return u_response(resp) -@router.delete( - path="/disable/many/", - response_model=APIResponse, - dependencies=[Depends(get_current_user)], -) -async def disable_many_rule( - bangumi_id: list, file: bool = False, db: Database = Depends(get_db) -): - manager = TorrentManager(db) - for i in bangumi_id: - resp = await manager.disable_rule(i, file) - return u_response(resp) - - -@router.get( +@router.post( path="/enable/{bangumi_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)], ) -async def enable_rule(bangumi_id: str, db: Database = Depends(get_db)): +async def enable_rule(bangumi_id: int, db: Database = Depends(get_db)): manager = TorrentManager(db) resp = await manager.enable_rule(bangumi_id) return u_response(resp) @@ -204,7 +234,7 @@ async def refresh_calendar(db: Database = Depends(get_db)): return u_response(resp) -@router.get( +@router.post( "/reset/all", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) async def reset_all(db: Database = Depends(get_db)): diff --git a/backend/src/module/api/downloader.py b/backend/src/module/api/downloader.py index c5fe1403..fc122e07 100644 --- a/backend/src/module/api/downloader.py +++ b/backend/src/module/api/downloader.py @@ -4,6 +4,11 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel from module.database import Database +from module.database.bangumi import ( + build_save_path_index, + match_bangumi_in_list, + normalize_save_path, +) from module.downloader import DownloadClient from module.security.api import get_current_user @@ -101,45 +106,45 @@ async def auto_tag_torrents(): tagged_count = 0 unmatched = [] + # Load the bangumi list once and match in memory instead of running up to + # two DB queries (plus save_path fallback variations) per torrent. + async with Database() as db: + bangumi_list = await db.bangumi.search_all() + save_path_index = build_save_path_index(bangumi_list) + async with DownloadClient() as client: # Get all Bangumi torrents torrents = await client.get_torrent_info(category="Bangumi", status_filter=None) - async with Database() as db: - for torrent in torrents: - torrent_hash = torrent["hash"] - torrent_name = torrent["name"] - save_path = torrent["save_path"] - tags = torrent.get("tags", "") + for torrent in torrents: + torrent_hash = torrent["hash"] + torrent_name = torrent["name"] + save_path = torrent["save_path"] + tags = torrent.get("tags", "") - # Skip if already has ab: tag - if "ab:" in tags: - continue + # Skip if already has ab: tag + if "ab:" in tags: + continue - # Try to match bangumi - bangumi = None + # First try by torrent name, then fall back to save_path + bangumi = match_bangumi_in_list(torrent_name, bangumi_list) + if not bangumi: + bangumi = save_path_index.get(normalize_save_path(save_path)) - # First try by torrent name - bangumi = await db.bangumi.match_torrent(torrent_name) - - # Then try by save_path - if not bangumi: - bangumi = await db.bangumi.match_by_save_path(save_path) - - if bangumi and not bangumi.deleted: - tag = f"ab:{bangumi.id}" - await client.add_tag(torrent_hash, tag) - tagged_count += 1 - logger.info( - f"[AutoTag] Tagged '{torrent_name[:50]}...' with {tag} " - f"(matched: {bangumi.official_title})" - ) - else: - unmatched.append({ - "hash": torrent_hash, - "name": torrent_name, - "save_path": save_path, - }) + if bangumi and not bangumi.deleted: + tag = f"ab:{bangumi.id}" + await client.add_tag(torrent_hash, tag) + tagged_count += 1 + logger.info( + f"[AutoTag] Tagged '{torrent_name[:50]}...' with {tag} " + f"(matched: {bangumi.official_title})" + ) + else: + unmatched.append({ + "hash": torrent_hash, + "name": torrent_name, + "save_path": save_path, + }) return { "status": True, diff --git a/backend/src/module/api/log.py b/backend/src/module/api/log.py index 7020800e..f857d6e4 100644 --- a/backend/src/module/api/log.py +++ b/backend/src/module/api/log.py @@ -3,7 +3,7 @@ from fastapi.responses import JSONResponse from module.conf import LOG_PATH from module.models import APIResponse -from module.security.api import UNAUTHORIZED, get_current_user +from module.security.api import get_current_user router = APIRouter(prefix="/log", tags=["log"]) @@ -32,7 +32,7 @@ async def get_log(): return Response("Log file not found", status_code=404) -@router.get( +@router.post( "/clear", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) async def clear_log(): @@ -44,6 +44,6 @@ async def clear_log(): ) else: return JSONResponse( - status_code=406, + status_code=404, content={"msg_en": "Log file not found.", "msg_zh": "日志文件未找到。"}, ) diff --git a/backend/src/module/api/program.py b/backend/src/module/api/program.py index 1d174f33..82a091c3 100644 --- a/backend/src/module/api/program.py +++ b/backend/src/module/api/program.py @@ -21,7 +21,7 @@ router = APIRouter(tags=["program"]) # lifespan context manager; the AppContext is injected here per-request. -@router.get( +@router.post( "/restart", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) async def restart(ctx: AppContext = Depends(get_context)): @@ -40,7 +40,7 @@ async def restart(ctx: AppContext = Depends(get_context)): ) -@router.get( +@router.post( "/start", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) async def start(ctx: AppContext = Depends(get_context)): @@ -59,7 +59,7 @@ async def start(ctx: AppContext = Depends(get_context)): ) -@router.get( +@router.post( "/stop", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) async def stop(ctx: AppContext = Depends(get_context)): @@ -83,7 +83,7 @@ async def program_status(ctx: AppContext = Depends(get_context)): } -@router.get( +@router.post( "/shutdown", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) async def shutdown_program(ctx: AppContext = Depends(get_context)): diff --git a/backend/src/module/api/rss.py b/backend/src/module/api/rss.py index 74430547..5a660005 100644 --- a/backend/src/module/api/rss.py +++ b/backend/src/module/api/rss.py @@ -6,7 +6,7 @@ from module.downloader import DownloadClient from module.manager import SeasonCollector from module.models import APIResponse, Bangumi, RSSItem, RSSUpdate, Torrent from module.rss import RSSAnalyser, RSSEngine -from module.security.api import UNAUTHORIZED, get_current_user +from module.security.api import get_current_user from .response import u_response @@ -56,7 +56,7 @@ async def delete_rss(rss_id: int, db: Database = Depends(get_db)): ) else: return JSONResponse( - status_code=406, + status_code=400, content={"msg_en": "Delete RSS failed.", "msg_zh": "删除 RSS 失败。"}, ) @@ -91,7 +91,7 @@ async def disable_rss(rss_id: int, db: Database = Depends(get_db)): ) else: return JSONResponse( - status_code=406, + status_code=404, content={"msg_en": "Disable RSS failed.", "msg_zh": "禁用 RSS 失败。"}, ) @@ -115,11 +115,8 @@ async def disable_many_rss(rss_ids: list[int], db: Database = Depends(get_db)): async def update_rss( rss_id: int, data: RSSUpdate, - current_user=Depends(get_current_user), db: Database = Depends(get_db), ): - if not current_user: - raise UNAUTHORIZED if await db.rss.update(rss_id, data): return JSONResponse( status_code=200, @@ -127,12 +124,12 @@ async def update_rss( ) else: return JSONResponse( - status_code=406, + status_code=404, content={"msg_en": "Update RSS failed.", "msg_zh": "更新 RSS 失败。"}, ) -@router.get( +@router.post( path="/refresh/all", response_model=APIResponse, dependencies=[Depends(get_current_user)], @@ -150,7 +147,7 @@ async def refresh_all(db: Database = Depends(get_db)): ) -@router.get( +@router.post( path="/refresh/{rss_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)], diff --git a/backend/src/module/api/search.py b/backend/src/module/api/search.py index e1b5996a..f9fa720d 100644 --- a/backend/src/module/api/search.py +++ b/backend/src/module/api/search.py @@ -2,16 +2,13 @@ from fastapi import APIRouter, Depends, Query from sse_starlette.sse import EventSourceResponse from module.conf.search_provider import get_provider, save_provider -from module.models import Bangumi from module.searcher import SEARCH_CONFIG, SearchTorrent -from module.security.api import UNAUTHORIZED, get_current_user +from module.security.api import get_current_user router = APIRouter(prefix="/search", tags=["search"]) -@router.get( - "/bangumi", response_model=list[Bangumi], dependencies=[Depends(get_current_user)] -) +@router.get("/bangumi", dependencies=[Depends(get_current_user)]) async def search_torrents(site: str = "mikan", keywords: str = Query(None)): """ Server Send Event for per Bangumi item diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 1e207f90..f8b0afff 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -73,6 +73,33 @@ def _all_title_patterns(bangumi: Bangumi) -> list[str]: return patterns +def normalize_save_path(save_path: str | None) -> str: + """Normalize a save_path so equivalent paths compare equal. + + Collapses the separator/trailing-slash variations that + `BangumiDatabase.match_by_save_path` used to try one at a time. + """ + if not save_path: + return "" + return save_path.replace("\\", "/").rstrip("/") + + +def build_save_path_index(bangumi_list: list[Bangumi]) -> dict[str, Bangumi]: + """Build an in-memory normalized-save_path -> Bangumi index. + + Lets callers match many torrents against an already-loaded bangumi list + (O(1) per lookup) instead of issuing a DB query per torrent. + """ + index: dict[str, Bangumi] = {} + for bangumi in bangumi_list: + if bangumi.deleted or not bangumi.save_path: + continue + key = normalize_save_path(bangumi.save_path) + if key: + index.setdefault(key, bangumi) + return index + + def match_bangumi_in_list( torrent_name: str, bangumi_list: list[Bangumi] ) -> Optional[Bangumi]: diff --git a/backend/src/test/test_api_bangumi.py b/backend/src/test/test_api_bangumi.py index c62d5f90..aee926c2 100644 --- a/backend/src/test/test_api_bangumi.py +++ b/backend/src/test/test_api_bangumi.py @@ -173,7 +173,7 @@ class TestDeleteBangumi: assert response.status_code == 200 def test_disable_rule(self, authed_client): - """DELETE /bangumi/disable/{id} marks as deleted.""" + """POST /bangumi/disable/{id} marks as deleted.""" resp_model = ResponseModel( status=True, status_code=200, msg_en="Disabled.", msg_zh="已禁用。" ) @@ -181,12 +181,12 @@ class TestDeleteBangumi: mock_mgr = MockManager.return_value mock_mgr.disable_rule = AsyncMock(return_value=resp_model) - response = authed_client.delete("/api/v1/bangumi/disable/1") + response = authed_client.post("/api/v1/bangumi/disable/1") assert response.status_code == 200 def test_enable_rule(self, authed_client): - """GET /bangumi/enable/{id} re-enables rule.""" + """POST /bangumi/enable/{id} re-enables rule.""" resp_model = ResponseModel( status=True, status_code=200, msg_en="Enabled.", msg_zh="已启用。" ) @@ -194,7 +194,7 @@ class TestDeleteBangumi: mock_mgr = MockManager.return_value mock_mgr.enable_rule = AsyncMock(return_value=resp_model) - response = authed_client.get("/api/v1/bangumi/enable/1") + response = authed_client.post("/api/v1/bangumi/enable/1") assert response.status_code == 200 @@ -206,9 +206,9 @@ class TestDeleteBangumi: class TestResetBangumi: def test_reset_all(self, authed_client, mock_db): - """GET /bangumi/reset/all deletes all bangumi.""" + """POST /bangumi/reset/all deletes all bangumi.""" mock_db.bangumi.delete_all = AsyncMock(return_value=None) - response = authed_client.get("/api/v1/bangumi/reset/all") + response = authed_client.post("/api/v1/bangumi/reset/all") assert response.status_code == 200 diff --git a/backend/src/test/test_api_bangumi_extended.py b/backend/src/test/test_api_bangumi_extended.py index c646bab5..1efb9561 100644 --- a/backend/src/test/test_api_bangumi_extended.py +++ b/backend/src/test/test_api_bangumi_extended.py @@ -321,23 +321,21 @@ class TestSetWeekday: class TestBatchOperations: def test_delete_many_auth_required(self, unauthed_client): - """DELETE /bangumi/delete/many/ requires authentication.""" + """POST /bangumi/delete/many requires authentication.""" # Note: The batch endpoints accept list as body but FastAPI requires # proper Query/Body annotations. Testing auth requirement only. with patch("module.security.api.DEV_AUTH_BYPASS", False): - response = unauthed_client.request( - "DELETE", - "/api/v1/bangumi/delete/many/", + response = unauthed_client.post( + "/api/v1/bangumi/delete/many", json=[1, 2, 3], ) assert response.status_code == 401 def test_disable_many_auth_required(self, unauthed_client): - """DELETE /bangumi/disable/many/ requires authentication.""" + """POST /bangumi/disable/many requires authentication.""" with patch("module.security.api.DEV_AUTH_BYPASS", False): - response = unauthed_client.request( - "DELETE", - "/api/v1/bangumi/disable/many/", + response = unauthed_client.post( + "/api/v1/bangumi/disable/many", json=[1, 2], ) assert response.status_code == 401 diff --git a/backend/src/test/test_api_downloader.py b/backend/src/test/test_api_downloader.py index ff98f281..2c2270e4 100644 --- a/backend/src/test/test_api_downloader.py +++ b/backend/src/test/test_api_downloader.py @@ -393,8 +393,7 @@ class TestAutoTagTorrents: with patch("module.api.downloader.Database") as MockDB: mock_db = MagicMock() - mock_db.bangumi.match_torrent = AsyncMock(return_value=mock_bangumi) - mock_db.bangumi.match_by_save_path = AsyncMock(return_value=None) + mock_db.bangumi.search_all = AsyncMock(return_value=[mock_bangumi]) MockDB.return_value.__aenter__ = AsyncMock(return_value=mock_db) MockDB.return_value.__aexit__ = AsyncMock(return_value=False) @@ -426,8 +425,7 @@ class TestAutoTagTorrents: with patch("module.api.downloader.Database") as MockDB: mock_db = MagicMock() - mock_db.bangumi.match_torrent = AsyncMock(return_value=None) - mock_db.bangumi.match_by_save_path = AsyncMock(return_value=None) + mock_db.bangumi.search_all = AsyncMock(return_value=[]) MockDB.return_value.__aenter__ = AsyncMock(return_value=mock_db) MockDB.return_value.__aexit__ = AsyncMock(return_value=False) diff --git a/backend/src/test/test_api_log.py b/backend/src/test/test_api_log.py index 62e164b6..bd6fc23b 100644 --- a/backend/src/test/test_api_log.py +++ b/backend/src/test/test_api_log.py @@ -1,17 +1,16 @@ """Tests for Log API endpoints.""" -import pytest from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from module.api import v1 from module.security.api import get_current_user - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -67,8 +66,8 @@ class TestAuthRequired: @patch("module.security.api.DEV_AUTH_BYPASS", False) def test_clear_log_unauthorized(self, unauthed_client): - """GET /log/clear without auth returns 401.""" - response = unauthed_client.get("/api/v1/log/clear") + """POST /log/clear without auth returns 401.""" + response = unauthed_client.post("/api/v1/log/clear") assert response.status_code == 401 @@ -111,19 +110,19 @@ class TestGetLog: # --------------------------------------------------------------------------- -# GET /log/clear +# POST /log/clear # --------------------------------------------------------------------------- class TestClearLog: def test_clear_log_success(self, authed_client, temp_log_file): - """GET /log/clear clears the log file.""" + """POST /log/clear clears the log file.""" # Ensure file has content temp_log_file.write_text("Some log content") assert temp_log_file.read_text() != "" with patch("module.api.log.LOG_PATH", temp_log_file): - response = authed_client.get("/api/v1/log/clear") + response = authed_client.post("/api/v1/log/clear") assert response.status_code == 200 data = response.json() @@ -131,11 +130,11 @@ class TestClearLog: assert temp_log_file.read_text() == "" def test_clear_log_not_found(self, authed_client): - """GET /log/clear returns 406 when log file doesn't exist.""" + """POST /log/clear returns 404 when log file doesn't exist.""" non_existent_path = Path("/nonexistent/path/app.log") with patch("module.api.log.LOG_PATH", non_existent_path): - response = authed_client.get("/api/v1/log/clear") + response = authed_client.post("/api/v1/log/clear") - assert response.status_code == 406 + assert response.status_code == 404 data = response.json() assert data["msg_en"] == "Log file not found." diff --git a/backend/src/test/test_api_program.py b/backend/src/test/test_api_program.py index 3da7b71d..33643705 100644 --- a/backend/src/test/test_api_program.py +++ b/backend/src/test/test_api_program.py @@ -1,8 +1,8 @@ """Tests for Program API endpoints.""" -import pytest -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -77,20 +77,20 @@ def unauthed_client(app): class TestAuthRequired: @patch("module.security.api.DEV_AUTH_BYPASS", False) def test_restart_unauthorized(self, unauthed_client): - """GET /restart without auth returns 401.""" - response = unauthed_client.get("/api/v1/restart") + """POST /restart without auth returns 401.""" + response = unauthed_client.post("/api/v1/restart") assert response.status_code == 401 @patch("module.security.api.DEV_AUTH_BYPASS", False) def test_start_unauthorized(self, unauthed_client): - """GET /start without auth returns 401.""" - response = unauthed_client.get("/api/v1/start") + """POST /start without auth returns 401.""" + response = unauthed_client.post("/api/v1/start") assert response.status_code == 401 @patch("module.security.api.DEV_AUTH_BYPASS", False) def test_stop_unauthorized(self, unauthed_client): - """GET /stop without auth returns 401.""" - response = unauthed_client.get("/api/v1/stop") + """POST /stop without auth returns 401.""" + response = unauthed_client.post("/api/v1/stop") assert response.status_code == 401 @patch("module.security.api.DEV_AUTH_BYPASS", False) @@ -101,50 +101,50 @@ class TestAuthRequired: # --------------------------------------------------------------------------- -# GET /start +# POST /start # --------------------------------------------------------------------------- class TestStartProgram: def test_start_success(self, authed_client): - """GET /start returns success response.""" - response = authed_client.get("/api/v1/start") + """POST /start returns success response.""" + response = authed_client.post("/api/v1/start") assert response.status_code == 200 def test_start_failure(self, authed_client, mock_ctx): - """GET /start handles exceptions.""" + """POST /start handles exceptions.""" mock_ctx.start_tasks = AsyncMock(side_effect=Exception("Start failed")) - response = authed_client.get("/api/v1/start") + response = authed_client.post("/api/v1/start") assert response.status_code == 500 # --------------------------------------------------------------------------- -# GET /stop +# POST /stop # --------------------------------------------------------------------------- class TestStopProgram: def test_stop_success(self, authed_client): - """GET /stop returns success response.""" - response = authed_client.get("/api/v1/stop") + """POST /stop returns success response.""" + response = authed_client.post("/api/v1/stop") assert response.status_code == 200 # --------------------------------------------------------------------------- -# GET /restart +# POST /restart # --------------------------------------------------------------------------- class TestRestartProgram: def test_restart_success(self, authed_client): - """GET /restart returns success response.""" - response = authed_client.get("/api/v1/restart") + """POST /restart returns success response.""" + response = authed_client.post("/api/v1/restart") assert response.status_code == 200 def test_restart_failure(self, authed_client, mock_ctx): - """GET /restart handles exceptions.""" + """POST /restart handles exceptions.""" mock_ctx.restart = AsyncMock(side_effect=Exception("Restart failed")) - response = authed_client.get("/api/v1/restart") + response = authed_client.post("/api/v1/restart") assert response.status_code == 500 diff --git a/backend/src/test/test_api_rss.py b/backend/src/test/test_api_rss.py index e38738f0..3500d86c 100644 --- a/backend/src/test/test_api_rss.py +++ b/backend/src/test/test_api_rss.py @@ -136,12 +136,12 @@ class TestDeleteRss: assert response.status_code == 200 def test_delete_failure(self, authed_client, mock_db): - """DELETE /rss/delete/{id} returns 406 when feed not found.""" + """DELETE /rss/delete/{id} returns 400 when feed not found.""" mock_db.rss.delete = AsyncMock(return_value=False) response = authed_client.delete("/api/v1/rss/delete/999") - assert response.status_code == 406 + assert response.status_code == 400 # --------------------------------------------------------------------------- @@ -159,12 +159,12 @@ class TestDisableRss: assert response.status_code == 200 def test_disable_failure(self, authed_client, mock_db): - """PATCH /rss/disable/{id} returns 406 when feed not found.""" + """PATCH /rss/disable/{id} returns 404 when feed not found.""" mock_db.rss.disable = AsyncMock(return_value=False) response = authed_client.patch("/api/v1/rss/disable/999") - assert response.status_code == 406 + assert response.status_code == 404 # --------------------------------------------------------------------------- @@ -232,13 +232,13 @@ class TestUpdateRss: # --------------------------------------------------------------------------- -# GET /rss/refresh/* +# POST /rss/refresh/* # --------------------------------------------------------------------------- class TestRefreshRss: def test_refresh_all(self, authed_client): - """GET /rss/refresh/all triggers engine.refresh_rss.""" + """POST /rss/refresh/all triggers engine.refresh_rss.""" with patch("module.api.rss.DownloadClient") as MockClient: mock_client = AsyncMock() MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client) @@ -247,12 +247,12 @@ class TestRefreshRss: mock_eng = MockEngine.return_value mock_eng.refresh_rss = AsyncMock() - response = authed_client.get("/api/v1/rss/refresh/all") + response = authed_client.post("/api/v1/rss/refresh/all") assert response.status_code == 200 def test_refresh_single(self, authed_client): - """GET /rss/refresh/{id} refreshes specific feed.""" + """POST /rss/refresh/{id} refreshes specific feed.""" with patch("module.api.rss.DownloadClient") as MockClient: mock_client = AsyncMock() MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client) @@ -261,7 +261,7 @@ class TestRefreshRss: mock_eng = MockEngine.return_value mock_eng.refresh_rss = AsyncMock() - response = authed_client.get("/api/v1/rss/refresh/1") + response = authed_client.post("/api/v1/rss/refresh/1") assert response.status_code == 200 diff --git a/backend/src/test/test_api_search.py b/backend/src/test/test_api_search.py index 1bc8de56..ebed25e0 100644 --- a/backend/src/test/test_api_search.py +++ b/backend/src/test/test_api_search.py @@ -1,15 +1,14 @@ """Tests for Search API endpoints.""" -import pytest -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from module.api import v1 from module.security.api import get_current_user - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- diff --git a/webui/src/api/auth.ts b/webui/src/api/auth.ts index d5be7e8a..3a546f3a 100644 --- a/webui/src/api/auth.ts +++ b/webui/src/api/auth.ts @@ -27,7 +27,7 @@ export const apiAuth = { }, async logout() { - const { data } = await axios.get('api/v1/auth/logout'); + const { data } = await axios.post('api/v1/auth/logout'); return data; }, diff --git a/webui/src/api/bangumi.ts b/webui/src/api/bangumi.ts index 74ac80ce..727963e4 100644 --- a/webui/src/api/bangumi.ts +++ b/webui/src/api/bangumi.ts @@ -24,24 +24,6 @@ export const apiBangumi = { return result; }, - /** - * 获取指定 bangumiId 的规则 - * @param bangumiId bangumi id - * @returns 指定 bangumi 的规则 - */ - async getRule(bangumiId: number) { - const { data } = await axios.get( - `api/v1/bangumi/get/${bangumiId}` - ); - const result: BangumiRule = { - ...data, - filter: data.filter.split(','), - rss_link: data.rss_link.split(','), - air_weekday: data.air_weekday ?? null, - }; - return result; - }, - /** * 更新指定 bangumiId 的规则 * @param bangumiId - 需要更新的 bangumi 的 id @@ -69,22 +51,19 @@ export const apiBangumi = { * @returns axios 请求返回的数据 */ async deleteRule(bangumiId: number | number[], file: boolean) { - let url = 'api/v1/bangumi/delete'; - let ids: undefined | number[]; - if (typeof bangumiId === 'number') { - url = `${url}/${bangumiId}`; - } else { - url = `${url}/many`; - ids = bangumiId; + const { data } = await axios.delete( + `api/v1/bangumi/delete/${bangumiId}`, + { params: { file } } + ); + return data; } - const { data } = await axios.delete(url, { - data: ids, - params: { - file, - }, - }); + const { data } = await axios.post( + 'api/v1/bangumi/delete/many', + bangumiId, + { params: { file } } + ); return data; }, @@ -95,22 +74,20 @@ export const apiBangumi = { * @returns axios 请求返回的数据 */ async disableRule(bangumiId: number | number[], file: boolean) { - let url = 'api/v1/bangumi/disable'; - let ids: undefined | number[]; - if (typeof bangumiId === 'number') { - url = `${url}/${bangumiId}`; - } else { - url = `${url}/many`; - ids = bangumiId; + const { data } = await axios.post( + `api/v1/bangumi/disable/${bangumiId}`, + null, + { params: { file } } + ); + return data; } - const { data } = await axios.delete(url, { - data: ids, - params: { - file, - }, - }); + const { data } = await axios.post( + 'api/v1/bangumi/disable/many', + bangumiId, + { params: { file } } + ); return data; }, @@ -119,7 +96,7 @@ export const apiBangumi = { * @param bangumiId - 需要启用的 bangumi 的 id */ async enableRule(bangumiId: number) { - const { data } = await axios.get( + const { data } = await axios.post( `api/v1/bangumi/enable/${bangumiId}` ); return data; @@ -129,7 +106,7 @@ export const apiBangumi = { * 重置所有 bangumi 数据 */ async resetAll() { - const { data } = await axios.get('api/v1/bangumi/reset/all'); + const { data } = await axios.post('api/v1/bangumi/reset/all'); return data; }, @@ -231,19 +208,4 @@ export const apiBangumi = { ); return data; }, - - /** - * 获取所有需要检查偏移量的 bangumi - */ - async getNeedsReview() { - const { data } = await axios.get( - 'api/v1/bangumi/needs-review' - ); - return data.map((bangumi) => ({ - ...bangumi, - filter: bangumi.filter.split(','), - rss_link: bangumi.rss_link.split(','), - air_weekday: bangumi.air_weekday ?? null, - })) as BangumiRule[]; - }, }; diff --git a/webui/src/api/log.ts b/webui/src/api/log.ts index bf9baf5b..525b22cb 100644 --- a/webui/src/api/log.ts +++ b/webui/src/api/log.ts @@ -7,7 +7,7 @@ export const apiLog = { }, async clearLog() { - const { data } = await axios.get('api/v1/log/clear'); + const { data } = await axios.post('api/v1/log/clear'); return data; }, }; diff --git a/webui/src/api/program.ts b/webui/src/api/program.ts index eea8e1bb..373000f7 100644 --- a/webui/src/api/program.ts +++ b/webui/src/api/program.ts @@ -5,7 +5,7 @@ export const apiProgram = { * 重启 */ async restart() { - const { data } = await axios.get('api/v1/restart'); + const { data } = await axios.post('api/v1/restart'); return data; }, @@ -13,7 +13,7 @@ export const apiProgram = { * 启动 */ async start() { - const { data } = await axios.get('api/v1/start'); + const { data } = await axios.post('api/v1/start'); return data; }, @@ -21,7 +21,7 @@ export const apiProgram = { * 停止 */ async stop() { - const { data } = await axios.get('api/v1/stop'); + const { data } = await axios.post('api/v1/stop'); return data; }, @@ -40,7 +40,7 @@ export const apiProgram = { * 终止 */ async shutdown() { - const { data } = await axios.get('api/v1/shutdown'); + const { data } = await axios.post('api/v1/shutdown'); return data; }, }; diff --git a/webui/src/api/rss.ts b/webui/src/api/rss.ts index 9cd5d92b..49297dbb 100644 --- a/webui/src/api/rss.ts +++ b/webui/src/api/rss.ts @@ -60,12 +60,12 @@ export const apiRSS = { }, async refreshAll() { - const { data } = await axios.get('api/v1/rss/refresh/all'); + const { data } = await axios.post('api/v1/rss/refresh/all'); return data!; }, async refresh(rss_id: number) { - const { data } = await axios.get( + const { data } = await axios.post( `api/v1/rss/refresh/${rss_id}` ); return data!;