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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
This commit is contained in:
Estrella Pan
2026-07-02 17:05:34 +02:00
parent 0f44efa2af
commit 9f9c7042dc
20 changed files with 226 additions and 214 deletions

View File

@@ -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"

View File

@@ -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)):

View File

@@ -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,

View File

@@ -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": "日志文件未找到。"},
)

View File

@@ -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)):

View File

@@ -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)],

View File

@@ -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

View File

@@ -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]:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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."

View File

@@ -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

View File

@@ -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

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -27,7 +27,7 @@ export const apiAuth = {
},
async logout() {
const { data } = await axios.get<ApiSuccess>('api/v1/auth/logout');
const { data } = await axios.post<ApiSuccess>('api/v1/auth/logout');
return data;
},

View File

@@ -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<BangumiAPI>(
`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<ApiSuccess>(
`api/v1/bangumi/delete/${bangumiId}`,
{ params: { file } }
);
return data;
}
const { data } = await axios.delete<ApiSuccess>(url, {
data: ids,
params: {
file,
},
});
const { data } = await axios.post<ApiSuccess>(
'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<ApiSuccess>(
`api/v1/bangumi/disable/${bangumiId}`,
null,
{ params: { file } }
);
return data;
}
const { data } = await axios.delete<ApiSuccess>(url, {
data: ids,
params: {
file,
},
});
const { data } = await axios.post<ApiSuccess>(
'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<ApiSuccess>(
const { data } = await axios.post<ApiSuccess>(
`api/v1/bangumi/enable/${bangumiId}`
);
return data;
@@ -129,7 +106,7 @@ export const apiBangumi = {
* 重置所有 bangumi 数据
*/
async resetAll() {
const { data } = await axios.get<ApiSuccess>('api/v1/bangumi/reset/all');
const { data } = await axios.post<ApiSuccess>('api/v1/bangumi/reset/all');
return data;
},
@@ -231,19 +208,4 @@ export const apiBangumi = {
);
return data;
},
/**
* 获取所有需要检查偏移量的 bangumi
*/
async getNeedsReview() {
const { data } = await axios.get<BangumiAPI[]>(
'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[];
},
};

View File

@@ -7,7 +7,7 @@ export const apiLog = {
},
async clearLog() {
const { data } = await axios.get<ApiSuccess>('api/v1/log/clear');
const { data } = await axios.post<ApiSuccess>('api/v1/log/clear');
return data;
},
};

View File

@@ -5,7 +5,7 @@ export const apiProgram = {
* 重启
*/
async restart() {
const { data } = await axios.get<ApiSuccess>('api/v1/restart');
const { data } = await axios.post<ApiSuccess>('api/v1/restart');
return data;
},
@@ -13,7 +13,7 @@ export const apiProgram = {
* 启动
*/
async start() {
const { data } = await axios.get<ApiSuccess>('api/v1/start');
const { data } = await axios.post<ApiSuccess>('api/v1/start');
return data;
},
@@ -21,7 +21,7 @@ export const apiProgram = {
* 停止
*/
async stop() {
const { data } = await axios.get<ApiSuccess>('api/v1/stop');
const { data } = await axios.post<ApiSuccess>('api/v1/stop');
return data;
},
@@ -40,7 +40,7 @@ export const apiProgram = {
* 终止
*/
async shutdown() {
const { data } = await axios.get<ApiSuccess>('api/v1/shutdown');
const { data } = await axios.post<ApiSuccess>('api/v1/shutdown');
return data;
},
};

View File

@@ -60,12 +60,12 @@ export const apiRSS = {
},
async refreshAll() {
const { data } = await axios.get<ApiSuccess>('api/v1/rss/refresh/all');
const { data } = await axios.post<ApiSuccess>('api/v1/rss/refresh/all');
return data!;
},
async refresh(rss_id: number) {
const { data } = await axios.get<ApiSuccess>(
const { data } = await axios.post<ApiSuccess>(
`api/v1/rss/refresh/${rss_id}`
);
return data!;