diff --git a/README.md b/README.md index a47b854..e9bcd3d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ quickpay callbacks:watch --to=http://127.0.0.1:8000/quickpay/callback Leave the watcher running while testing the payment flow. It signs and sends payment updates to your local callback handler. +With no selector, the watcher forwards new operation callbacks for every +payment changed after it becomes ready. Pass a payment ID or `--order-id` to +narrow the watch to one payment. Existing operations are not replayed. + ## Development ```bash diff --git a/SECURITY.md b/SECURITY.md index fe52fde..3d3e1e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -61,4 +61,9 @@ commands do not add a second confirmation prompt. Avoid destinations you do not control, since payment callbacks contain merchant and transaction data even after credential redaction. +Running `callbacks:watch` without a payment ID or `--order-id` watches the +account. It may forward data from any payment changed during that session, not +only the payment involved in the developer's current checkout flow. Use a +selector when the destination should receive data for only one payment. + Quickpay's hosted API, Manager, payment window, and merchant configuration are outside this project's control and should be reported to Quickpay through its official channels. diff --git a/app/Callbacks/Input/CallbackRequest.php b/app/Callbacks/Input/CallbackRequest.php index a53ffab..e600dd5 100644 --- a/app/Callbacks/Input/CallbackRequest.php +++ b/app/Callbacks/Input/CallbackRequest.php @@ -16,10 +16,24 @@ private function __construct( public static function from(mixed $payment, mixed $order, mixed $destination): self { + return self::create($payment, $order, $destination, selectorRequired: true); + } + + public static function forWatch(mixed $payment, mixed $order, mixed $destination): self + { + return self::create($payment, $order, $destination, selectorRequired: false); + } + + private static function create( + mixed $payment, + mixed $order, + mixed $destination, + bool $selectorRequired, + ): self { $hasPayment = $payment !== null && $payment !== ''; $hasOrder = is_string($order) && $order !== ''; - if (! $hasPayment && ! $hasOrder) { + if ($selectorRequired && ! $hasPayment && ! $hasOrder) { throw new InvalidArgumentException('Provide a payment ID or --order-id.'); } diff --git a/app/Callbacks/Resolution/PaymentLocator.php b/app/Callbacks/Resolution/PaymentLocator.php index 75199f0..26d7cbb 100644 --- a/app/Callbacks/Resolution/PaymentLocator.php +++ b/app/Callbacks/Resolution/PaymentLocator.php @@ -3,8 +3,13 @@ namespace App\Callbacks\Resolution; use App\Callbacks\Watching\CallbackPollingException; +use App\Quickpay\Pagination\LinkHeaderParser; +use App\Quickpay\Pagination\PaginationTargetCanonicalizer; use App\Quickpay\QuickpayClient; use App\Quickpay\QuickpayResponse; +use DateTimeImmutable; +use DateTimeZone; +use InvalidArgumentException; use UnexpectedValueException; /** @@ -19,6 +24,74 @@ { public function __construct(private QuickpayClient $quickpay) {} + /** @return array */ + public function changedBetween(DateTimeImmutable $minimum, DateTimeImmutable $maximum): array + { + $utc = new DateTimeZone('UTC'); + $query = [ + 'timestamp' => 'updated_at', + 'min_time' => $minimum->setTimezone($utc)->format('Y-m-d H:i:s O'), + 'max_time' => $maximum->setTimezone($utc)->format('Y-m-d H:i:s O'), + 'operations_size' => 0, + 'page_size' => 100, + ]; + $response = $this->changedPage('/payments', $query); + $pageCount = 1; + $seen = [PaginationTargetCanonicalizer::fromQuery('/payments', $query) => true]; + + $ids = []; + + while (true) { + foreach ($response->json as $payment) { + $id = is_array($payment) && ! array_is_list($payment) ? ($payment['id'] ?? null) : null; + + if ((! is_int($id) && ! is_string($id)) || (string) $id === '') { + throw new UnexpectedValueException('Quickpay returned a changed-payment row without a valid payment ID.'); + } + + $ids[(string) $id] = true; + } + + $next = LinkHeaderParser::next($response->header('Link')); + + if ($next === null) { + break; + } + + if ($pageCount >= 100) { + throw new InvalidArgumentException('Pagination exceeded the configured maximum of 100 pages.'); + } + + $canonicalNext = PaginationTargetCanonicalizer::canonical($next); + + if (isset($seen[$canonicalNext])) { + throw new InvalidArgumentException('Quickpay returned a pagination cycle.'); + } + + $seen[$canonicalNext] = true; + $response = $this->changedPage($next); + $pageCount++; + } + + return array_map($this->byId(...), array_keys($ids)); + } + + /** @param array $query */ + private function changedPage(string $path, array $query = []): QuickpayResponse + { + $response = $this->quickpay->get($path, $query); + + if (! $response->successful()) { + throw $this->pollingFailure($response); + } + + if (! is_array($response->json) || ! array_is_list($response->json)) { + throw new UnexpectedValueException('Quickpay returned a malformed changed-payment response.'); + } + + return $response; + } + public function byId(string $paymentId): QuickpayResponse { $response = $this->quickpay->get('/payments/'.rawurlencode($paymentId)); diff --git a/app/Callbacks/Watching/CallbackWatchRunner.php b/app/Callbacks/Watching/CallbackWatchRunner.php index bb6ba59..5d0cdbc 100644 --- a/app/Callbacks/Watching/CallbackWatchRunner.php +++ b/app/Callbacks/Watching/CallbackWatchRunner.php @@ -9,10 +9,12 @@ use App\Quickpay\Exceptions\QuickpayRequestException; use App\Quickpay\QuickpayResponse; use Closure; +use DateTimeImmutable; +use DateTimeZone; use UnexpectedValueException; /** - * Polls one payment and forwards callbacks for operations observed after start. + * Polls all payments or one selected payment for operations observed after start. * * The runner is deliberately in-memory: it is a foreground development tool, * not a durable webhook relay. Each detected operation receives its own queued @@ -26,15 +28,31 @@ final class CallbackWatchRunner implements CallbackWatcher private readonly Closure $continue; + private readonly Closure $clock; + + private readonly Closure $waitUntil; + public function __construct( private readonly PaymentLocator $locator, private readonly CallbackEnvelopeFactory $envelopes, private readonly CallbackForwarder $forwarder, ?callable $sleep = null, ?callable $continue = null, + ?callable $clock = null, + ?callable $waitUntil = null, ) { $this->sleep = Closure::fromCallable($sleep ?? sleep(...)); $this->continue = Closure::fromCallable($continue ?? static fn (): bool => true); + $this->clock = Closure::fromCallable( + $clock ?? static fn (): DateTimeImmutable => new DateTimeImmutable('now', new DateTimeZone('UTC')), + ); + $this->waitUntil = Closure::fromCallable($waitUntil ?? function (DateTimeImmutable $target): void { + $remaining = (float) $target->format('U.u') - (float) (($this->clock)())->format('U.u'); + + if ($remaining > 0) { + usleep((int) ceil($remaining * 1_000_000)); + } + }); } /** @@ -49,10 +67,16 @@ public function run( int $interval, Closure $observer, ): void { + if ($paymentId === null && $orderId === null) { + $this->runAccountWide($target, $apiKey, $privateKey, $interval, $observer); + + return; + } + $payment = $this->poll( $paymentId !== null ? fn (): QuickpayResponse => $this->locator->byId($paymentId) - : fn (): ?QuickpayResponse => $this->locator->byOrderId($orderId ?? ''), + : fn (): ?QuickpayResponse => $this->locator->byOrderId($orderId), $interval, $observer, ); @@ -71,7 +95,7 @@ public function run( if ($paymentId === null) { $payment = $this->poll( - fn (): ?QuickpayResponse => $this->locator->byOrderId($orderId ?? ''), + fn (): ?QuickpayResponse => $this->locator->byOrderId($orderId), $interval, $observer, ); @@ -90,10 +114,6 @@ public function run( ); } - if ($payment === null) { - continue; - } - $new = array_values(array_filter( $this->operations($payment->json), fn (array $operation): bool => ! isset($known[$operation['id']]), @@ -106,39 +126,125 @@ public function run( } foreach ($new as $operation) { - $envelope = $this->envelopes->make( + $this->deliverOperation( $payment, + $paymentId, + $operation['id'], $apiKey, $privateKey, - $operation['id'], + $target, + $interval, + $observer, ); + $known[$operation['id']] = true; + } + } + } - do { - $delivery = $this->forwarder->deliver($target, $envelope); + /** @param Closure(string, array): void $observer */ + private function runAccountWide( + CallbackTarget $target, + string $apiKey, + string $privateKey, + int $interval, + Closure $observer, + ): void { + $readiness = $this->nextWholeUtcSecond(($this->clock)()); + ($this->waitUntil)($readiness); + $observer('watching-all', ['ready_at' => $readiness->format(DateTimeImmutable::ATOM)]); + $watermark = $readiness; + $known = []; - if (! $delivery->successful) { - $observer('delivery-retry', [ - 'operation_id' => $operation['id'], - 'status' => $delivery->status, - ]); - ($this->sleep)($interval); - } - } while (! $delivery->successful); + while (($this->continue)()) { + ($this->sleep)($interval); + $windowEnd = $this->wholeUtcSecond(($this->clock)()); - $known[$operation['id']] = true; - $observer('delivered', [ - 'operation_id' => $operation['id'], + if ($windowEnd < $watermark) { + throw new UnexpectedValueException('The callback watcher clock moved before its payment watermark.'); + } + + $payments = $this->poll( + fn (): array => $this->locator->changedBetween($watermark->modify('-1 second'), $windowEnd), + $interval, + $observer, + ); + $watermark = $windowEnd; + + foreach ($payments as $payment) { + $paymentId = $this->paymentId($payment->json); + $operations = array_values(array_filter( + $this->operations($payment->json), + fn (array $operation): bool => $operation['created_at'] >= $readiness + && ! isset($known[$paymentId][$operation['id']]), + )); + + if (count($operations) > 1) { + $observer('multiple-operations', ['count' => count($operations)]); + } + + foreach ($operations as $operation) { + $this->deliverOperation( + $payment, + $paymentId, + $operation['id'], + $apiKey, + $privateKey, + $target, + $interval, + $observer, + ); + $known[$paymentId][$operation['id']] = true; + } + } + } + } + + /** @param Closure(string, array): void $observer */ + private function deliverOperation( + QuickpayResponse $payment, + string $paymentId, + string $operationId, + string $apiKey, + string $privateKey, + CallbackTarget $target, + int $interval, + Closure $observer, + ): void { + $envelope = $this->envelopes->make( + $payment, + $apiKey, + $privateKey, + $operationId, + ); + + do { + $delivery = $this->forwarder->deliver($target, $envelope); + + if (! $delivery->successful) { + $observer('delivery-retry', [ + 'payment_id' => $paymentId, + 'operation_id' => $operationId, 'status' => $delivery->status, ]); + ($this->sleep)($interval); } - } + } while (! $delivery->successful); + + $observer('delivered', [ + 'payment_id' => $paymentId, + 'operation_id' => $operationId, + 'status' => $delivery->status, + ]); } /** - * @param Closure(): (?QuickpayResponse) $request + * @template T + * + * @param Closure(): T $request * @param Closure(string, array): void $observer + * @return T */ - private function poll(Closure $request, int $interval, Closure $observer): ?QuickpayResponse + private function poll(Closure $request, int $interval, Closure $observer): mixed { while (true) { try { @@ -160,6 +266,22 @@ private function poll(Closure $request, int $interval, Closure $observer): ?Quic } } + private function nextWholeUtcSecond(DateTimeImmutable $time): DateTimeImmutable + { + return $this->wholeUtcSecond($time)->modify('+1 second'); + } + + private function wholeUtcSecond(DateTimeImmutable $time): DateTimeImmutable + { + $utc = $time->setTimezone(new DateTimeZone('UTC')); + + return $utc->setTime( + (int) $utc->format('H'), + (int) $utc->format('i'), + (int) $utc->format('s'), + ); + } + private function paymentId(mixed $payment): string { $id = is_array($payment) ? ($payment['id'] ?? null) : null; @@ -172,11 +294,11 @@ private function paymentId(mixed $payment): string } /** - * @return array + * @return array */ private function operations(mixed $payment): array { - $operations = is_array($payment) ? ($payment['operations'] ?? []) : null; + $operations = is_array($payment) ? ($payment['operations'] ?? null) : null; if (! is_array($operations) || ! array_is_list($operations)) { throw new UnexpectedValueException('Quickpay returned a payment with malformed operations.'); @@ -191,20 +313,39 @@ private function operations(mixed $payment): array return [ 'id' => (string) $id, - 'created_at' => isset($operation['created_at']) && is_scalar($operation['created_at']) - ? (string) $operation['created_at'] - : '', + 'created_at' => $this->operationTimestamp($operation['created_at'] ?? null), ]; }, $operations); usort($normalized, fn (array $left, array $right): int => [ - $left['created_at'], + (int) $left['created_at']->format('U'), + (int) $left['created_at']->format('u'), str_pad($left['id'], 20, '0', STR_PAD_LEFT), ] <=> [ - $right['created_at'], + (int) $right['created_at']->format('U'), + (int) $right['created_at']->format('u'), str_pad($right['id'], 20, '0', STR_PAD_LEFT), ]); return $normalized; } + + private function operationTimestamp(mixed $value): DateTimeImmutable + { + if (! is_string($value) + || preg_match('/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})\z/D', $value) !== 1) { + throw new UnexpectedValueException('Quickpay returned an operation without a valid timestamp.'); + } + + $format = str_contains($value, '.') ? '!Y-m-d\TH:i:s.uP' : '!Y-m-d\TH:i:sP'; + $timestamp = DateTimeImmutable::createFromFormat($format, $value); + $errors = DateTimeImmutable::getLastErrors(); + + if ($timestamp === false + || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + throw new UnexpectedValueException('Quickpay returned an operation without a valid timestamp.'); + } + + return $timestamp->setTimezone(new DateTimeZone('UTC')); + } } diff --git a/app/Commands/Callbacks/WatchCallbacksCommand.php b/app/Commands/Callbacks/WatchCallbacksCommand.php index 0b2ac9d..513d066 100644 --- a/app/Commands/Callbacks/WatchCallbacksCommand.php +++ b/app/Commands/Callbacks/WatchCallbacksCommand.php @@ -14,20 +14,19 @@ /** * Streams future payment changes as signed callbacks to a local endpoint. * - * Existing operations form a baseline so starting the command has no replay - * side effect. Selecting a not-yet-created order is different: every operation - * present when that payment first appears happened during the watch session and - * is therefore forwarded. + * Account-wide watching uses an explicit readiness timestamp, while scoped + * watches baseline a selected payment's existing operations. Neither mode + * replays operations that predate the session. */ class WatchCallbacksCommand extends AuthenticatedCommand { protected $signature = 'callbacks:watch - {payment-id? : Quickpay payment ID} + {payment-id? : Quickpay payment ID. Omit to watch all payments} {--order-id= : Wait for and watch one payment by its order ID} {--to= : HTTP or HTTPS callback destination} {--interval=2 : Poll and retry interval in seconds (1-60)}'; - protected $description = 'Watch a payment and stream signed Quickpay callbacks'; + protected $description = 'Watch payment operations and stream signed Quickpay callbacks'; public function handle( AuthenticatedQuickpayFactory $quickpay, @@ -36,7 +35,7 @@ public function handle( return $this->withQuickpay( $quickpay, function (AuthenticatedQuickpay $authenticated) use ($watch): int { - $request = CallbackRequest::from( + $request = CallbackRequest::forWatch( $this->argument('payment-id'), $this->option('order-id'), $this->option('to'), @@ -44,17 +43,19 @@ function (AuthenticatedQuickpay $authenticated) use ($watch): int { $interval = $this->interval(); $apiKey = $authenticated->apiKey->value(); - $this->info(ResponseBodySanitizer::terminalLine( - "Watching for Quickpay payment callbacks to {$request->target->url}. Press Ctrl-C to stop.", - $apiKey, - )); + if ($request->paymentId !== null || $request->orderId !== null) { + $this->info(ResponseBodySanitizer::terminalLine( + "Watching for Quickpay payment callbacks to {$request->target->url}. Press Ctrl-C to stop.", + $apiKey, + )); + } $watch->execute( $authenticated->client, $apiKey, $request, $interval, - function (string $event, array $context) use ($apiKey): void { - $this->writeWatchEvent($event, $context, $apiKey); + function (string $event, array $context) use ($apiKey, $request): void { + $this->writeWatchEvent($event, $context, $apiKey, $request->target->url); }, ); @@ -79,18 +80,29 @@ private function interval(): int } /** @param array $context */ - private function writeWatchEvent(string $event, array $context, string $apiKey): int + private function writeWatchEvent(string $event, array $context, string $apiKey, string $destination): int { $value = fn (string $key): string => ResponseBodySanitizer::terminalLine((string) ($context[$key] ?? '-'), $apiKey); + $safeDestination = ResponseBodySanitizer::terminalLine($destination, $apiKey); + $operation = isset($context['payment_id']) + ? "payment {$value('payment_id')} operation {$value('operation_id')}" + : "operation {$value('operation_id')}"; + + if ($event === 'watching-all') { + $this->info('Watching all Quickpay payment callbacks. Press Ctrl-C to stop.'); + $this->line("Ready at {$value('ready_at')}; forwarding to {$safeDestination}."); + + return self::SUCCESS; + } match ($event) { 'waiting-for-payment' => $this->line("Waiting for order {$value('order_id')} to create a payment."), 'watching' => $this->line("Payment {$value('payment_id')} ready; {$value('baseline_operations')} existing operation(s) baselined."), 'payment-found' => $this->line("Order {$value('order_id')} created payment {$value('payment_id')}."), 'multiple-operations' => $this->warn("Detected {$value('count')} operations in one poll; forwarding one callback per operation with the same latest payment snapshot."), - 'delivery-retry' => $this->warn("Callback for operation {$value('operation_id')} failed (HTTP {$value('status')}); retrying before later operations."), + 'delivery-retry' => $this->warn("Callback for {$operation} failed (HTTP {$value('status')}); retrying before later operations."), 'polling-retry' => $this->warn("Quickpay polling failed (HTTP {$value('status')}); retrying in {$value('delay')} second(s)."), - 'delivered' => $this->info("Delivered callback for operation {$value('operation_id')} with HTTP {$value('status')}."), + 'delivered' => $this->info("Delivered callback for {$operation} with HTTP {$value('status')}."), default => null, }; diff --git a/skills/quickpay/SKILL.md b/skills/quickpay/SKILL.md index 5abfed6..3825522 100644 --- a/skills/quickpay/SKILL.md +++ b/skills/quickpay/SKILL.md @@ -62,9 +62,14 @@ quickpay api [--query=key=value]... ## Local callback development -Use `callbacks:replay` to send the current payment resource once. Use -`callbacks:watch` as a foreground stream for operations that appear after the -watch starts. Provide exactly one selector: a payment ID or `--order-id`. +Use `callbacks:replay` with exactly one payment ID or `--order-id` selector to +send that payment's current resource once. + +Use selector-free `callbacks:watch --to=url` to watch every payment operation +created after the watcher announces that it is ready. This account-wide mode +may forward data from any payment changed during the session. Add one payment +ID or `--order-id` to narrow the watch to a single payment. Providing both +selectors is invalid. The `--to` URL is an outbound POST destination and may receive merchant or transaction data. Require the user to provide or explicitly approve the exact @@ -72,11 +77,12 @@ destination before running either command. Do not invent a public endpoint, silently start a tunnel, or substitute `--callback-url`: that option tells Quickpay's servers where to deliver and localhost is not reachable from them. -Watch has no JSON mode. It retries a failed captured callback before later -operations and runs until the user stops it with Ctrl-C. `QUICKPAY_PRIVATE_KEY` -is an optional sensitive environment override; never ask the user to paste it -into chat or place it in a command argument. Without it, the CLI retrieves the -key through the authenticated API and retains it only in memory. +Watch has no JSON mode and does not replay existing operations. It retries a +failed captured callback before later operations and runs until the user stops +it with Ctrl-C. `QUICKPAY_PRIVATE_KEY` is an optional sensitive environment +override; never ask the user to paste it into chat or place it in a command +argument. Without it, the CLI retrieves the key through the authenticated API +and retains it only in memory. ## Raw API guardrails diff --git a/tests/Feature/Callbacks/Resolution/PaymentLocatorTest.php b/tests/Feature/Callbacks/Resolution/PaymentLocatorTest.php index aa9b8b8..7f1a121 100644 --- a/tests/Feature/Callbacks/Resolution/PaymentLocatorTest.php +++ b/tests/Feature/Callbacks/Resolution/PaymentLocatorTest.php @@ -7,6 +7,168 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +it('scans changed payments with the exact updated-at window and fetches each full resource once', function () { + Http::fake([ + 'https://api.quickpay.net/payments?*' => Http::response([ + ['id' => 42], + ['id' => 42], + ['id' => 43], + ]), + 'https://api.quickpay.net/payments/42' => Http::response(['id' => 42, 'operations' => []]), + 'https://api.quickpay.net/payments/43' => Http::response(['id' => 43, 'operations' => []]), + ]); + + $locator = new PaymentLocator(new QuickpayClient(app(Factory::class), 'api-key')); + $payments = $locator->changedBetween( + new DateTimeImmutable('2026-08-07T10:00:00+02:00'), + new DateTimeImmutable('2026-08-07T10:00:05+02:00'), + ); + + expect(array_map(fn ($payment): mixed => $payment->json['id'], $payments))->toBe([42, 43]); + + Http::assertSent(function (Request $request): bool { + if (parse_url($request->url(), PHP_URL_PATH) !== '/payments') { + return false; + } + + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return $query === [ + 'timestamp' => 'updated_at', + 'min_time' => '2026-08-07 08:00:00 +0000', + 'max_time' => '2026-08-07 08:00:05 +0000', + 'operations_size' => '0', + 'page_size' => '100', + ]; + }); + + foreach (['42', '43'] as $paymentId) { + expect(Http::recorded(fn (Request $request): bool => $request->url() === "https://api.quickpay.net/payments/{$paymentId}")) + ->toHaveCount(1); + } +}); + +it('scans every changed-payment page and deduplicates payment ids before full fetches', function () { + Http::fake([ + 'https://api.quickpay.net/payments?timestamp=updated_at*' => Http::response( + [['id' => 42], ['id' => 42]], + 200, + ['Link' => '; rel="next"'], + ), + 'https://api.quickpay.net/payments?page=2&page_size=100' => Http::response([ + ['id' => 42], + ['id' => 43], + ]), + 'https://api.quickpay.net/payments/42' => Http::response(['id' => 42, 'operations' => []]), + 'https://api.quickpay.net/payments/43' => Http::response(['id' => 43, 'operations' => []]), + ]); + + $payments = paymentLocator()->changedBetween( + new DateTimeImmutable('2026-08-07T08:00:00Z'), + new DateTimeImmutable('2026-08-07T08:00:05Z'), + ); + + expect(array_map(fn ($payment): mixed => $payment->json['id'], $payments))->toBe([42, 43]); + Http::assertSentCount(4); +}); + +it('rejects malformed changed-payment rows', function (mixed $row) { + Http::fake(['https://api.quickpay.net/payments?*' => Http::response([$row])]); + + expect(fn () => paymentLocator()->changedBetween( + new DateTimeImmutable('2026-08-07T08:00:00Z'), + new DateTimeImmutable('2026-08-07T08:00:05Z'), + ))->toThrow(UnexpectedValueException::class, 'valid payment ID'); + + Http::assertSentCount(1); +})->with([ + 'scalar row' => ['42'], + 'list row' => [[42]], + 'missing id' => [['order_id' => 'order-42']], + 'empty id' => [['id' => '']], +]); + +it('rejects malformed changed-payment pages including later pages', function (mixed $page, bool $later) { + Http::fake($later ? [ + 'https://api.quickpay.net/payments?timestamp=updated_at*' => Http::response( + [], + 200, + ['Link' => '; rel="next"'], + ), + 'https://api.quickpay.net/payments?page=2' => Http::response($page), + ] : [ + 'https://api.quickpay.net/payments?*' => Http::response($page), + ]); + + expect(fn () => paymentLocator()->changedBetween( + new DateTimeImmutable('2026-08-07T08:00:00Z'), + new DateTimeImmutable('2026-08-07T08:00:05Z'), + ))->toThrow(UnexpectedValueException::class, 'malformed changed-payment response'); +})->with([ + 'object first page' => [['id' => 42], false], + 'scalar first page' => [42, false], + 'object second page' => [['id' => 42], true], +]); + +it('detects changed-payment pagination cycles before repeating a request', function () { + Http::fake([ + 'https://api.quickpay.net/payments?timestamp=updated_at*' => Http::response( + [], + 200, + ['Link' => '; rel="next"'], + ), + 'https://api.quickpay.net/payments?page=2' => Http::response( + [], + 200, + ['Link' => '; rel="next"'], + ), + ]); + + expect(fn () => paymentLocator()->changedBetween( + new DateTimeImmutable('2026-08-07T08:00:00Z'), + new DateTimeImmutable('2026-08-07T08:00:05Z'), + ))->toThrow(InvalidArgumentException::class, 'pagination cycle'); + + Http::assertSentCount(2); +}); + +it('rejects unsafe changed-payment pagination links before contacting their host', function () { + Http::fake(['*' => Http::response( + [], + 200, + ['Link' => '; rel="next"'], + )]); + + expect(fn () => paymentLocator()->changedBetween( + new DateTimeImmutable('2026-08-07T08:00:00Z'), + new DateTimeImmutable('2026-08-07T08:00:05Z'), + ))->toThrow(InvalidArgumentException::class, 'Quickpay API origin'); + + Http::assertSentCount(1); + Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), 'evil.test')); +}); + +it('stops changed-payment pagination before requesting page 101', function () { + Http::fake(function (Request $request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + $page = isset($query['page']) ? (int) $query['page'] : 1; + + return Http::response( + [], + 200, + ['Link' => '; rel="next"'], + ); + }); + + expect(fn () => paymentLocator()->changedBetween( + new DateTimeImmutable('2026-08-07T08:00:00Z'), + new DateTimeImmutable('2026-08-07T08:00:05Z'), + ))->toThrow(InvalidArgumentException::class, 'maximum of 100 pages'); + + Http::assertSentCount(100); + Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), 'page=101')); +}); + it('fetches a fixed payment by id', function () { Http::fake(['https://api.quickpay.net/payments/42' => Http::response([ 'id' => 42, @@ -86,3 +248,8 @@ 'missing fixed payment' => [404, false, null], 'authentication error' => [401, false, null], ]); + +function paymentLocator(): PaymentLocator +{ + return new PaymentLocator(new QuickpayClient(app(Factory::class), 'api-key')); +} diff --git a/tests/Feature/Callbacks/Watching/CallbackWatchRunnerTest.php b/tests/Feature/Callbacks/Watching/CallbackWatchRunnerTest.php index 939263a..3eb45cf 100644 --- a/tests/Feature/Callbacks/Watching/CallbackWatchRunnerTest.php +++ b/tests/Feature/Callbacks/Watching/CallbackWatchRunnerTest.php @@ -11,6 +11,353 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +it('starts account-wide watching at the next whole UTC second and scans an overlapping closed window', function () { + Http::fake(['https://api.quickpay.net/payments?*' => Http::response([])]); + $sleeps = []; + $waitedUntil = []; + $clockValues = [ + new DateTimeImmutable('2026-08-07T12:00:00.250000+02:00'), + new DateTimeImmutable('2026-08-07T10:00:03.900000Z'), + ]; + $events = []; + $runner = callbackWatchRunner( + sleeps: $sleeps, + polls: 1, + clock: function () use (&$clockValues): DateTimeImmutable { + return array_shift($clockValues); + }, + waitUntil: function (DateTimeImmutable $time) use (&$waitedUntil): void { + $waitedUntil[] = $time->format('Y-m-d\TH:i:s.uP'); + }, + ); + + $runner->run( + paymentId: null, + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: function (string $event, array $context) use (&$events): void { + $events[] = [$event, $context]; + }, + ); + + expect($waitedUntil)->toBe(['2026-08-07T10:00:01.000000+00:00']) + ->and($events)->toContain(['watching-all', ['ready_at' => '2026-08-07T10:00:01+00:00']]) + ->and($sleeps)->toBe([2]); + + Http::assertSent(function (Request $request): bool { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return $query['min_time'] === '2026-08-07 10:00:00 +0000' + && $query['max_time'] === '2026-08-07 10:00:03 +0000'; + }); +}); + +it('forwards only post-readiness operations across payments without colliding operation ids', function () { + Http::fake([ + 'https://api.quickpay.net/payments?*' => Http::response([['id' => 42], ['id' => 43]]), + 'https://api.quickpay.net/payments/42' => Http::response(watchPayment([ + watchOperation(2, 'capture', '2026-08-07T10:00:03Z'), + watchOperation(9, 'authorize', '2026-08-07T10:00:00Z'), + watchOperation(10, 'authorize', '2026-08-07T10:00:01Z'), + watchOperation(1, 'authorize', '2026-08-07T10:00:02Z'), + ])), + 'https://api.quickpay.net/payments/43' => Http::response([ + ...watchPayment([watchOperation(1, 'authorize', '2026-08-07T10:00:02Z')], 'order-43'), + 'id' => 43, + ]), + 'http://localhost/callback' => Http::sequence() + ->push('', 204) + ->push('', 204) + ->push('', 204) + ->push('', 204), + ]); + $sleeps = []; + $clockValues = [ + new DateTimeImmutable('2026-08-07T10:00:00.500000Z'), + new DateTimeImmutable('2026-08-07T10:00:04.500000Z'), + ]; + $events = []; + $runner = callbackWatchRunner( + sleeps: $sleeps, + polls: 1, + clock: function () use (&$clockValues): DateTimeImmutable { + return array_shift($clockValues); + }, + waitUntil: fn (DateTimeImmutable $time): null => null, + ); + + $runner->run( + paymentId: null, + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: function (string $event, array $context) use (&$events): void { + $events[] = [$event, $context]; + }, + ); + + $callbackPaymentIds = collect(Http::recorded()) + ->map(fn (array $record): Request => $record[0]) + ->filter(fn (Request $request): bool => $request->url() === 'http://localhost/callback') + ->map(fn (Request $request): mixed => json_decode($request->body(), true)['id']) + ->values() + ->all(); + $delivered = collect($events) + ->filter(fn (array $event): bool => $event[0] === 'delivered') + ->map(fn (array $event): array => [ + $event[1]['payment_id'], + $event[1]['operation_id'], + ]) + ->values() + ->all(); + + expect($callbackPaymentIds)->toBe([42, 42, 42, 43]) + ->and($delivered)->toBe([ + ['42', '10'], + ['42', '1'], + ['42', '2'], + ['43', '1'], + ]); +}); + +it('overlaps scan windows by one second without redelivering known payment operations', function () { + Http::fake([ + 'https://api.quickpay.net/payments?*' => Http::sequence() + ->push([['id' => 42]]) + ->push([['id' => 42]]), + 'https://api.quickpay.net/payments/42' => Http::sequence() + ->push(watchPayment([watchOperation(1, 'authorize', '2026-08-07T10:00:02Z')])) + ->push(watchPayment([ + watchOperation(1, 'authorize', '2026-08-07T10:00:02Z'), + watchOperation(2, 'capture', '2026-08-07T10:00:04Z'), + ])), + 'http://localhost/callback' => Http::sequence()->push('', 204)->push('', 204), + ]); + $sleeps = []; + $clockValues = [ + new DateTimeImmutable('2026-08-07T10:00:00.500000Z'), + new DateTimeImmutable('2026-08-07T10:00:03.500000Z'), + new DateTimeImmutable('2026-08-07T10:00:05.500000Z'), + ]; + $events = []; + $runner = callbackWatchRunner( + sleeps: $sleeps, + polls: 2, + clock: function () use (&$clockValues): DateTimeImmutable { + return array_shift($clockValues); + }, + waitUntil: fn (DateTimeImmutable $time): null => null, + ); + + $runner->run( + paymentId: null, + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: function (string $event, array $context) use (&$events): void { + $events[] = [$event, $context]; + }, + ); + + $delivered = collect($events) + ->filter(fn (array $event): bool => $event[0] === 'delivered') + ->pluck('1.operation_id') + ->values() + ->all(); + $windows = collect(Http::recorded()) + ->map(fn (array $record): Request => $record[0]) + ->filter(fn (Request $request): bool => parse_url($request->url(), PHP_URL_PATH) === '/payments') + ->map(function (Request $request): array { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return [$query['min_time'], $query['max_time']]; + }) + ->values() + ->all(); + + expect($delivered)->toBe(['1', '2']) + ->and($windows)->toBe([ + ['2026-08-07 10:00:00 +0000', '2026-08-07 10:00:03 +0000'], + ['2026-08-07 10:00:02 +0000', '2026-08-07 10:00:05 +0000'], + ]); +}); + +it('retries a failed account-wide scan without advancing its watermark', function () { + Http::fake([ + 'https://api.quickpay.net/payments?*' => Http::sequence() + ->push(['message' => 'slow down'], 429, ['Retry-After' => '7']) + ->push([]), + ]); + $sleeps = []; + $clockValues = [ + new DateTimeImmutable('2026-08-07T10:00:00.500000Z'), + new DateTimeImmutable('2026-08-07T10:00:03.500000Z'), + ]; + $events = []; + $runner = callbackWatchRunner( + sleeps: $sleeps, + polls: 1, + clock: function () use (&$clockValues): DateTimeImmutable { + return array_shift($clockValues); + }, + waitUntil: fn (DateTimeImmutable $time): null => null, + ); + + $runner->run( + paymentId: null, + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: function (string $event, array $context) use (&$events): void { + $events[] = [$event, $context]; + }, + ); + + $scanUrls = collect(Http::recorded()) + ->map(fn (array $record): Request => $record[0]) + ->filter(fn (Request $request): bool => parse_url($request->url(), PHP_URL_PATH) === '/payments') + ->pluck('url') + ->values() + ->all(); + + expect($scanUrls)->toHaveCount(2) + ->and($scanUrls[0])->toBe($scanUrls[1]) + ->and($sleeps)->toBe([2, 7]) + ->and($events)->toContain(['polling-retry', ['status' => 429, 'delay' => 7]]); +}); + +it('retries account-wide network and server failures against the same window', function () { + Http::fake([ + 'https://api.quickpay.net/payments?*' => Http::sequence() + ->pushFailedConnection() + ->push(['message' => 'temporarily unavailable'], 503) + ->push([]), + ]); + $sleeps = []; + $clockValues = [ + new DateTimeImmutable('2026-08-07T10:00:00.500000Z'), + new DateTimeImmutable('2026-08-07T10:00:03.500000Z'), + ]; + $events = []; + $runner = callbackWatchRunner( + sleeps: $sleeps, + polls: 1, + clock: function () use (&$clockValues): DateTimeImmutable { + return array_shift($clockValues); + }, + waitUntil: fn (DateTimeImmutable $time): null => null, + ); + + $runner->run( + paymentId: null, + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: function (string $event, array $context) use (&$events): void { + $events[] = [$event, $context]; + }, + ); + + $scanUrls = collect(Http::recorded()) + ->map(fn (array $record): Request => $record[0]) + ->filter(fn (Request $request): bool => parse_url($request->url(), PHP_URL_PATH) === '/payments') + ->pluck('url') + ->values() + ->all(); + + expect($scanUrls)->toHaveCount(3) + ->and(array_unique($scanUrls))->toHaveCount(1) + ->and($sleeps)->toBe([2, 2, 2]) + ->and($events)->toContain( + ['polling-retry', ['status' => null, 'delay' => 2]], + ['polling-retry', ['status' => 503, 'delay' => 2]], + ); +}); + +it('treats invalid operation timestamps as fatal during account-wide watching', function (mixed $timestamp) { + Http::fake([ + 'https://api.quickpay.net/payments?*' => Http::response([['id' => 42]]), + 'https://api.quickpay.net/payments/42' => Http::response(watchPayment([ + ['id' => 1, 'type' => 'authorize', 'created_at' => $timestamp], + ])), + ]); + $sleeps = []; + $clockValues = [ + new DateTimeImmutable('2026-08-07T10:00:00.500000Z'), + new DateTimeImmutable('2026-08-07T10:00:03.500000Z'), + ]; + $runner = callbackWatchRunner( + sleeps: $sleeps, + polls: 1, + clock: function () use (&$clockValues): DateTimeImmutable { + return array_shift($clockValues); + }, + waitUntil: fn (DateTimeImmutable $time): null => null, + ); + + expect(fn () => $runner->run( + paymentId: null, + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: fn (string $event, array $context): null => null, + ))->toThrow(UnexpectedValueException::class, 'valid timestamp'); + + Http::assertNotSent(fn (Request $request): bool => $request->url() === 'http://localhost/callback'); +})->with([ + 'missing' => [null], + 'not ISO-8601' => ['2026-08-07 10:00:02'], + 'impossible date' => ['2026-02-30T10:00:02Z'], + 'non-string' => [42], +]); + +it('treats a changed payment without an operations list as fatal', function () { + Http::fake([ + 'https://api.quickpay.net/payments?*' => Http::response([['id' => 42]]), + 'https://api.quickpay.net/payments/42' => Http::response([ + 'id' => 42, + 'order_id' => 'order-42', + 'merchant_id' => 123, + ]), + ]); + $sleeps = []; + $clockValues = [ + new DateTimeImmutable('2026-08-07T10:00:00.500000Z'), + new DateTimeImmutable('2026-08-07T10:00:03.500000Z'), + ]; + $runner = callbackWatchRunner( + sleeps: $sleeps, + polls: 1, + clock: function () use (&$clockValues): DateTimeImmutable { + return array_shift($clockValues); + }, + waitUntil: fn (DateTimeImmutable $time): null => null, + ); + + expect(fn () => $runner->run( + paymentId: null, + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: fn (string $event, array $context): null => null, + ))->toThrow(UnexpectedValueException::class, 'malformed operations'); +}); + it('baselines existing operations and forwards every later operation in order', function () { $paymentResponses = Http::sequence() ->push(watchPayment([watchOperation(1, 'authorize', '2026-07-25T10:00:00Z')])) @@ -51,6 +398,41 @@ ->and($sleeps)->toBe([2]); }); +it('orders operations chronologically when decimal timestamps lose float precision', function () { + Http::fake([ + 'https://api.quickpay.net/payments/42' => Http::sequence() + ->push(watchPayment([])) + ->push(watchPayment([ + watchOperation(1, 'capture', '9999-12-31T00:00:00.000002Z'), + watchOperation(2, 'authorize', '9999-12-31T00:00:00.000001Z'), + ])), + 'http://localhost/callback' => Http::sequence()->push('', 204)->push('', 204), + ]); + $events = []; + $sleeps = []; + $runner = callbackWatchRunner($sleeps, 1); + + $runner->run( + paymentId: '42', + orderId: null, + target: CallbackTarget::fromString('http://localhost/callback'), + apiKey: 'api-key', + privateKey: 'private-key', + interval: 2, + observer: function (string $event, array $context) use (&$events): void { + $events[] = [$event, $context]; + }, + ); + + $delivered = collect($events) + ->filter(fn (array $event): bool => $event[0] === 'delivered') + ->pluck('1.operation_id') + ->values() + ->all(); + + expect($delivered)->toBe(['2', '1']); +}); + it('treats all operations as new when an order appears after watching starts', function () { Http::fake([ 'https://api.quickpay.net/payments?*' => Http::sequence() @@ -177,8 +559,12 @@ function watchOperation(int $id, string $type, string $createdAt): array } /** @param array $sleeps */ -function callbackWatchRunner(array &$sleeps, int $polls): CallbackWatchRunner -{ +function callbackWatchRunner( + array &$sleeps, + int $polls, + ?callable $clock = null, + ?callable $waitUntil = null, +): CallbackWatchRunner { $remaining = $polls; return new CallbackWatchRunner( @@ -191,5 +577,7 @@ function callbackWatchRunner(array &$sleeps, int $polls): CallbackWatchRunner continue: function () use (&$remaining): bool { return $remaining-- > 0; }, + clock: $clock, + waitUntil: $waitUntil, ); } diff --git a/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php b/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php index 5745881..2cc16e2 100644 --- a/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php +++ b/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php @@ -38,7 +38,6 @@ Http::assertNothingSent(); })->with([ - 'no selector' => [['--to' => 'http://localhost/callback'], 'payment ID or --order-id'], 'both selectors' => [[ 'payment-id' => '42', '--order-id' => 'order-42', @@ -51,6 +50,45 @@ 'fractional interval' => [['payment-id' => '42', '--to' => 'http://localhost/callback', '--interval' => '1.5'], '1 through 60'], ]); +it('accepts an account-wide watch without a payment selector', function () { + app()->instance(CallbackWatcherFactory::class, new class extends CallbackWatcherFactory + { + public function make(QuickpayClient $quickpay, Factory $http): CallbackWatcher + { + return new class implements CallbackWatcher + { + public function run( + ?string $paymentId, + ?string $orderId, + CallbackTarget $target, + string $apiKey, + string $privateKey, + int $interval, + Closure $observer, + ): void { + expect($paymentId)->toBeNull() + ->and($orderId)->toBeNull() + ->and($target->url)->toBe('http://localhost/callback'); + + $observer('watching-all', ['ready_at' => '2026-08-07T10:00:01+00:00']); + $observer('delivered', ['payment_id' => '42', 'operation_id' => '1', 'status' => 204]); + $observer('delivered', ['payment_id' => '43', 'operation_id' => '1', 'status' => 204]); + } + }; + } + }); + Http::fake(); + + $this->artisan('callbacks:watch', ['--to' => 'http://localhost/callback']) + ->expectsOutputToContain('Watching all Quickpay payment callbacks') + ->expectsOutputToContain('2026-08-07T10:00:01+00:00') + ->expectsOutputToContain('payment 42 operation 1') + ->expectsOutputToContain('payment 43 operation 1') + ->assertExitCode(0); + + Http::assertNothingSent(); +}); + it('documents foreground streaming behavior without offering json mode', function () { $command = new WatchCallbacksCommand; $definition = $command->getDefinition(); @@ -58,6 +96,7 @@ expect($definition->hasOption('interval'))->toBeTrue() ->and($definition->getOption('interval')->getDefault())->toBe('2') ->and($definition->hasOption('json'))->toBeFalse() + ->and($definition->getArgument('payment-id')->getDescription())->toContain('Omit to watch all payments') ->and($command->getDescription())->toContain('Watch'); });