tests/test_reporting_fault_fixes.py
Rama master · réplica de lectura del repositorio original.
"""远程诊断的断连、独立资源与媒体故障证据行为回归。"""
import asyncio
import importlib.util
import json
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from unittest import mock
from peach import api, metadata_amane, remote_reporting, settings_file
from peach.ffmpeg import BinaryChoice
from peach.streaming import BufferedFileResponse
from peach.transcodes import TranscodeService, TranscodeUnavailable
class FaultFixTests(unittest.TestCase):
def test_recovery_scheduler_works_without_local_timezone_data(self):
from datetime import timezone
from zoneinfo import ZoneInfoNotFoundError
from peach.web_job_recovery import RecoveryService
with mock.patch('apscheduler.schedulers.base.get_localzone', side_effect=ZoneInfoNotFoundError('synthetic')):
service = RecoveryService(mock.Mock(), available=True)
self.assertEqual(service.scheduler.timezone, timezone.utc)
service.scheduler.add_job(service.tick, 'interval', seconds=1)
self.assertEqual(len(service.scheduler.get_jobs()), 1)
def test_a_stuck_recovery_records_its_step_and_thread_stacks_once(self):
"""接回中断任务卡住时,日志里要能看出卡在哪个任务、停在哪一行;同一次只记一次。"""
import threading
import time
from peach import web_job_recovery
service = web_job_recovery.RecoveryService(mock.Mock(), available=True)
started, release = threading.Event(), threading.Event()
def stuck_in_processing(*_args, **_kwargs):
started.set()
release.wait(5)
with mock.patch('peach.update_tasks.is_pausing', return_value=False), \
mock.patch.object(web_job_recovery, 'resume',
side_effect=lambda _db, handlers: handlers['library-processing']({})), \
mock.patch.object(web_job_recovery, 'w_library_processing', side_effect=stuck_in_processing), \
mock.patch('peach.error_reporting.local_event') as event:
worker = threading.Thread(target=service.tick)
worker.start()
self.assertTrue(started.wait(5))
service.watch(now=time.monotonic() + 5)
event.assert_not_called()
service.watch(now=time.monotonic() + web_job_recovery.STUCK_SECONDS + 1)
service.watch(now=time.monotonic() + web_job_recovery.STUCK_SECONDS * 2)
release.set()
worker.join(5)
service.watch(now=time.monotonic() + 1000)
event.assert_called_once()
self.assertEqual(event.call_args.args, ('job-recovery-stuck',))
self.assertEqual(event.call_args.kwargs['step'], 'library-processing')
frames = [line for stack in event.call_args.kwargs['stacks'].values() for line in stack]
self.assertTrue(any(line.endswith(' stuck_in_processing') for line in frames), frames)
self.assertFalse(any('\\' in line or '/' in line for line in frames), '只留文件名,不带本机路径')
def test_early_disconnect_returns_without_a_server_exception(self):
async def exercise(path):
async def inner(scope, receive, send):
await BufferedFileResponse(path)(scope, receive, send)
async def receive():
return {'type': 'http.disconnect'}
async def send(message):
pass
await api.ResponseHeadersMiddleware(inner)(
{'type': 'http', 'method': 'GET', 'path': '/stream', 'headers': []}, receive, send)
with tempfile.TemporaryDirectory() as folder:
path = Path(folder).resolve() / 'synthetic.mp4'
path.write_bytes(b'synthetic')
asyncio.run(exercise(path))
def test_server_errors_propagate_even_after_client_disconnect(self):
error = RuntimeError('synthetic server failure')
async def inner(scope, receive, send):
await receive()
raise error
async def receive():
return {'type': 'http.disconnect'}
with self.assertRaises(RuntimeError) as raised:
asyncio.run(api.ResponseHeadersMiddleware(inner)(
{'type': 'http', 'path': '/stream'}, receive, mock.AsyncMock()))
self.assertIs(raised.exception, error)
def test_response_headers_preserve_cache_policy_and_non_http_messages(self):
class Messages(list):
async def append_async(self, value):
self.append(value)
async def check(path, cache=None):
messages = Messages()
async def inner(scope, receive, send):
headers = [] if cache is None else [(b'cache-control', cache)]
await send({'type': 'http.response.start', 'status': 206, 'headers': headers})
await send({'type': 'http.response.body', 'body': b'body'})
await api.ResponseHeadersMiddleware(inner)(
{'type': 'http', 'path': path}, mock.AsyncMock(), messages.append_async)
return messages
for path, cache, expected in (('/vendor/test.js', None, b'public, max-age=31536000, immutable'),
('/stream', None, b'no-store'), ('/api/test', b'public, no-cache', b'public, no-cache')):
with self.subTest(path=path):
messages = asyncio.run(check(path, cache))
headers = dict(messages[0]['headers'])
self.assertEqual(headers[b'cache-control'], expected)
self.assertEqual(headers[b'x-content-type-options'], b'nosniff')
self.assertEqual(headers[b'referrer-policy'], b'same-origin')
self.assertIn(b'x-robots-tag', headers)
self.assertEqual(messages[1]['body'], b'body')
inner = mock.AsyncMock()
asyncio.run(api.ResponseHeadersMiddleware(inner)({'type': 'websocket'}, None, None))
inner.assert_awaited_once_with({'type': 'websocket'}, None, None)
def test_bundle_manifest_loads_bridge_revision_in_frozen_and_wheel_layouts(self):
from peach.bundle_resources import data_files
root = Path(__file__).resolve().parents[1]
bridge_data = [(Path(source), target) for source, target in data_files(root)
if target == 'tools/amane-bridge']
self.assertEqual({path.name for path, _ in bridge_data}, {'bridge.py', 'pyproject.toml', 'uv.lock'})
for layout in ('_internal', 'site-packages/peach/_resources'):
with self.subTest(layout=layout), tempfile.TemporaryDirectory() as folder:
destination = Path(folder).resolve() / layout
for source, target in bridge_data:
parent = destination / target
parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, parent / source.name)
name = 'peach._bridge_resource_fixture'
spec = importlib.util.spec_from_file_location(name, metadata_amane.__file__)
module = importlib.util.module_from_spec(spec)
with mock.patch.dict(sys.modules, {name: module}), \
mock.patch.object(settings_file, 'PROJECT_ROOT', destination):
spec.loader.exec_module(module)
facts = module.describe(Path(folder).resolve() / 'data-tools')
self.assertEqual(facts['revision'], metadata_amane.pinned_revision())
self.assertTrue(module.BRIDGE_SCRIPT.is_file())
self.assertFalse(facts['installed'])
def test_transcode_failure_projects_bounded_structured_tool_evidence(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder).resolve()
source = root / 'synthetic.avi'
source.write_bytes(b'synthetic')
resolver = mock.Mock()
resolver.ffmpeg.return_value = BinaryChoice(root / 'ffmpeg.exe', 'test')
service = TranscodeService(resolver, root / 'cache', prefer_hardware=False)
with mock.patch.object(service, '_probe', return_value=None), \
mock.patch.object(service.hardware, 'encoders', return_value=()), \
mock.patch.object(service, '_execute', return_value=(69, b'', b'Unknown decoder private_input')):
try:
service.browser_path(1, source)
except TranscodeUnavailable as error:
self.assertEqual(error.returncode, 69)
with mock.patch.object(remote_reporting, 'send') as send:
remote_reporting.capture('logging', error, logger='peach.routes_media', level='ERROR')
event = send.call_args.args[1]
else:
self.fail('合成工具失败必须生成转码异常')
self.assertEqual(event['tags']['returncode'], '69')
self.assertEqual(event['tags']['media_failure'], 'decoder_unavailable')
self.assertNotIn('private_input', json.dumps(event))
def test_expected_media_inputs_and_cancellation_stay_local(self):
from peach.transcodes import TranscodeCancelled
for error in (TranscodeUnavailable('synthetic', diagnostics={'stderr': 'Invalid data found', 'returncode': 1}),
TranscodeCancelled('synthetic')):
with self.subTest(error=type(error).__name__), mock.patch.object(remote_reporting, 'send') as send:
remote_reporting.capture('logging', error, logger='peach.routes_media', level='ERROR')
send.assert_not_called()
def test_transcode_input_failure_is_local_after_real_pipeline_frames(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder).resolve()
source = root / 'synthetic.avi'
source.write_bytes(b'synthetic')
resolver = mock.Mock()
resolver.ffmpeg.return_value = BinaryChoice(root / 'ffmpeg.exe', 'test')
service = TranscodeService(resolver, root / 'cache', prefer_hardware=False)
with mock.patch.object(service, '_probe', return_value=None), \
mock.patch.object(service.hardware, 'encoders', return_value=()), \
mock.patch.object(service, '_execute', return_value=(1, b'', b'Invalid data found')):
with mock.patch.object(remote_reporting, 'send') as send:
try:
service.browser_path(1, source)
except TranscodeUnavailable as error:
remote_reporting.capture('logging', error, logger='peach.routes_media', level='ERROR')
else:
self.fail('坏输入必须保留转码失败状态')
send.assert_not_called()