import asyncio import json import subprocess from unittest.mock import AsyncMock, Mock, patch import pytest from src.pipeline.request_archive import RequestArchive from src.pipeline.translator import Translator from src.data.storage import Storage from src.utils.retry import RetryConfig def make_translator(tmp_path): return Translator(Mock(), Mock(), Storage(tmp_path), retry_config=RetryConfig(max_attempts=1)) def records(tmp_path): return [json.loads(p.read_text()) for p in sorted((tmp_path/'request-archive').glob('*.json'))] def test_exact_unique_private_records(tmp_path): archive=RequestArchive(tmp_path) identifier=archive.start('model', 'system\n', '日本語\n') archive.finish(identifier,' response\n','warning',0) saved=json.loads((tmp_path/(identifier+'.input.json')).read_text()) assert saved['user_prompt']=='日本語\n' assert saved['user_prompt_sha256']==archive.digest('日本語\n') assert (tmp_path/(identifier+'.output.json')).stat().st_mode & 0o777 == 0o600 assert archive.start('model','system','user') != identifier with pytest.raises(FileExistsError): archive.finish(identifier) def test_sync_success_and_error_capture(tmp_path): translator=make_translator(tmp_path) with patch('src.pipeline.translator.subprocess.run',return_value=subprocess.CompletedProcess([],0,' text\n','warning')): assert translator._call_claude('sys','user')=='text' output=next(r for r in records(tmp_path) if 'stdout' in r) assert output['stdout']==' text\n' and output['stderr']=='warning' @pytest.mark.parametrize('error,kind',[(subprocess.TimeoutExpired('claude',1),'timeout'),(FileNotFoundError(),'cli_not_found')]) def test_sync_failure(tmp_path,error,kind): translator=make_translator(tmp_path) with patch('src.pipeline.translator.subprocess.run',side_effect=error),pytest.raises(Exception): translator._call_claude('sys','user') assert next(r for r in records(tmp_path) if 'error' in r)['error']==kind @pytest.mark.asyncio async def test_async_capture_and_timeout_cleanup(tmp_path): translator=make_translator(tmp_path) process=Mock(returncode=0,communicate=AsyncMock(return_value=(b' raw\n',b''))) with patch('src.pipeline.translator.asyncio.create_subprocess_exec',new=AsyncMock(return_value=process)): assert await translator._call_claude_async('sys','user')=='raw' process=Mock(returncode=None,communicate=AsyncMock(side_effect=asyncio.TimeoutError()),wait=AsyncMock()) with patch('src.pipeline.translator.asyncio.create_subprocess_exec',new=AsyncMock(return_value=process)),pytest.raises(Exception): await translator._call_claude_async('sys','user') process.kill.assert_called_once();process.wait.assert_awaited_once() assert len(records(tmp_path))==4 def test_retries_keep_both_attempts(tmp_path): translator=make_translator(tmp_path) translator._retry_config=RetryConfig(max_attempts=2,base_delay=0,jitter=False) responses=[subprocess.CompletedProcess([],1,'','rate limit exceeded'),subprocess.CompletedProcess([],0,'success','')] with patch('src.pipeline.translator.subprocess.run',side_effect=responses): assert translator._call_claude('system','user')=='success' saved=records(tmp_path) assert len(saved)==4 inputs={row['id'] for row in saved if 'system_prompt' in row} outputs={row['id'] for row in saved if 'stdout' in row} assert inputs==outputs and len(inputs)==2 assert sorted(row['returncode'] for row in saved if 'stdout' in row)==[0,1]