LotteryImageServiceTest.php 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. <?php
  2. namespace Tests\Unit;
  3. use App\Services\LotteryImageService;
  4. use Illuminate\Config\Repository;
  5. use Illuminate\Container\Container;
  6. use Illuminate\Foundation\Application;
  7. use Illuminate\Support\Facades\Facade;
  8. use PHPUnit\Framework\TestCase;
  9. use Symfony\Component\Process\Exception\ProcessTimedOutException;
  10. class LotteryImageServiceTest extends TestCase
  11. {
  12. private Application $app;
  13. private string $temporaryRoot;
  14. private object $logger;
  15. private $previousContainer;
  16. private $previousFacadeApplication;
  17. protected function setUp(): void
  18. {
  19. parent::setUp();
  20. $this->previousContainer = Container::getInstance();
  21. $this->previousFacadeApplication = Facade::getFacadeApplication();
  22. Facade::clearResolvedInstances();
  23. $this->temporaryRoot = sys_get_temp_dir() . '/lottery image test ' . bin2hex(random_bytes(8));
  24. mkdir($this->temporaryRoot, 0700, true);
  25. $this->app = new Application(dirname(__DIR__, 2));
  26. $this->app->useStoragePath($this->temporaryRoot . '/storage');
  27. $this->app->instance('config', new Repository(['services' => ['lottery_image' => []]]));
  28. $this->app->instance('filesystem', new class {
  29. public function url(string $path): string
  30. {
  31. return 'https://media.example.test/storage/' . $path;
  32. }
  33. });
  34. $this->logger = new class {
  35. public array $entries = [];
  36. public function channel(string $name): self
  37. {
  38. if ($name !== 'issue') {
  39. throw new \RuntimeException('Unexpected log channel');
  40. }
  41. return $this;
  42. }
  43. public function info(string $message, array $context): void
  44. {
  45. $this->entries[] = ['level' => 'info', 'message' => $message, 'context' => $context];
  46. }
  47. public function error(string $message, array $context): void
  48. {
  49. $this->entries[] = ['level' => 'error', 'message' => $message, 'context' => $context];
  50. }
  51. };
  52. $this->app->instance('log', $this->logger);
  53. Facade::setFacadeApplication($this->app);
  54. }
  55. protected function tearDown(): void
  56. {
  57. Facade::clearResolvedInstances();
  58. Facade::setFacadeApplication($this->previousFacadeApplication);
  59. Container::setInstance($this->previousContainer);
  60. $files = new \RecursiveIteratorIterator(
  61. new \RecursiveDirectoryIterator($this->temporaryRoot, \FilesystemIterator::SKIP_DOTS),
  62. \RecursiveIteratorIterator::CHILD_FIRST
  63. );
  64. foreach ($files as $file) {
  65. $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
  66. }
  67. rmdir($this->temporaryRoot);
  68. parent::tearDown();
  69. }
  70. public function test_success_preserves_images_with_unique_paths_and_removes_temporary_html(): void
  71. {
  72. $binary = $this->fakeRenderer(<<<'SH'
  73. printf 'fake-png' > "$4"
  74. printf 'rendered'
  75. printf 'renderer diagnostic' >&2
  76. SH);
  77. $service = new FixtureLotteryImageService();
  78. $firstUrl = $service->generate([]);
  79. $secondUrl = $service->generate([]);
  80. $this->assertNotSame($firstUrl, $secondUrl);
  81. $this->assertStringStartsWith('/storage/lottery/lottery_', $firstUrl);
  82. $invocations = $this->invocations();
  83. $this->assertCount(2, $invocations);
  84. $this->assertNotSame($invocations[0][0], $invocations[1][0]);
  85. $this->assertNotSame($invocations[0][1], $invocations[1][1]);
  86. foreach ($invocations as [$inputPath, $outputPath]) {
  87. $this->assertStringEndsWith('.html', $inputPath);
  88. $this->assertFileDoesNotExist($inputPath);
  89. $this->assertSame('fake-png', file_get_contents($outputPath));
  90. }
  91. $this->assertCount(2, $this->logger->entries);
  92. foreach ($this->logger->entries as $entry) {
  93. $this->assertSame('info', $entry['level']);
  94. $this->assertSame(20.0, $entry['context']['timeout_seconds']);
  95. $this->assertGreaterThanOrEqual(0, $entry['context']['duration_seconds']);
  96. $this->assertSame(0, $entry['context']['exit_code']);
  97. $this->assertStringContainsString('renderer diagnostic', $entry['context']['renderer_output']);
  98. $this->assertStringContainsString('rendered', $entry['context']['renderer_output']);
  99. $this->assertStringNotContainsString($binary, json_encode($entry));
  100. $this->assertStringNotContainsString('private record content', json_encode($entry));
  101. }
  102. }
  103. public function test_nonzero_exit_reports_failure_and_removes_partial_image_and_html(): void
  104. {
  105. $this->fakeRenderer(<<<'SH'
  106. printf 'partial-png' > "$4"
  107. printf 'renderer failed' >&2
  108. exit 7
  109. SH);
  110. try {
  111. (new FixtureLotteryImageService())->generate([]);
  112. $this->fail('A failed renderer must throw');
  113. } catch (\RuntimeException $exception) {
  114. $this->assertStringContainsString('图片生成失败', $exception->getMessage());
  115. }
  116. $this->assertInvocationFilesRemoved();
  117. $entry = $this->logger->entries[0];
  118. $this->assertSame('error', $entry['level']);
  119. $this->assertSame(7, $entry['context']['exit_code']);
  120. $this->assertStringContainsString('renderer failed', $entry['context']['renderer_output']);
  121. }
  122. public function test_timeout_is_bounded_to_at_least_one_second_and_cleans_partial_files(): void
  123. {
  124. $this->fakeRenderer(<<<'SH'
  125. printf 'partial-png' > "$4"
  126. exec sleep 5
  127. SH);
  128. $this->app['config']->set('services.lottery_image.timeout', 0);
  129. $startedAt = microtime(true);
  130. try {
  131. (new FixtureLotteryImageService())->generate([]);
  132. $this->fail('A stalled renderer must time out');
  133. } catch (\RuntimeException $exception) {
  134. $this->assertStringContainsString('图片生成超时', $exception->getMessage());
  135. $this->assertInstanceOf(ProcessTimedOutException::class, $exception->getPrevious());
  136. }
  137. $duration = microtime(true) - $startedAt;
  138. $this->assertGreaterThanOrEqual(1, $duration);
  139. $this->assertLessThan(4, $duration);
  140. $this->assertInvocationFilesRemoved();
  141. $entry = $this->logger->entries[0];
  142. $this->assertSame('error', $entry['level']);
  143. $this->assertSame('timeout', $entry['context']['reason']);
  144. $this->assertSame(1.0, $entry['context']['timeout_seconds']);
  145. $this->assertGreaterThanOrEqual(1, $entry['context']['duration_seconds']);
  146. }
  147. public function test_success_exit_without_output_image_is_a_failure(): void
  148. {
  149. $this->fakeRenderer('exit 0');
  150. try {
  151. (new FixtureLotteryImageService())->generate([]);
  152. $this->fail('A renderer must create an image');
  153. } catch (\RuntimeException $exception) {
  154. $this->assertStringContainsString('图片生成失败', $exception->getMessage());
  155. }
  156. $this->assertInvocationFilesRemoved();
  157. $this->assertSame('error', $this->logger->entries[0]['level']);
  158. $this->assertSame(0, $this->logger->entries[0]['context']['exit_code']);
  159. }
  160. private function fakeRenderer(string $body): string
  161. {
  162. $binary = $this->temporaryRoot . '/fake renderer';
  163. $script = "#!/bin/sh\nset -eu\n"
  164. . 'test -s "$3"' . "\n"
  165. . 'printf \'%s\t%s\n\' "$3" "$4" >> ' . escapeshellarg($this->temporaryRoot . '/invocations.log') . "\n"
  166. . $body . "\n";
  167. file_put_contents($binary, $script);
  168. chmod($binary, 0700);
  169. $this->app['config']->set('services.lottery_image.binary', $binary);
  170. return $binary;
  171. }
  172. private function invocations(): array
  173. {
  174. $lines = file($this->temporaryRoot . '/invocations.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
  175. return array_map(fn (string $line): array => explode("\t", $line, 2), $lines);
  176. }
  177. private function assertInvocationFilesRemoved(): void
  178. {
  179. $invocations = $this->invocations();
  180. $this->assertCount(1, $invocations);
  181. foreach ($invocations[0] as $path) {
  182. $this->assertFileDoesNotExist($path);
  183. }
  184. }
  185. }
  186. class FixtureLotteryImageService extends LotteryImageService
  187. {
  188. protected function buildLocalizedHtml(array $records): string
  189. {
  190. return '<html><body>private record content</body></html>';
  191. }
  192. }