|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace Square\Tests\Integration; |
| 4 | + |
| 5 | +use PHPUnit\Framework\TestCase; |
| 6 | +use Square\Reporting\ReportingClient; |
| 7 | +use Square\Reporting\Requests\LoadRequest; |
| 8 | +use Square\Exceptions\SquareException; |
| 9 | +use Square\SquareClient; |
| 10 | +use Square\Types\LoadResponse; |
| 11 | +use Square\Utils\ReportingHelper; |
| 12 | + |
| 13 | +/** |
| 14 | + * The Reporting API answers a still-processing `/reporting/v1/load` query with an |
| 15 | + * HTTP 200 whose body is `{ "error": "Continue wait" }`. `ReportingHelper::loadAndWait` |
| 16 | + * owns the retry loop around that sentinel. |
| 17 | + * |
| 18 | + * These tests run fully offline: the `reporting` client is replaced with a stub |
| 19 | + * that pops a scripted sequence of responses. A "Continue wait" entry is fed as the |
| 20 | + * raw JSON body and deserialized through the *real* generated `LoadResponse::fromJson`, |
| 21 | + * so the sentinel is detected by exactly the same mechanism as in production — the |
| 22 | + * generated `LoadResponse` has only optional fields, so the body deserializes cleanly |
| 23 | + * and its unmapped `error` key survives as an additional property (with `data` null) — |
| 24 | + * rather than a hand-faked exception. Resolved entries are real `LoadResponse` objects. |
| 25 | + */ |
| 26 | +class ReportingHelperTest extends TestCase |
| 27 | +{ |
| 28 | + private const CONTINUE_WAIT_BODY = '{"error":"Continue wait"}'; |
| 29 | + |
| 30 | + /** Builds a minimal, fully-valid resolved `LoadResponse` with one data row. */ |
| 31 | + private static function resolvedResponse(): LoadResponse |
| 32 | + { |
| 33 | + return new LoadResponse([ |
| 34 | + 'data' => [['Orders.count' => '128']], |
| 35 | + ]); |
| 36 | + } |
| 37 | + |
| 38 | + /** |
| 39 | + * Builds a SquareClient whose `reporting->load` returns the next scripted entry |
| 40 | + * (the last entry repeats once exhausted), recording the call count. A string |
| 41 | + * entry is deserialized via the real `LoadResponse::fromJson`; a `LoadResponse` |
| 42 | + * entry is returned as-is. |
| 43 | + * |
| 44 | + * @param array<string|LoadResponse> $sequence One entry per expected `load` call. |
| 45 | + * @param int &$callCount Receives the number of `load` invocations. |
| 46 | + */ |
| 47 | + private function clientReturning(array $sequence, int &$callCount): SquareClient |
| 48 | + { |
| 49 | + $callCount = 0; |
| 50 | + $reporting = $this->getMockBuilder(ReportingClient::class) |
| 51 | + ->disableOriginalConstructor() |
| 52 | + ->onlyMethods(['load']) |
| 53 | + ->getMock(); |
| 54 | + $reporting->method('load')->willReturnCallback( |
| 55 | + function () use ($sequence, &$callCount): LoadResponse { |
| 56 | + $entry = $sequence[min($callCount, count($sequence) - 1)]; |
| 57 | + $callCount++; |
| 58 | + // Real deserialization for the sentinel: a "Continue wait" body keeps |
| 59 | + // its unmapped `error` as an additional property here, exactly as the |
| 60 | + // generated client does in production. |
| 61 | + return is_string($entry) ? LoadResponse::fromJson($entry) : $entry; |
| 62 | + } |
| 63 | + ); |
| 64 | + |
| 65 | + $client = new SquareClient('test-token'); |
| 66 | + $client->reporting = $reporting; |
| 67 | + return $client; |
| 68 | + } |
| 69 | + |
| 70 | + public function testPollsPastContinueWaitAndReturnsResolvedResult(): void |
| 71 | + { |
| 72 | + $callCount = 0; |
| 73 | + $client = $this->clientReturning( |
| 74 | + [self::CONTINUE_WAIT_BODY, self::CONTINUE_WAIT_BODY, self::resolvedResponse()], |
| 75 | + $callCount, |
| 76 | + ); |
| 77 | + |
| 78 | + $response = ReportingHelper::loadAndWait( |
| 79 | + $client, |
| 80 | + new LoadRequest(), |
| 81 | + ['initialDelayMs' => 1, 'maxDelayMs' => 1, 'maxAttempts' => 5], |
| 82 | + ); |
| 83 | + |
| 84 | + $this->assertNotNull($response->getData()); |
| 85 | + $this->assertArrayNotHasKey('error', $response->getAdditionalProperties()); |
| 86 | + $this->assertSame(3, $callCount); |
| 87 | + } |
| 88 | + |
| 89 | + public function testReturnsImmediatelyWhenFirstResponseHasResults(): void |
| 90 | + { |
| 91 | + $callCount = 0; |
| 92 | + $client = $this->clientReturning([self::resolvedResponse()], $callCount); |
| 93 | + |
| 94 | + $response = ReportingHelper::loadAndWait($client, new LoadRequest(), ['initialDelayMs' => 1]); |
| 95 | + |
| 96 | + $this->assertNotNull($response->getData()); |
| 97 | + $this->assertSame(1, $callCount); |
| 98 | + } |
| 99 | + |
| 100 | + public function testThrowsOnceMaxAttemptsExhausted(): void |
| 101 | + { |
| 102 | + $callCount = 0; |
| 103 | + $client = $this->clientReturning([self::CONTINUE_WAIT_BODY], $callCount); // never resolves |
| 104 | + |
| 105 | + try { |
| 106 | + ReportingHelper::loadAndWait( |
| 107 | + $client, |
| 108 | + new LoadRequest(), |
| 109 | + ['initialDelayMs' => 1, 'maxDelayMs' => 1, 'maxAttempts' => 3], |
| 110 | + ); |
| 111 | + $this->fail('Expected SquareException was not thrown.'); |
| 112 | + } catch (SquareException $e) { |
| 113 | + $this->assertStringContainsString('did not complete after 3 attempts', $e->getMessage()); |
| 114 | + } |
| 115 | + $this->assertSame(3, $callCount); |
| 116 | + } |
| 117 | + |
| 118 | + public function testCancellationAbortsPolling(): void |
| 119 | + { |
| 120 | + $callCount = 0; |
| 121 | + $client = $this->clientReturning([self::CONTINUE_WAIT_BODY], $callCount); // would otherwise poll |
| 122 | + |
| 123 | + $this->expectException(SquareException::class); |
| 124 | + $this->expectExceptionMessage('cancelled'); |
| 125 | + |
| 126 | + ReportingHelper::loadAndWait( |
| 127 | + $client, |
| 128 | + new LoadRequest(), |
| 129 | + ['maxAttempts' => 10, 'shouldCancel' => fn (): bool => true], |
| 130 | + ); |
| 131 | + } |
| 132 | + |
| 133 | + /** |
| 134 | + * The crux of the design: the generated `LoadResponse` has only optional fields, |
| 135 | + * so the "Continue wait" body deserializes cleanly, keeping its unmapped `error` |
| 136 | + * key as an additional property and leaving `data` null. That preserved |
| 137 | + * `error === "Continue wait"` is how `loadAndWait` recognizes the sentinel; if the |
| 138 | + * deserializer ever dropped it, the helper would mistake "Continue wait" for a result. |
| 139 | + */ |
| 140 | + public function testContinueWaitBodyPreservesErrorOnDeserialization(): void |
| 141 | + { |
| 142 | + $response = LoadResponse::fromJson(self::CONTINUE_WAIT_BODY); |
| 143 | + $this->assertSame('Continue wait', $response->getAdditionalProperties()['error'] ?? null); |
| 144 | + $this->assertNull($response->getData()); |
| 145 | + } |
| 146 | + |
| 147 | + /** A real (here, empty) result body deserializes cleanly, with no sentinel. */ |
| 148 | + public function testResolvedBodyDeserializesCleanly(): void |
| 149 | + { |
| 150 | + $resolved = LoadResponse::fromJson('{"data":[]}'); |
| 151 | + $this->assertSame([], $resolved->getData()); |
| 152 | + $this->assertArrayNotHasKey('error', $resolved->getAdditionalProperties()); |
| 153 | + } |
| 154 | +} |
0 commit comments