|
|
@@ -0,0 +1,217 @@
|
|
|
+<?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>';
|
|
|
+ }
|
|
|
+}
|