PlayNowRequestTimingTest.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. <?php
  2. namespace Tests\Unit;
  3. use App\Services\IssueService;
  4. use GuzzleHttp\Exception\ConnectException;
  5. use GuzzleHttp\Psr7\Response as PsrResponse;
  6. use GuzzleHttp\TransferStats;
  7. use Illuminate\Config\Repository;
  8. use Illuminate\Container\Container;
  9. use Illuminate\Http\Client\ConnectionException;
  10. use Illuminate\Http\Client\Factory;
  11. use Illuminate\Http\Client\Request;
  12. use Illuminate\Support\Facades\Facade;
  13. use Illuminate\Support\Facades\Http;
  14. use Mockery;
  15. use PHPUnit\Framework\TestCase;
  16. use ReflectionMethod;
  17. class PlayNowRequestTimingTest extends TestCase
  18. {
  19. private $previousContainer;
  20. private $previousFacadeApplication;
  21. private array $logs = [];
  22. protected function setUp(): void
  23. {
  24. parent::setUp();
  25. $this->previousContainer = Container::getInstance();
  26. $this->previousFacadeApplication = Facade::getFacadeApplication();
  27. $this->logs = [];
  28. $app = new Container();
  29. $app->instance('config', new Repository([
  30. 'services' => [
  31. 'playnow' => [
  32. 'keno_url' => 'https://draw.example.test/keno',
  33. 'proxy' => [
  34. 'scheme' => 'http',
  35. 'host' => 'proxy.example.test',
  36. 'port' => '3128',
  37. 'username' => 'timing-test-user',
  38. 'password' => 'timing-test-secret/@',
  39. ],
  40. ],
  41. ],
  42. ]));
  43. $app->instance(Factory::class, new Factory());
  44. $logger = Mockery::mock();
  45. $logger->shouldReceive('info')->andReturnUsing(function (string $message, array $context = []) {
  46. $this->logs[] = ['message' => $message, 'context' => $context];
  47. });
  48. $logManager = Mockery::mock();
  49. $logManager->shouldReceive('channel')->with('issue')->andReturn($logger);
  50. $app->instance('log', $logManager);
  51. Container::setInstance($app);
  52. Facade::clearResolvedInstances();
  53. Facade::setFacadeApplication($app);
  54. }
  55. protected function tearDown(): void
  56. {
  57. Mockery::close();
  58. Facade::clearResolvedInstances();
  59. Facade::setFacadeApplication($this->previousFacadeApplication);
  60. Container::setInstance($this->previousContainer);
  61. parent::tearDown();
  62. }
  63. public function test_success_logs_elapsed_time_and_cumulative_network_timings_without_proxy_credentials(): void
  64. {
  65. Http::fake(function (Request $request, array $options) {
  66. $this->assertRequestOptions($request, $options);
  67. $response = new PsrResponse(200, [], '{"drawNumber":123}');
  68. $options['on_stats'](new TransferStats($request->toPsrRequest(), $response, 1.8, 0, [
  69. 'namelookup_time' => 0.1,
  70. 'connect_time' => 0.4,
  71. 'appconnect_time' => 0.7,
  72. 'starttransfer_time' => 1.2,
  73. 'total_time' => 1.8,
  74. 'url' => $options['proxy'],
  75. ]));
  76. return Http::response(['drawNumber' => 123]);
  77. });
  78. $this->assertSame(['drawNumber' => 123], $this->fetch());
  79. $timing = $this->timingLog();
  80. $this->assertSame(200, $timing['http_status']);
  81. // These values are cumulative from request start, not per-phase differences.
  82. $this->assertSame([
  83. 'dns_ms' => 100.0,
  84. 'connect_ms' => 400.0,
  85. 'tls_ms' => 700.0,
  86. 'first_byte_ms' => 1200.0,
  87. 'total_ms' => 1800.0,
  88. 'curl_error_code' => 0,
  89. ], $timing['network_timings_ms']);
  90. $this->assertSafeTimingContext($timing);
  91. Http::assertSentCount(1);
  92. }
  93. public function test_connection_failure_still_logs_elapsed_time_partial_network_timings_and_error_code(): void
  94. {
  95. Http::fake(function (Request $request, array $options) {
  96. $this->assertRequestOptions($request, $options);
  97. $options['on_stats'](new TransferStats($request->toPsrRequest(), null, 25.0, 28, [
  98. 'namelookup_time' => 0.05,
  99. 'connect_time' => 0.06,
  100. 'total_time' => 25.0,
  101. 'url' => $options['proxy'],
  102. ]));
  103. throw new ConnectException('Synthetic connection timeout', $request->toPsrRequest());
  104. });
  105. try {
  106. $this->fetch();
  107. $this->fail('Expected the network error to propagate');
  108. } catch (ConnectionException $exception) {
  109. $this->assertSame('Synthetic connection timeout', $exception->getMessage());
  110. }
  111. $timing = $this->timingLog();
  112. $this->assertNull($timing['http_status']);
  113. $this->assertSame([
  114. 'dns_ms' => 50.0,
  115. 'connect_ms' => 60.0,
  116. 'total_ms' => 25000.0,
  117. 'curl_error_code' => 28,
  118. ], $timing['network_timings_ms']);
  119. $this->assertSafeTimingContext($timing);
  120. }
  121. public function test_http_failure_returns_null_and_still_logs_elapsed_time(): void
  122. {
  123. Http::fake(['*' => Http::response('Synthetic upstream failure', 503)]);
  124. $this->assertNull($this->fetch());
  125. $timing = $this->timingLog();
  126. $this->assertSame(503, $timing['http_status']);
  127. $this->assertSame([], $timing['network_timings_ms']);
  128. $this->assertSafeTimingContext($timing);
  129. Http::assertSentCount(1);
  130. }
  131. private function fetch()
  132. {
  133. $method = new ReflectionMethod(IssueService::class, 'fetchPlayNowKenoResult');
  134. $method->setAccessible(true);
  135. return $method->invoke(null);
  136. }
  137. private function assertRequestOptions(Request $request, array $options): void
  138. {
  139. $this->assertSame('https://draw.example.test/keno', $request->url());
  140. $this->assertSame(25, $options['timeout']);
  141. $this->assertSame(10, $options['connect_timeout']);
  142. $this->assertSame('http://timing-test-user:timing-test-secret%2F%40@proxy.example.test:3128', $options['proxy']);
  143. $this->assertIsCallable($options['on_stats']);
  144. }
  145. private function timingLog(): array
  146. {
  147. $timingLogs = array_values(array_filter($this->logs, fn (array $entry) => $entry['message'] === 'PlayNow请求耗时'));
  148. $this->assertCount(1, $timingLogs);
  149. return $timingLogs[0]['context'];
  150. }
  151. private function assertSafeTimingContext(array $context): void
  152. {
  153. $this->assertSame('draw.example.test', $context['target_host']);
  154. $this->assertIsFloat($context['elapsed_ms']);
  155. $this->assertGreaterThanOrEqual(0, $context['elapsed_ms']);
  156. $this->assertSame('configured', $context['proxy']);
  157. $this->assertSame('configured', $context['proxy_auth']);
  158. $serialized = json_encode($this->logs, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
  159. foreach (['timing-test-user', 'timing-test-secret', 'timing-test-secret%2F%40', 'proxy.example.test:3128'] as $sensitiveValue) {
  160. $this->assertStringNotContainsString($sensitiveValue, $serialized);
  161. }
  162. }
  163. }