mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-04 06:38:05 +08:00
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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "日志文件未找到。"},
|
||||
)
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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!;
|
||||
|
||||
Reference in New Issue
Block a user