返回 douyin-downloader
test_audio_extraction.py
根目录 / tests / test_audio_extraction.py
1 """Unit + property tests for :mod:`core.audio_extraction`.
2
3 Covers Property 6 (stderr 内存有界), Property 7 (subprocess 永不
4 ``shell=True``), Property 8 (可用性缓存正确性), plus the explicit
5 acceptance criteria around timeout, non-zero exit, empty output, and
6 platform-unsupported paths.
7
8 We intentionally do NOT spin up real ffmpeg processes here; that's
9 covered by the integration smoke step in tasks.md task 20. Mocking
10 ``asyncio.create_subprocess_exec`` lets us exercise every error branch
11 deterministically and quickly.
12 """
13 from __future__ import annotations
14
15 import asyncio
16 from pathlib import Path
17 from typing import Optional, Tuple
18 from unittest.mock import AsyncMock, MagicMock, patch
19
20 import pytest
21 from hypothesis import given
22 from hypothesis import strategies as st
23
24 from core.audio_extraction import (
25 AudioExtractEmpty,
26 FfmpegLocator,
27 FfmpegNonZeroExit,
28 FfmpegNotAvailable,
29 FfmpegTimeout,
30 extract_audio,
31 )
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37
38 class _FakeStream:
39 """Minimal asyncio.StreamReader stand-in for stderr drain testing."""
40
41 def __init__(self, payload: bytes) -> None:
42 self._payload = payload
43 self._pos = 0
44
45 async def read(self, n: int) -> bytes:
46 if self._pos >= len(self._payload):
47 return b""
48 chunk = self._payload[self._pos : self._pos + n]
49 self._pos += len(chunk)
50 return chunk
51
52
53 class _FakeProcess:
54 """asyncio.subprocess.Process stand-in.
55
56 We stub the three attributes ``extract_audio`` actually touches:
57 ``returncode``, ``stderr``, and ``wait()``/``kill()``.
58 """
59
60 def __init__(
61 self,
62 *,
63 returncode: int = 0,
64 stderr_payload: bytes = b"",
65 wait_delay: float = 0.0,
66 wait_exception: Optional[BaseException] = None,
67 ) -> None:
68 self.returncode = returncode
69 self.stderr = _FakeStream(stderr_payload)
70 self.pid = 12345 # arbitrary, only used in log messages
71 self._wait_delay = wait_delay
72 self._wait_exception = wait_exception
73 self.killed = False
74 # Track the rc the caller set; flip to None until wait() resolves
75 # if you want to simulate a still-running process.
76
77 async def wait(self) -> int:
78 # After ``kill()``, subsequent ``wait()`` calls should resolve
79 # promptly (the OS reaped the process). Without this short-circuit
80 # ``_kill_and_reap`` would still sleep ``wait_delay`` seconds.
81 if self.killed:
82 return self.returncode
83 if self._wait_delay:
84 await asyncio.sleep(self._wait_delay)
85 if self._wait_exception is not None:
86 raise self._wait_exception
87 return self.returncode
88
89 def kill(self) -> None:
90 self.killed = True
91
92
93 def _patch_subprocess(
94 monkeypatch: pytest.MonkeyPatch, fake: _FakeProcess
95 ) -> MagicMock:
96 """Patch ``asyncio.create_subprocess_exec`` so the next call returns
97 ``fake``. Returns the mock so the test can inspect call args."""
98 mock = AsyncMock(return_value=fake)
99 # Patch in *both* places in case the module aliases.
100 monkeypatch.setattr(
101 "core.audio_extraction.asyncio.create_subprocess_exec", mock
102 )
103 return mock
104
105
106 @pytest.fixture
107 def writable_tmp(tmp_path: Path) -> Tuple[Path, Path]:
108 """Return ``(video_path, output_dir)`` and pre-create a fake video."""
109 video = tmp_path / "源视频_with spaces.mp4"
110 video.write_bytes(b"\x00" * 16) # placeholder content
111 out_dir = tmp_path / "out"
112 return video, out_dir
113
114
115 @pytest.fixture(autouse=True)
116 def _reset_ffmpeg_locator():
117 """Each test gets a clean FfmpegLocator singleton."""
118 FfmpegLocator.reset_for_tests()
119 yield
120 FfmpegLocator.reset_for_tests()
121
122
123 @pytest.fixture
124 def mock_locator() -> FfmpegLocator:
125 """A FfmpegLocator that yields a deterministic path without
126 actually probing ffmpeg."""
127 locator = FfmpegLocator()
128 locator._available = True
129 locator._path = "/fake/ffmpeg"
130 locator._version = "ffmpeg version test"
131 locator._cached_at = 999_999_999.0 # far future; never refreshes
132 return locator
133
134
135 # ---------------------------------------------------------------------------
136 # extract_audio: success path
137 # ---------------------------------------------------------------------------
138
139
140 async def test_extract_audio_writes_mp3_when_ffmpeg_returns_zero(
141 monkeypatch: pytest.MonkeyPatch,
142 writable_tmp: Tuple[Path, Path],
143 mock_locator: FfmpegLocator,
144 ) -> None:
145 video, out_dir = writable_tmp
146
147 # We need the mock subprocess to also create the output file so
148 # extract_audio's empty-output check passes.
149 expected_out = out_dir / f"{video.stem}.mp3"
150
151 fake = _FakeProcess(returncode=0, stderr_payload=b"ffmpeg: ok\n")
152
153 async def fake_create(*args, **kwargs):
154 # Side effect: pretend ffmpeg wrote the output file.
155 out_dir.mkdir(parents=True, exist_ok=True)
156 expected_out.write_bytes(b"\xff\xfb\x00fake-mp3-bytes")
157 return fake
158
159 monkeypatch.setattr(
160 "core.audio_extraction.asyncio.create_subprocess_exec", fake_create
161 )
162
163 result = await extract_audio(video, out_dir, locator=mock_locator)
164 assert result == expected_out
165 assert result.read_bytes() == b"\xff\xfb\x00fake-mp3-bytes"
166
167
168 async def test_extract_audio_passes_correct_ffmpeg_args(
169 monkeypatch: pytest.MonkeyPatch,
170 writable_tmp: Tuple[Path, Path],
171 mock_locator: FfmpegLocator,
172 ) -> None:
173 """Property 7: subprocess always invoked with list args, never
174 ``shell=True``. Also pins R1.2's exact codec flags."""
175 video, out_dir = writable_tmp
176 expected_out = out_dir / f"{video.stem}.mp3"
177
178 fake = _FakeProcess(returncode=0)
179 captured = {}
180
181 async def fake_create(*args, **kwargs):
182 captured["args"] = args
183 captured["kwargs"] = kwargs
184 out_dir.mkdir(parents=True, exist_ok=True)
185 expected_out.write_bytes(b"\xff\xfb\x00mp3")
186 return fake
187
188 monkeypatch.setattr(
189 "core.audio_extraction.asyncio.create_subprocess_exec", fake_create
190 )
191
192 await extract_audio(video, out_dir, locator=mock_locator)
193
194 args = captured["args"]
195 # First positional is ffmpeg path, then -y, then -i <video>, then the
196 # codec flags, then output.
197 assert args[0] == "/fake/ffmpeg"
198 assert args[1] == "-y"
199 assert args[2] == "-i"
200 assert args[3] == str(video)
201 # Required encoder flags (R1.2)
202 for flag in ("-vn", "-ac", "1", "-ar", "16000", "-b:a", "32k", "-f", "mp3"):
203 assert flag in args, f"missing required flag: {flag}"
204 # Last positional is the output path
205 assert args[-1] == str(expected_out)
206 # No shell=True (Property 7 / R1.9)
207 assert "shell" not in captured["kwargs"]
208
209
210 # ---------------------------------------------------------------------------
211 # extract_audio: failure paths
212 # ---------------------------------------------------------------------------
213
214
215 async def test_extract_audio_raises_nonzero_exit_with_stderr_tail(
216 monkeypatch: pytest.MonkeyPatch,
217 writable_tmp: Tuple[Path, Path],
218 mock_locator: FfmpegLocator,
219 ) -> None:
220 video, out_dir = writable_tmp
221 fake = _FakeProcess(
222 returncode=1, stderr_payload=b"ffmpeg: invalid input\n"
223 )
224
225 async def fake_create(*args, **kwargs):
226 return fake
227
228 monkeypatch.setattr(
229 "core.audio_extraction.asyncio.create_subprocess_exec", fake_create
230 )
231
232 with pytest.raises(FfmpegNonZeroExit) as excinfo:
233 await extract_audio(video, out_dir, locator=mock_locator)
234
235 msg = str(excinfo.value)
236 assert msg.startswith("audio_extract_failed: nonzero_exit_code")
237 assert "exit=1" in msg
238 assert "ffmpeg: invalid input" in msg
239
240
241 async def test_extract_audio_raises_empty_when_output_zero_bytes(
242 monkeypatch: pytest.MonkeyPatch,
243 writable_tmp: Tuple[Path, Path],
244 mock_locator: FfmpegLocator,
245 ) -> None:
246 video, out_dir = writable_tmp
247 fake = _FakeProcess(returncode=0)
248
249 async def fake_create(*args, **kwargs):
250 out_dir.mkdir(parents=True, exist_ok=True)
251 # Write a 0-byte output file, simulating libmp3lame edge case.
252 (out_dir / f"{video.stem}.mp3").write_bytes(b"")
253 return fake
254
255 monkeypatch.setattr(
256 "core.audio_extraction.asyncio.create_subprocess_exec", fake_create
257 )
258
259 with pytest.raises(AudioExtractEmpty) as excinfo:
260 await extract_audio(video, out_dir, locator=mock_locator)
261 assert str(excinfo.value).startswith(
262 "audio_extract_failed: audio_extract_empty"
263 )
264
265
266 async def test_extract_audio_raises_timeout_and_kills_process(
267 monkeypatch: pytest.MonkeyPatch,
268 writable_tmp: Tuple[Path, Path],
269 mock_locator: FfmpegLocator,
270 ) -> None:
271 video, out_dir = writable_tmp
272 expected_out = out_dir / f"{video.stem}.mp3"
273
274 fake = _FakeProcess(returncode=0, wait_delay=10.0) # never resolves
275
276 async def fake_create(*args, **kwargs):
277 out_dir.mkdir(parents=True, exist_ok=True)
278 # Pretend ffmpeg started writing output before timeout.
279 expected_out.write_bytes(b"partial")
280 return fake
281
282 monkeypatch.setattr(
283 "core.audio_extraction.asyncio.create_subprocess_exec", fake_create
284 )
285
286 # Patch the timeout constant so the test resolves quickly.
287 monkeypatch.setattr(
288 "core.audio_extraction._FFMPEG_TIMEOUT_SECONDS", 0.05
289 )
290
291 with pytest.raises(FfmpegTimeout) as excinfo:
292 await extract_audio(video, out_dir, locator=mock_locator)
293
294 assert str(excinfo.value).startswith(
295 "audio_extract_failed: audio_extract_timeout"
296 )
297 assert fake.killed is True
298 # Half-written output should have been removed.
299 assert not expected_out.exists()
300
301
302 async def test_extract_audio_propagates_ffmpeg_not_available(
303 writable_tmp: Tuple[Path, Path],
304 ) -> None:
305 """If ``locator.locate()`` raises, ``extract_audio`` must propagate."""
306 video, out_dir = writable_tmp
307 locator = FfmpegLocator()
308 # locator hasn't probed; force a failure cache state directly.
309 locator._available = False
310 locator._cached_at = 9_999_999_999.0
311 locator._last_error = "test: simulated"
312
313 with pytest.raises(FfmpegNotAvailable) as excinfo:
314 await extract_audio(video, out_dir, locator=locator)
315 assert "test: simulated" in str(excinfo.value)
316
317
318 # ---------------------------------------------------------------------------
319 # Property 6: stderr ring buffer is bounded
320 # ---------------------------------------------------------------------------
321
322
323 async def test_extract_audio_stderr_ring_buffer_keeps_only_tail(
324 monkeypatch: pytest.MonkeyPatch,
325 writable_tmp: Tuple[Path, Path],
326 mock_locator: FfmpegLocator,
327 ) -> None:
328 """When ffmpeg dumps multi-MiB of stderr, the deque's ``maxlen`` keeps
329 memory bounded and the error message contains only the tail bytes."""
330 video, out_dir = writable_tmp
331
332 # 2 MiB of unique stderr content; the last 4096 bytes should be a
333 # well-known sentinel we can grep for.
334 sentinel = b"SENTINEL_TAIL_PATTERN_XYZ_" * 100 # 2600 bytes, < 4096
335 bulk = b"X" * (2 * 1024 * 1024 - len(sentinel))
336 payload = bulk + sentinel
337 fake = _FakeProcess(returncode=2, stderr_payload=payload)
338
339 async def fake_create(*args, **kwargs):
340 return fake
341
342 monkeypatch.setattr(
343 "core.audio_extraction.asyncio.create_subprocess_exec", fake_create
344 )
345
346 with pytest.raises(FfmpegNonZeroExit) as excinfo:
347 await extract_audio(video, out_dir, locator=mock_locator)
348
349 msg = str(excinfo.value)
350 # Tail sentinel must be present (proves the deque kept the end).
351 assert b"SENTINEL_TAIL_PATTERN_XYZ_".decode() in msg
352 # The original ``X`` bulk should NOT round-trip in full — message size
353 # is bounded by 4096 bytes of tail (plus a small wrapping prefix).
354 assert msg.count("X") < 4096
355
356
357 # ---------------------------------------------------------------------------
358 # Property 7: subprocess never invoked with shell=True (hypothesis)
359 # ---------------------------------------------------------------------------
360
361
362 @given(
363 raw_stem=st.text(
364 alphabet=st.characters(
365 min_codepoint=32,
366 max_codepoint=0x1FFFF,
367 blacklist_categories=("Cs",), # exclude surrogates
368 ),
369 min_size=1,
370 max_size=64,
371 ).filter(lambda s: "/" not in s and "\\" not in s and "\x00" not in s),
372 )
373 def test_extract_audio_never_uses_shell_true_for_arbitrary_filenames(
374 raw_stem: str,
375 ) -> None:
376 """Hypothesis property: any filename that survives FS encoding must
377 still flow through ``create_subprocess_exec`` with list args, not
378 ``shell=True``. Catches injection regressions if anyone ever
379 refactors to ``f"ffmpeg -i {video}"``.
380
381 Wrapped as a sync test driving its own asyncio loop because hypothesis
382 + pytest-asyncio don't compose cleanly on async examples (each example
383 needs an isolated loop). Uses ``tempfile`` directly — pytest tmp_path
384 fixtures don't compose with ``@given`` either.
385 """
386 import tempfile
387
388 with tempfile.TemporaryDirectory(prefix="hyp_") as tmp_str:
389 tmp = Path(tmp_str)
390 try:
391 video = tmp / f"{raw_stem}.mp4"
392 video.write_bytes(b"\x00")
393 except (OSError, ValueError):
394 return # uninteresting; not a regression target.
395
396 out_dir = tmp / "out"
397
398 async def _run() -> None:
399 locator = FfmpegLocator()
400 locator._available = True
401 locator._path = "/fake/ffmpeg"
402 locator._version = "test"
403 locator._cached_at = 9e9
404
405 captured: dict = {}
406 fake_proc = _FakeProcess(returncode=0)
407
408 async def fake_create(*args, **kwargs):
409 captured["args"] = args
410 captured["kwargs"] = kwargs
411 out_dir.mkdir(parents=True, exist_ok=True)
412 (out_dir / f"{video.stem}.mp3").write_bytes(
413 b"\xff\xfb\x00mp3"
414 )
415 return fake_proc
416
417 with patch(
418 "core.audio_extraction.asyncio.create_subprocess_exec",
419 side_effect=fake_create,
420 ):
421 await extract_audio(video, out_dir, locator=locator)
422
423 assert "shell" not in captured.get("kwargs", {})
424 assert all(isinstance(a, str) for a in captured["args"])
425
426 asyncio.run(_run())
427
428
429 # ---------------------------------------------------------------------------
430 # Property 8: FfmpegLocator availability cache (60s TTL)
431 # ---------------------------------------------------------------------------
432
433
434 async def test_locator_caches_probe_within_ttl(
435 monkeypatch: pytest.MonkeyPatch,
436 ) -> None:
437 """N calls to locate() within 60s should trigger only 1 probe."""
438 locator = FfmpegLocator()
439
440 probe_count = 0
441
442 async def counting_probe():
443 nonlocal probe_count
444 probe_count += 1
445 # Inline a successful state directly so we don't hit imageio.
446 locator._available = True
447 locator._path = "/fake/ffmpeg"
448 locator._version = "ffmpeg version test"
449 locator._last_error = None
450
451 locator._probe = counting_probe # type: ignore[assignment]
452
453 for _ in range(5):
454 path = await locator.locate()
455 assert path == "/fake/ffmpeg"
456
457 assert probe_count == 1
458
459
460 async def test_locator_re_probes_after_ttl(
461 monkeypatch: pytest.MonkeyPatch,
462 ) -> None:
463 """After the 60s TTL, the next locate() must re-probe."""
464 locator = FfmpegLocator()
465 probe_count = 0
466 fake_now = [1000.0]
467
468 async def counting_probe():
469 nonlocal probe_count
470 probe_count += 1
471 locator._available = True
472 locator._path = "/fake/ffmpeg"
473 locator._version = "v"
474 locator._last_error = None
475
476 locator._probe = counting_probe # type: ignore[assignment]
477 monkeypatch.setattr(
478 "core.audio_extraction.time.monotonic",
479 lambda: fake_now[0],
480 )
481
482 await locator.locate() # probe 1
483 fake_now[0] += 30 # within TTL
484 await locator.locate() # cache hit
485 fake_now[0] += 31 # crosses TTL boundary (61s)
486 await locator.locate() # probe 2
487
488 assert probe_count == 2
489
490
491 async def test_locator_diagnostic_returns_unavailable_on_imageio_failure(
492 monkeypatch: pytest.MonkeyPatch,
493 ) -> None:
494 """Platform-unsupported simulation: ``get_ffmpeg_exe()`` raises."""
495 locator = FfmpegLocator()
496
497 fake_imageio = MagicMock()
498 fake_imageio.get_ffmpeg_exe = MagicMock(
499 side_effect=RuntimeError("no binary on platform")
500 )
501 # We can't easily monkeypatch a stdlib import line, so we monkeypatch
502 # the function via sys.modules.
503 import sys
504
505 monkeypatch.setitem(sys.modules, "imageio_ffmpeg", fake_imageio)
506
507 diag = await locator.diagnostic()
508 assert diag == {
509 "ffmpeg_available": False,
510 "ffmpeg_path": "",
511 "ffmpeg_version": None,
512 }
513
514 with pytest.raises(FfmpegNotAvailable) as excinfo:
515 await locator.locate()
516 assert "no binary on platform" in str(excinfo.value)
517
518
519 async def test_locator_diagnostic_returns_unavailable_on_version_nonzero(
520 monkeypatch: pytest.MonkeyPatch, tmp_path: Path
521 ) -> None:
522 """``ffmpeg -version`` exits non-zero ⇒ unavailable."""
523 locator = FfmpegLocator()
524
525 fake_path = tmp_path / "ffmpeg"
526 fake_path.write_bytes(b"#!/bin/sh\nexit 1\n")
527 fake_path.chmod(0o755)
528
529 fake_imageio = MagicMock()
530 fake_imageio.get_ffmpeg_exe = MagicMock(return_value=str(fake_path))
531 import sys
532
533 monkeypatch.setitem(sys.modules, "imageio_ffmpeg", fake_imageio)
534
535 fake = _FakeProcess(returncode=1, stderr_payload=b"")
536
537 async def fake_create(*args, **kwargs):
538 return fake
539
540 # Stub stdout returned by communicate() — we rebuild a Process-ish
541 # object since `_probe` calls communicate(), not .wait().
542 class _CommunicableFake:
543 returncode = 1
544
545 async def communicate(self):
546 return (b"", b"")
547
548 def kill(self):
549 pass
550
551 async def fake_create_for_probe(*args, **kwargs):
552 return _CommunicableFake()
553
554 monkeypatch.setattr(
555 "core.audio_extraction.asyncio.create_subprocess_exec",
556 fake_create_for_probe,
557 )
558
559 diag = await locator.diagnostic()
560 assert diag["ffmpeg_available"] is False
561 assert diag["ffmpeg_version"] is None
562
563
564 # ---------------------------------------------------------------------------
565 # Sanity: AudioExtractError prefix contract
566 # ---------------------------------------------------------------------------
567
568
569 @pytest.mark.parametrize(
570 "exc_cls,expected_cause",
571 [
572 (FfmpegNotAvailable, "ffmpeg_not_available"),
573 (FfmpegTimeout, "audio_extract_timeout"),
574 (FfmpegNonZeroExit, "nonzero_exit_code"),
575 (AudioExtractEmpty, "audio_extract_empty"),
576 ],
577 )
578 def test_audio_extract_error_message_prefix(exc_cls, expected_cause) -> None:
579 """All AudioExtractError subclasses must produce
580 ``audio_extract_failed: <cause>: <detail>`` per R6.2."""
581 exc = exc_cls("some-detail")
582 msg = str(exc)
583 assert msg.startswith(f"audio_extract_failed: {expected_cause}")
584 assert "some-detail" in msg
585
585 lines PYTHON