$response->getCode(); final class CallbackContractResponse { public function __construct(private readonly int $status) { } public function getCode(): int { return $this->status; } } $validPayload = [ 'event_id' => 'evt-integration-1', 'pharmacy_order_no' => 'EJ-1001', 'source_order_no' => 'PO-1001', ]; $makeController = static function (string $body, EjPharmacyCallbackWorkflow $workflow): EjPharmacyCallbackController { return new class($body, $workflow) extends EjPharmacyCallbackController { public function __construct( private readonly string $testBody, private readonly EjPharmacyCallbackWorkflow $testWorkflow ) { } protected function callbackBody(): string { return $this->testBody; } protected function isAuthenticCallback(string $body): bool { return true; } protected function callbackWorkflow(): EjPharmacyCallbackWorkflow { return $this->testWorkflow; } protected function callbackResponse(array $payload, int $httpStatus) { return new CallbackContractResponse($httpStatus); } protected function logCallbackFailure(string $message): void { } }; }; $processedCalls = 0; $processedWorkflow = new EjPharmacyCallbackWorkflow( static fn (): array => ['id' => 1, 'process_status' => 'PROCESSED'], static fn (): array => [], static fn (): ?array => null, static function () use (&$processedCalls): array { ++$processedCalls; return ['process_status' => 'PROCESSED']; }, static function (): void {}, static fn (): bool => false ); $processedResponse = $makeController( json_encode($validPayload, JSON_THROW_ON_ERROR), $processedWorkflow )->webhook(); $assertSame(200, $responseStatus($processedResponse), 'processed callbacks must return HTTP 200'); $assertSame(0, $processedCalls, 'only a PROCESSED inbox may return immediate success'); $pendingCalls = 0; $pendingWorkflow = new EjPharmacyCallbackWorkflow( static fn (): array => ['id' => 2, 'process_status' => 'PENDING'], static fn (): array => [], static fn (): ?array => null, static function () use (&$pendingCalls): array { ++$pendingCalls; return ['process_status' => 'PROCESSED']; }, static function (): void {}, static fn (): bool => false ); $pendingResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $pendingWorkflow)->webhook(); $assertSame(200, $responseStatus($pendingResponse), 'a successfully retried PENDING inbox must return HTTP 200'); $assertSame(1, $pendingCalls, 'a PENDING inbox must retry business processing'); $failedCalls = 0; $failedWorkflow = new EjPharmacyCallbackWorkflow( static fn (): array => ['id' => 3, 'process_status' => 'FAILED'], static fn (): array => [], static fn (): ?array => null, static function () use (&$failedCalls): array { ++$failedCalls; return ['process_status' => 'PROCESSED']; }, static function (): void {}, static fn (): bool => false ); $failedResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $failedWorkflow)->webhook(); $assertSame(200, $responseStatus($failedResponse), 'a successfully retried FAILED inbox must return HTTP 200'); $assertSame(1, $failedCalls, 'a FAILED inbox must retry business processing'); $retryWorkflow = new EjPharmacyCallbackWorkflow( static fn (): array => ['id' => 4, 'process_status' => 'PENDING'], static fn (): array => [], static fn (): ?array => null, static function (): never { throw new EjPharmacyCallbackRetryException('业务订单关联尚未建立'); }, static function (): void {}, static fn (): bool => false ); $retryResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $retryWorkflow)->webhook(); $assertSame(503, $responseStatus($retryResponse), 'a missing order association must ask EJ to retry'); $runtimeWorkflow = new EjPharmacyCallbackWorkflow( static function (): never { throw new \RuntimeException('database unavailable'); }, static fn (): array => [], static fn (): ?array => null, static fn (): array => ['process_status' => 'PROCESSED'], static function (): void {}, static fn (): bool => false ); $runtimeResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $runtimeWorkflow)->webhook(); $assertSame(500, $responseStatus($runtimeResponse), 'database and unknown failures must return HTTP 500'); $unusedWorkflow = new EjPharmacyCallbackWorkflow( static fn (): ?array => null, static fn (): array => [], static fn (): ?array => null, static fn (): array => ['process_status' => 'PROCESSED'], static function (): void {}, static fn (): bool => false ); $malformedResponse = $makeController('{', $unusedWorkflow)->webhook(); $assertSame(400, $responseStatus($malformedResponse), 'malformed callback JSON must return HTTP 400'); $missingFieldResponse = $makeController( json_encode(['event_id' => 'evt-missing'], JSON_THROW_ON_ERROR), $unusedWorkflow )->webhook(); $assertSame(422, $responseStatus($missingFieldResponse), 'business-invalid callback JSON must return HTTP 422'); $duplicateReloads = 0; $duplicateProcesses = 0; $duplicateWorkflow = new EjPharmacyCallbackWorkflow( static fn (): ?array => null, static function (): never { throw new \RuntimeException('SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry'); }, static function () use (&$duplicateReloads): array { ++$duplicateReloads; return ['id' => 5, 'process_status' => 'PENDING']; }, static function () use (&$duplicateProcesses): array { ++$duplicateProcesses; return ['process_status' => 'PROCESSED']; }, static function (): void {}, static fn (\Throwable $exception): bool => str_contains($exception->getMessage(), '1062') ); $duplicateResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $duplicateWorkflow)->webhook(); $assertSame(200, $responseStatus($duplicateResponse), 'a duplicate inbox insert race must be reloaded and processed'); $assertSame(1, $duplicateReloads, 'a duplicate inbox insert must reload the winning row once'); $assertSame(1, $duplicateProcesses, 'a reloaded PENDING duplicate must be processed once'); $callbackRaceState = 'PENDING'; $callbackRaceUpdated = EjPharmacyCallbackFailureTransition::apply( 7, 'late failure after concurrent success', static function (int $inboxId, array $values, string $protectedStatus) use (&$callbackRaceState): bool { $callbackRaceState = 'PROCESSED'; if ($callbackRaceState === $protectedStatus) { return false; } $callbackRaceState = (string) $values['process_status']; return true; } ); $assertSame(false, $callbackRaceUpdated, 'failure CAS must report no update after concurrent callback success'); $assertSame('PROCESSED', $callbackRaceState, 'concurrent callback success must never be overwritten as FAILED'); $assertSame( 'SF', PharmacyLogisticsValue::normalize("\u{200B}\u{00A0}SF\u{3000}\u{FEFF}", 32, '快递公司'), 'logistics values must normalize Unicode edge whitespace' ); $assertSame( true, EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_SHIPPED', 'status' => 'PROCESSING']), 'ORDER_SHIPPED event type must trigger local shipment fulfillment' ); $assertSame( true, EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'SHIPPED']), 'SHIPPED remote status must trigger local shipment fulfillment' ); $assertSame( false, EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'PROCESSING']), 'non-shipment callbacks must not change local fulfillment' ); foreach ([1, 2] as $pendingFulfillment) { $assertSame( 5, EjPharmacyShipmentPolicy::nextFulfillmentStatus($pendingFulfillment, ['event_type' => 'ORDER_SHIPPED']), "shipment callback must advance fulfillment {$pendingFulfillment} to shipped" ); } foreach ([3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as $protectedFulfillment) { $assertSame( $protectedFulfillment, EjPharmacyShipmentPolicy::nextFulfillmentStatus($protectedFulfillment, ['status' => 'SHIPPED']), "shipment callback must not regress/overwrite protected fulfillment {$protectedFulfillment}" ); } $sameTrackingDecision = EjPharmacyTrackingPolicy::select( ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], null, 7, 'SF-OLD' ); $assertSame('REUSE_CURRENT', $sameTrackingDecision['action'], 'same-number callbacks must reuse the active order tracking'); $replacementDecision = EjPharmacyTrackingPolicy::select( ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], null, 7, 'SF-NEW' ); $assertSame('CREATE', $replacementDecision['action'], 'a replacement number must get a fresh tracking row'); $assertSame(true, $replacementDecision['archive_current'], 'replacing a number must archive the old active tracking'); $historicalDecision = EjPharmacyTrackingPolicy::select( ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-CURRENT'], ['id' => 8, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], 7, 'SF-OLD' ); $assertSame('REUSE_MATCHING', $historicalDecision['action'], 'a same-order historical number may be promoted without duplicating its traces'); try { EjPharmacyTrackingPolicy::select( ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], ['id' => 99, 'order_id' => 8, 'tracking_number' => 'SF-NEW'], 7, 'SF-NEW' ); throw new RuntimeException('cross-order tracking ownership conflict unexpectedly accepted'); } catch (DomainException $exception) { $assertSame(true, str_contains($exception->getMessage(), '其他订单'), 'cross-order tracking numbers must be rejected without rebinding'); } $overlongWorkflowCalls = 0; $overlongWorkflow = new EjPharmacyCallbackWorkflow( static function () use (&$overlongWorkflowCalls): array { ++$overlongWorkflowCalls; return ['id' => 6, 'process_status' => 'PENDING']; }, static fn (): array => [], static fn (): ?array => null, static fn (): array => ['process_status' => 'PROCESSED'], static function (): void {}, static fn (): bool => false ); $overlongResponse = $makeController( json_encode($validPayload + ['tracking_number' => str_repeat('运', 101)], JSON_THROW_ON_ERROR), $overlongWorkflow )->webhook(); $assertSame(422, $responseStatus($overlongResponse), 'overlong logistics values must return HTTP 422'); $assertSame(0, $overlongWorkflowCalls, 'logistics validation must finish before any inbox/workflow read or write'); $middleware = new AuthMiddleware(); $aliasMethod = new ReflectionMethod($middleware, 'matchPermissionAlias'); $assertSame( true, $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/uploadtopharmacy', ['tcm.prescriptionorder/submitgancaorecipel']), 'the historical permission must grant the unified upload URI' ); $assertSame( true, $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/submitgancaorecipel', ['tcm.prescriptionorder/uploadtopharmacy']), 'the unified permission must grant the historical upload URI' ); $assertSame( false, $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/export', ['tcm.prescriptionorder/uploadtopharmacy']), 'the upload alias must not expand to unrelated URIs' ); $assertSame( false, $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/confirmgancaosubmission', ['tcm.prescriptionorder/uploadtopharmacy']), 'ordinary pharmacy upload permission must not grant manual Gancao reconciliation' ); $controllerSource = (string) file_get_contents( dirname(__DIR__, 2) . '/app/api/controller/EjPharmacyCallbackController.php' ); $assertSame( 2, substr_count($controllerSource, 'PharmacyLogisticsValue::normalize('), 'carrier and tracking values must each be normalized exactly once before persistence' ); $assertSame( 0, substr_count($controllerSource, "(string) (\$payload['tracking_number']"), 'order, tracking, and trace writes must not consume the raw tracking number' ); $assertSame( true, strpos($controllerSource, '$this->normalizeLogistics($payload)') < strpos($controllerSource, '$this->callbackWorkflow()->handle($payload)'), 'callback logistics must be normalized before the workflow can write its inbox row' ); $assertSame( true, str_contains($controllerSource, 'EjPharmacyCallbackFailureTransition::apply(') && str_contains($controllerSource, "where('process_status', '<>', \$protectedStatus)"), 'callback failure persistence must use a conditional status CAS that protects PROCESSED' ); $versionGateStart = strpos($controllerSource, 'if ($incomingVersion >'); $versionGateEnd = strpos($controllerSource, '$inboxModel->save', $versionGateStart); $versionGatedSource = substr($controllerSource, $versionGateStart, $versionGateEnd - $versionGateStart); $assertSame( true, str_contains($versionGatedSource, 'EjPharmacyShipmentPolicy::nextFulfillmentStatus('), 'shipment fulfillment advancement must occur inside the callback version gate transaction' ); $assertSame( true, str_contains($versionGatedSource, 'self::syncLogistics('), 'ExpressTracking synchronization must remain inside the callback version gate' ); $assertSame( true, str_contains($versionGatedSource, 'ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder('), 'EJ shipment callbacks must reuse the existing shipped-order assistant release linkage' ); $assertSame( true, str_contains($controllerSource, "where('order_id', (int) \$order->id)") && str_contains($controllerSource, 'EjPharmacyTrackingPolicy::select(') && str_contains($controllerSource, "'order_type' => 'prescription_history'"), 'EJ callbacks must isolate replacement numbers and reject cross-order tracking ownership conflicts' ); echo "zyt pharmacy callback/auth integration tests passed: {$passed}\n";