Files
zyt/server/tests/pharmacy/callback_auth_integration.php
T

435 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require dirname(__DIR__, 2) . '/vendor/autoload.php';
use app\adminapi\http\middleware\AuthMiddleware;
use app\api\controller\EjPharmacyCallbackController;
use app\common\service\pharmacy\EjPharmacyCallbackRetryException;
use app\common\service\pharmacy\EjPharmacyCallbackFailureTransition;
use app\common\service\pharmacy\EjPharmacyCallbackWorkflow;
use app\common\service\pharmacy\PharmacyLogisticsValue;
use app\common\service\pharmacy\EjPharmacyShipmentPolicy;
use app\common\service\pharmacy\EjPharmacyTrackingPolicy;
$passed = 0;
$assertSame = static function (mixed $expected, mixed $actual, string $message) use (&$passed): void {
if ($expected !== $actual) {
throw new \RuntimeException(sprintf(
"%s\nExpected: %s\nActual: %s",
$message,
var_export($expected, true),
var_export($actual, true)
));
}
++$passed;
};
$responseStatus = static fn ($response): int => $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'
);
$assertSame(
true,
EjPharmacyShipmentPolicy::isCompletedEvent(['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]),
'a final EJ workflow callback must be recognized as order completion'
);
$assertSame(
3,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
2,
['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]
),
'a final EJ workflow callback must advance ZYT fulfillment to completed'
);
$assertSame(
2,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
2,
['event_type' => 'REVIEW_REJECTED', 'status' => 'REJECTED'],
2
),
'EJ review rejection must restore the fulfillment status captured before upload'
);
$assertSame(
2,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
2,
['event_type' => 'INVENTORY_SHORTAGE', 'status' => 'STOCK_SHORTAGE'],
2
),
'EJ inventory shortage must not become a ZYT customer refusal'
);
$assertSame(
2,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
9,
['event_type' => 'REVIEW_REJECTED', 'status' => 'REJECTED']
),
'legacy EJ rejection rows already marked as ZYT refusal must reopen to uploadable 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 ($versionAdvanced)');
$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'
);
$assertSame(
true,
str_contains($controllerSource, "\$log->action = 'ej_pharmacy_callback'")
&& str_contains($controllerSource, '操作人:')
&& str_contains($controllerSource, '版本已处理,保留回传日志'),
'every unique EJ callback must be written to the prescription order operation timeline with operator and version context'
);
$assertSame(
true,
str_contains($controllerSource, '订单药房流转制作中')
&& str_contains($controllerSource, '订单已完成')
&& str_contains($controllerSource, '流程:')
&& str_contains($controllerSource, '药房:洛阳药房')
&& str_contains($controllerSource, "implode(\$isWorkflowStep ? ' | ' : '', \$summaryParts)"),
'EJ workflow callback logs must use the same production-flow presentation as Gancao callbacks'
);
echo "zyt pharmacy callback/auth integration tests passed: {$passed}\n";