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

343 lines
14 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__, 2) . '/vendor/autoload.php';
use app\common\service\pharmacy\EjMedicineIncrementalPushService;
$passed = 0;
$assertSame = static function (mixed $expected, mixed $actual, string $message) use (&$passed): void {
if ($expected !== $actual) {
throw new RuntimeException($message . '\nExpected: ' . var_export($expected, true) . '\nActual: ' . var_export($actual, true));
}
++$passed;
};
$assertTrue = static function (bool $actual, string $message) use (&$passed): void {
if (!$actual) {
throw new RuntimeException($message);
}
++$passed;
};
$canonicalize = null;
$canonicalize = static function (mixed $value) use (&$canonicalize): mixed {
if (!is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map($canonicalize, $value);
}
ksort($value, SORT_STRING);
foreach ($value as $key => $child) {
$value[$key] = $canonicalize($child);
}
return $value;
};
$canonicalPayloadHash = static fn (array $payload): string => hash(
'sha256',
json_encode($canonicalize($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
$sourceRows = [
[
'id' => 101,
'name' => '增量药材甲',
'unit' => '克',
'settlement_price' => '1.100000',
'retail_price' => '1.200000',
'status' => 1,
],
[
'id' => 202,
'name' => '增量药材乙',
'unit' => '克',
'settlement_price' => '2.100000',
'retail_price' => '2.200000',
'status' => 1,
],
[
'id' => 303,
'name' => '人工停用映射药材',
'unit' => '克',
'settlement_price' => '3.100000',
'retail_price' => '3.200000',
'status' => 1,
],
];
EjMedicineIncrementalPushService::assertCommandGate(false, '', 100);
$blocked = false;
try {
EjMedicineIncrementalPushService::assertCommandGate(true, '', 100);
} catch (InvalidArgumentException $exception) {
$blocked = str_contains($exception->getMessage(), 'INCREMENTAL_NO_DELETE');
}
$assertTrue($blocked, 'apply mode must require the non-destructive confirmation token');
foreach ([0, 501] as $invalidBatchSize) {
try {
EjMedicineIncrementalPushService::assertCommandGate(false, '', $invalidBatchSize);
throw new RuntimeException('invalid incremental batch size unexpectedly accepted');
} catch (InvalidArgumentException $exception) {
$assertTrue(str_contains($exception->getMessage(), 'batch-size'), 'invalid batch size must return an actionable error');
}
}
$plan = EjMedicineIncrementalPushService::plan(
100,
static fn (): array => $sourceRows,
static fn (array $localIds): array => [
['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null],
['id' => 2, 'local_medicine_id' => 303, 'medicine_code' => 'EJ-303', 'status' => 0, 'delete_time' => 123],
]
);
$assertSame(3, $plan['source_count'], 'dry-run must count every active local medicine');
$assertSame(2, $plan['candidate_count'], 'dry-run must replay active mappings and add never-mapped medicines');
$assertSame(1, $plan['batch_count'], 'dry-run must report the remote batch count');
$assertSame(1, $plan['mapped_count'], 'dry-run must count existing active mappings');
$assertSame(1, $plan['unmapped_count'], 'dry-run must identify local medicines without an active mapping');
$assertSame(1, $plan['preserved_inactive'], 'dry-run must preserve manually disabled or unlinked mappings');
$assertSame(0, $plan['remote_delete_count'], 'incremental push must never plan remote deletes');
$assertSame(0, $plan['local_delete_count'], 'incremental push must never plan local projection deletes');
$capturedPayloads = [];
$capturedProjection = [];
$heldLockToken = '';
$lockAcquireCount = 0;
$lockRenewCount = 0;
$lockReleaseCount = 0;
$result = EjMedicineIncrementalPushService::execute(
1,
'zyt-inc-contract',
static fn (): array => $sourceRows,
static function (array $payload) use (&$capturedPayloads, $canonicalPayloadHash): array {
$capturedPayloads[] = $payload;
$sourceId = (string) $payload['items'][0]['source_medicine_id'];
$action = $sourceId === '101' ? 'existing' : 'created';
return [
'http_status' => $action === 'created' ? 201 : 200,
'body' => [
'code' => 0,
'message' => 'ok',
'data' => [
'source_system' => 'zyt',
'import_id' => $payload['import_id'],
'payload_hash' => $canonicalPayloadHash($payload),
'idempotent' => false,
'item_count' => 1,
'created_count' => $action === 'created' ? 1 : 0,
'existing_count' => $action === 'existing' ? 1 : 0,
'items' => [[
'source_medicine_id' => $sourceId,
'medicine_code' => 'EJ-' . $sourceId,
'catalog_version' => $sourceId === '101' ? 10 : 11,
'action' => $action,
]],
],
],
'request_id' => 'request-' . $sourceId,
];
},
static function (array $rows) use (&$capturedProjection): array {
$capturedProjection = $rows;
return [
'catalog_created' => 1,
'catalog_updated' => 1,
'mapping_created' => 1,
'mapping_updated' => 0,
'mapping_unchanged' => 1,
];
},
static fn (array $localIds): array => [
['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null],
['id' => 2, 'local_medicine_id' => 303, 'medicine_code' => 'EJ-303', 'status' => 0, 'delete_time' => 123],
],
static function (string $token) use (&$heldLockToken, &$lockAcquireCount): bool {
++$lockAcquireCount;
if ($heldLockToken !== '') {
return false;
}
$heldLockToken = $token;
return true;
},
static function (string $token) use (&$heldLockToken, &$lockRenewCount): bool {
++$lockRenewCount;
return $heldLockToken !== '' && hash_equals($heldLockToken, $token);
},
static function (string $token) use (&$heldLockToken, &$lockReleaseCount): void {
++$lockReleaseCount;
if ($heldLockToken !== '' && hash_equals($heldLockToken, $token)) {
$heldLockToken = '';
}
}
);
$assertSame(2, count($capturedPayloads), 'apply must send deterministic bounded batches');
$assertTrue(
str_starts_with((string) $capturedPayloads[0]['import_id'], 'zyt-inc-contract-0001-'),
'incremental import id must include the caller-provided run id and batch ordinal'
);
$assertSame(['101', '202'], array_column($capturedProjection, 'local_medicine_id'), 'projection upsert must retain source identity');
$assertSame(['EJ-101', 'EJ-202'], array_column($capturedProjection, 'medicine_code'), 'projection upsert must use EJ-returned codes');
$assertSame(1, $result['remote_created'], 'result must report remotely created medicines');
$assertSame(1, $result['remote_existing'], 'result must report idempotently existing medicines');
$assertSame(1, $result['preserved_inactive'], 'apply must not reactivate an operator-disabled mapping');
$assertSame(0, $result['remote_delete_count'], 'apply result must prove no EJ medicine was deleted');
$assertSame(0, $result['local_delete_count'], 'apply result must prove no ZYT projection was deleted');
$assertSame(1, $lockAcquireCount, 'apply must acquire one shared EJ synchronization lease');
$assertSame(5, $lockRenewCount, 'apply must renew its lease around every remote batch and before projection writes');
$assertSame(1, $lockReleaseCount, 'apply must release its synchronization lease');
$assertSame('', $heldLockToken, 'successful apply must not leave the synchronization lease held');
$candidateItems = array_merge($capturedPayloads[0]['items'], $capturedPayloads[1]['items']);
$defaultBatches = EjMedicineIncrementalPushService::buildBatches($candidateItems, 1, '');
$retryBatches = EjMedicineIncrementalPushService::buildBatches(array_reverse($candidateItems), 1, '');
$assertSame(
array_column($defaultBatches, 'import_id'),
array_column($retryBatches, 'import_id'),
'default incremental import ids must be deterministic for identical content'
);
$assertTrue(
str_starts_with((string) $defaultBatches[0]['import_id'], 'zyt-incremental-0001-'),
'default run id must be stable so an interrupted apply can safely retry'
);
$responseFor = static function (
array $payload,
string $medicineCode,
string $sourceSystem = 'zyt',
?string $responsePayloadHash = null
) use ($canonicalPayloadHash): array {
$sourceId = (string) $payload['items'][0]['source_medicine_id'];
return [
'http_status' => 200,
'body' => ['code' => 0, 'message' => 'ok', 'data' => [
'source_system' => $sourceSystem,
'import_id' => $payload['import_id'],
'payload_hash' => $responsePayloadHash ?? $canonicalPayloadHash($payload),
'idempotent' => true,
'item_count' => 1,
'created_count' => 0,
'existing_count' => 1,
'items' => [[
'source_medicine_id' => $sourceId,
'medicine_code' => $medicineCode,
'catalog_version' => 99,
'action' => 'existing',
]],
]],
'request_id' => 'validation-' . $sourceId,
];
};
$alwaysAcquire = static fn (string $token): bool => $token !== '';
$alwaysRenew = static fn (string $token): bool => $token !== '';
$releaseNoop = static function (string $token): void {};
foreach ([
'source_system' => static fn (array $payload): array => $responseFor($payload, 'EJ-202', 'other'),
'payload_hash' => static fn (array $payload): array => $responseFor($payload, 'EJ-202', 'zyt', str_repeat('0', 64)),
] as $field => $invalidImporter) {
$projectionCalled = false;
try {
EjMedicineIncrementalPushService::execute(
1,
'response-validation',
static fn (): array => [$sourceRows[1]],
$invalidImporter,
static function () use (&$projectionCalled): array {
$projectionCalled = true;
return [];
},
static fn (array $localIds): array => [],
$alwaysAcquire,
$alwaysRenew,
$releaseNoop
);
throw new RuntimeException("invalid {$field} unexpectedly accepted");
} catch (RuntimeException $exception) {
$assertTrue(str_contains($exception->getMessage(), $field), "incremental response must reject invalid {$field}");
}
$assertSame(false, $projectionCalled, "invalid {$field} must block local projection writes");
}
$projectionCalled = false;
try {
EjMedicineIncrementalPushService::execute(
1,
'mapping-mismatch',
static fn (): array => [$sourceRows[0]],
static fn (array $payload): array => $responseFor($payload, 'EJ-DIFFERENT'),
static function () use (&$projectionCalled): array {
$projectionCalled = true;
return [];
},
static fn (array $localIds): array => [
['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null],
],
$alwaysAcquire,
$alwaysRenew,
$releaseNoop
);
throw new RuntimeException('active mapping code mismatch unexpectedly accepted');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '不一致'), 'active mapping replay must reject a different EJ code');
}
$assertSame(false, $projectionCalled, 'active mapping mismatch must not overwrite its local projection');
$sourceLoaded = false;
$failedAcquireReleased = false;
try {
EjMedicineIncrementalPushService::execute(
1,
'concurrent-run',
static function () use (&$sourceLoaded): array {
$sourceLoaded = true;
return [];
},
null,
null,
null,
static fn (string $token): bool => false,
$alwaysRenew,
static function (string $token) use (&$failedAcquireReleased): void { $failedAcquireReleased = true; }
);
throw new RuntimeException('concurrent incremental run unexpectedly acquired the lease');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '正在执行'), 'concurrent apply must fail with a business error');
}
$assertSame(false, $sourceLoaded, 'concurrent apply must stop before reading or pushing source medicines');
$assertSame(false, $failedAcquireReleased, 'a failed lock acquisition must not release another run lease');
$leaseRenewals = 0;
$leaseFailureReleased = false;
$projectionCalled = false;
try {
EjMedicineIncrementalPushService::execute(
1,
'lease-loss',
static fn (): array => [$sourceRows[1]],
static fn (array $payload): array => $responseFor($payload, 'EJ-202'),
static function () use (&$projectionCalled): array {
$projectionCalled = true;
return [];
},
static fn (array $localIds): array => [],
$alwaysAcquire,
static function () use (&$leaseRenewals): bool {
++$leaseRenewals;
return $leaseRenewals === 1;
},
static function (string $token) use (&$leaseFailureReleased): void { $leaseFailureReleased = true; }
);
throw new RuntimeException('expired synchronization lease unexpectedly allowed projection');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '锁已失效'), 'lease loss must return an actionable retry error');
}
$assertSame(false, $projectionCalled, 'lease loss after a remote response must block local projection writes');
$assertSame(true, $leaseFailureReleased, 'lease loss must still release the caller-owned lock token');
$serviceSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineIncrementalPushService.php');
$assertTrue(!str_contains($serviceSource, '->delete('), 'incremental service must not call model delete');
$assertTrue(!str_contains($serviceSource, '->delete()'), 'incremental service must not call model delete without arguments');
echo "incremental EJ medicine push tests passed: {$passed}\n";