| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- <?php
- namespace Tests\Unit;
- use App\Services\LotteryImageService;
- use Illuminate\Config\Repository;
- use Illuminate\Container\Container;
- use Illuminate\Foundation\Application;
- use Illuminate\Support\Facades\Facade;
- use PHPUnit\Framework\TestCase;
- use Symfony\Component\Process\Exception\ProcessTimedOutException;
- class LotteryImageServiceTest extends TestCase
- {
- private Application $app;
- private string $temporaryRoot;
- private object $logger;
- private $previousContainer;
- private $previousFacadeApplication;
- protected function setUp(): void
- {
- parent::setUp();
- $this->previousContainer = Container::getInstance();
- $this->previousFacadeApplication = Facade::getFacadeApplication();
- Facade::clearResolvedInstances();
- $this->temporaryRoot = sys_get_temp_dir() . '/lottery image test ' . bin2hex(random_bytes(8));
- mkdir($this->temporaryRoot, 0700, true);
- $this->app = new Application(dirname(__DIR__, 2));
- $this->app->useStoragePath($this->temporaryRoot . '/storage');
- $this->app->instance('config', new Repository(['services' => ['lottery_image' => []]]));
- $this->app->instance('filesystem', new class {
- public function url(string $path): string
- {
- return 'https://media.example.test/storage/' . $path;
- }
- });
- $this->logger = new class {
- public array $entries = [];
- public function channel(string $name): self
- {
- if ($name !== 'issue') {
- throw new \RuntimeException('Unexpected log channel');
- }
- return $this;
- }
- public function info(string $message, array $context): void
- {
- $this->entries[] = ['level' => 'info', 'message' => $message, 'context' => $context];
- }
- public function error(string $message, array $context): void
- {
- $this->entries[] = ['level' => 'error', 'message' => $message, 'context' => $context];
- }
- };
- $this->app->instance('log', $this->logger);
- Facade::setFacadeApplication($this->app);
- }
- protected function tearDown(): void
- {
- Facade::clearResolvedInstances();
- Facade::setFacadeApplication($this->previousFacadeApplication);
- Container::setInstance($this->previousContainer);
- $files = new \RecursiveIteratorIterator(
- new \RecursiveDirectoryIterator($this->temporaryRoot, \FilesystemIterator::SKIP_DOTS),
- \RecursiveIteratorIterator::CHILD_FIRST
- );
- foreach ($files as $file) {
- $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
- }
- rmdir($this->temporaryRoot);
- parent::tearDown();
- }
- public function test_success_preserves_images_with_unique_paths_and_removes_temporary_html(): void
- {
- $binary = $this->fakeRenderer(<<<'SH'
- printf 'fake-png' > "$4"
- printf 'rendered'
- printf 'renderer diagnostic' >&2
- SH);
- $service = new FixtureLotteryImageService();
- $firstUrl = $service->generate([]);
- $secondUrl = $service->generate([]);
- $this->assertNotSame($firstUrl, $secondUrl);
- $this->assertStringStartsWith('/storage/lottery/lottery_', $firstUrl);
- $invocations = $this->invocations();
- $this->assertCount(2, $invocations);
- $this->assertNotSame($invocations[0][0], $invocations[1][0]);
- $this->assertNotSame($invocations[0][1], $invocations[1][1]);
- foreach ($invocations as [$inputPath, $outputPath]) {
- $this->assertStringEndsWith('.html', $inputPath);
- $this->assertFileDoesNotExist($inputPath);
- $this->assertSame('fake-png', file_get_contents($outputPath));
- }
- $this->assertCount(2, $this->logger->entries);
- foreach ($this->logger->entries as $entry) {
- $this->assertSame('info', $entry['level']);
- $this->assertSame(20.0, $entry['context']['timeout_seconds']);
- $this->assertGreaterThanOrEqual(0, $entry['context']['duration_seconds']);
- $this->assertSame(0, $entry['context']['exit_code']);
- $this->assertStringContainsString('renderer diagnostic', $entry['context']['renderer_output']);
- $this->assertStringContainsString('rendered', $entry['context']['renderer_output']);
- $this->assertStringNotContainsString($binary, json_encode($entry));
- $this->assertStringNotContainsString('private record content', json_encode($entry));
- }
- }
- public function test_nonzero_exit_reports_failure_and_removes_partial_image_and_html(): void
- {
- $this->fakeRenderer(<<<'SH'
- printf 'partial-png' > "$4"
- printf 'renderer failed' >&2
- exit 7
- SH);
- try {
- (new FixtureLotteryImageService())->generate([]);
- $this->fail('A failed renderer must throw');
- } catch (\RuntimeException $exception) {
- $this->assertStringContainsString('图片生成失败', $exception->getMessage());
- }
- $this->assertInvocationFilesRemoved();
- $entry = $this->logger->entries[0];
- $this->assertSame('error', $entry['level']);
- $this->assertSame(7, $entry['context']['exit_code']);
- $this->assertStringContainsString('renderer failed', $entry['context']['renderer_output']);
- }
- public function test_timeout_is_bounded_to_at_least_one_second_and_cleans_partial_files(): void
- {
- $this->fakeRenderer(<<<'SH'
- printf 'partial-png' > "$4"
- exec sleep 5
- SH);
- $this->app['config']->set('services.lottery_image.timeout', 0);
- $startedAt = microtime(true);
- try {
- (new FixtureLotteryImageService())->generate([]);
- $this->fail('A stalled renderer must time out');
- } catch (\RuntimeException $exception) {
- $this->assertStringContainsString('图片生成超时', $exception->getMessage());
- $this->assertInstanceOf(ProcessTimedOutException::class, $exception->getPrevious());
- }
- $duration = microtime(true) - $startedAt;
- $this->assertGreaterThanOrEqual(1, $duration);
- $this->assertLessThan(4, $duration);
- $this->assertInvocationFilesRemoved();
- $entry = $this->logger->entries[0];
- $this->assertSame('error', $entry['level']);
- $this->assertSame('timeout', $entry['context']['reason']);
- $this->assertSame(1.0, $entry['context']['timeout_seconds']);
- $this->assertGreaterThanOrEqual(1, $entry['context']['duration_seconds']);
- }
- public function test_success_exit_without_output_image_is_a_failure(): void
- {
- $this->fakeRenderer('exit 0');
- try {
- (new FixtureLotteryImageService())->generate([]);
- $this->fail('A renderer must create an image');
- } catch (\RuntimeException $exception) {
- $this->assertStringContainsString('图片生成失败', $exception->getMessage());
- }
- $this->assertInvocationFilesRemoved();
- $this->assertSame('error', $this->logger->entries[0]['level']);
- $this->assertSame(0, $this->logger->entries[0]['context']['exit_code']);
- }
- private function fakeRenderer(string $body): string
- {
- $binary = $this->temporaryRoot . '/fake renderer';
- $script = "#!/bin/sh\nset -eu\n"
- . 'test -s "$3"' . "\n"
- . 'printf \'%s\t%s\n\' "$3" "$4" >> ' . escapeshellarg($this->temporaryRoot . '/invocations.log') . "\n"
- . $body . "\n";
- file_put_contents($binary, $script);
- chmod($binary, 0700);
- $this->app['config']->set('services.lottery_image.binary', $binary);
- return $binary;
- }
- private function invocations(): array
- {
- $lines = file($this->temporaryRoot . '/invocations.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
- return array_map(fn (string $line): array => explode("\t", $line, 2), $lines);
- }
- private function assertInvocationFilesRemoved(): void
- {
- $invocations = $this->invocations();
- $this->assertCount(1, $invocations);
- foreach ($invocations[0] as $path) {
- $this->assertFileDoesNotExist($path);
- }
- }
- }
- class FixtureLotteryImageService extends LotteryImageService
- {
- protected function buildLocalizedHtml(array $records): string
- {
- return '<html><body>private record content</body></html>';
- }
- }
|