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 'private record content'; } }