返回 douyin-downloader
test_server.py
根目录 / tests / test_server.py
1 """FastAPI 服务测试:验证 job 生命周期与 HTTP 接口。
2
3 仅测试 HTTP 层 + JobManager 抽象;不触达真实 Douyin API。
4 """
5
6 import asyncio
7 from typing import Dict
8
9 import pytest
10
11 try:
12 from fastapi.testclient import TestClient # type: ignore
13 except ImportError: # pragma: no cover
14 pytest.skip("fastapi not installed", allow_module_level=True)
15
16
17 from config import ConfigLoader
18 from server.app import build_app
19 from server.jobs import JobManager
20
21
22 @pytest.mark.asyncio
23 async def test_job_manager_runs_executor(tmp_path):
24 async def fake_executor(url: str) -> Dict[str, int]:
25 return {"total": 1, "success": 1, "failed": 0, "skipped": 0}
26
27 manager = JobManager(executor=fake_executor, max_concurrency=2)
28 job = await manager.submit("https://example/one")
29 assert job.status == "pending"
30
31 # 等待后台任务跑完
32 await asyncio.wait_for(job._task, timeout=2.0)
33 fetched = await manager.get(job.job_id)
34 assert fetched is not None
35 assert fetched.status == "success"
36 assert fetched.success == 1
37
38
39 @pytest.mark.asyncio
40 async def test_job_manager_marks_failure_on_executor_error(tmp_path):
41 async def boom(url: str) -> Dict[str, int]:
42 raise RuntimeError("bad url")
43
44 manager = JobManager(executor=boom)
45 job = await manager.submit("x")
46 await asyncio.wait_for(job._task, timeout=2.0)
47 fetched = await manager.get(job.job_id)
48 assert fetched is not None
49 assert fetched.status == "failed"
50 assert fetched.error is not None
51 assert "bad url" in fetched.error
52
53
54 def test_health_endpoint(tmp_path):
55 config = ConfigLoader(None)
56 config.update(path=str(tmp_path))
57 app = build_app(config)
58
59 with TestClient(app) as client:
60 resp = client.get("/api/v1/health")
61 assert resp.status_code == 200
62 assert resp.json() == {"status": "ok"}
63
64
65 def test_download_endpoint_creates_job(tmp_path, monkeypatch):
66 config = ConfigLoader(None)
67 config.update(path=str(tmp_path))
68 app = build_app(config)
69
70 # 替换 job executor 为 fake(不去触达 Douyin)
71 async def fake_executor(url: str) -> Dict[str, int]:
72 return {"total": 0, "success": 0, "failed": 0, "skipped": 0}
73
74 app.state.job_manager.executor = fake_executor
75
76 with TestClient(app) as client:
77 resp = client.post("/api/v1/download", json={"url": "https://www.douyin.com/video/123"})
78 assert resp.status_code == 200
79 data = resp.json()
80 assert data["status"] in ("pending", "running", "success")
81 assert data["url"] == "https://www.douyin.com/video/123"
82 assert len(data["job_id"]) > 0
83
84 job_id = data["job_id"]
85 # job 列表应包含该 id
86 list_resp = client.get("/api/v1/jobs")
87 assert list_resp.status_code == 200
88 ids = [j["job_id"] for j in list_resp.json()["jobs"]]
89 assert job_id in ids
90
91 # 详情接口
92 detail = client.get(f"/api/v1/jobs/{job_id}")
93 assert detail.status_code == 200
94 assert detail.json()["job_id"] == job_id
95
96
97 def test_download_endpoint_rejects_empty_url(tmp_path):
98 config = ConfigLoader(None)
99 config.update(path=str(tmp_path))
100 app = build_app(config)
101 with TestClient(app) as client:
102 resp = client.post("/api/v1/download", json={"url": ""})
103 assert resp.status_code == 400
104
105
106 def test_get_unknown_job_returns_404(tmp_path):
107 config = ConfigLoader(None)
108 config.update(path=str(tmp_path))
109 app = build_app(config)
110 with TestClient(app) as client:
111 resp = client.get("/api/v1/jobs/unknown-id")
112 assert resp.status_code == 404
113
114
115 def test_build_app_shares_deps_across_requests(tmp_path):
116 """重请求应复用同一个 FileManager / RateLimiter 等(避免每次重建)。"""
117 config = ConfigLoader(None)
118 config.update(path=str(tmp_path))
119 app = build_app(config)
120
121 deps = app.state.deps
122 assert deps.file_manager is not None
123 assert deps.rate_limiter is not None
124 assert deps.retry_handler is not None
125 assert deps.queue_manager is not None
126 assert deps.cookie_manager is not None
127
128 # 构建第二次 app 时应该是完全独立的 deps 实例,但同一 app 内是共享的
129 app2 = build_app(config)
130 assert app2.state.deps is not app.state.deps
131 assert app.state.deps.file_manager is app.state.deps.file_manager # identity
132
133
134 @pytest.mark.asyncio
135 async def test_job_manager_prunes_by_max_jobs():
136 """max_jobs 超限时应优先淘汰最老的终态 job,保留 in-flight。"""
137
138 async def fast_executor(url: str) -> Dict[str, int]:
139 return {"total": 0, "success": 0, "failed": 0, "skipped": 0}
140
141 manager = JobManager(executor=fast_executor, max_jobs=3, job_ttl_seconds=0.0)
142 jobs = []
143 for i in range(5):
144 j = await manager.submit(f"u{i}")
145 jobs.append(j)
146 await asyncio.wait_for(j._task, timeout=1.0)
147
148 remaining = await manager.list_jobs()
149 # max_jobs=3:新任务 submit 时先剪裁,最终存量 ≤ max_jobs
150 assert len(remaining) <= 3
151 # 最新的那一批一定在,最早的那几个被淘汰
152 ids_remaining = {j.job_id for j in remaining}
153 assert jobs[-1].job_id in ids_remaining
154
155
156 @pytest.mark.asyncio
157 async def test_job_manager_prunes_by_ttl():
158 """TTL 过期的终态 job 应在下次 submit 时被清理。"""
159
160 async def fast_executor(url: str) -> Dict[str, int]:
161 return {"total": 0, "success": 0, "failed": 0, "skipped": 0}
162
163 manager = JobManager(executor=fast_executor, max_jobs=100, job_ttl_seconds=0.01)
164 old_job = await manager.submit("old")
165 await asyncio.wait_for(old_job._task, timeout=1.0)
166
167 # 等 TTL 过期
168 await asyncio.sleep(0.05)
169
170 new_job = await manager.submit("new")
171 await asyncio.wait_for(new_job._task, timeout=1.0)
172
173 remaining_ids = {j.job_id for j in await manager.list_jobs()}
174 assert old_job.job_id not in remaining_ids
175 assert new_job.job_id in remaining_ids
176
176 lines PYTHON