Files
zyt/server/tests/QywxPromotionAutomationServiceTest.php
T
2026-08-31 15:17:34 +08:00

193 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use app\common\service\qywx\QywxPromotionAutomationService;
use app\common\service\qywx\QywxPromotionAutomationStore;
use app\common\service\qywx\QywxPromotionCodeCipher;
use app\common\service\qywx\QywxPromotionConfig;
use app\common\service\qywx\QywxPromotionContactApiException;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionEnqueueException;
use app\common\service\qywx\QywxPromotionMediaService;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
new think\App();
function automationCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
final class AutomationApiFake extends QywxPromotionContactApiService
{
public array $calls = [];
public ?QywxPromotionContactApiException $welcomeError = null;
public bool $tagsFail = false;
public function __construct() {}
public function sendWelcome(string $code, string $text, array $attachments): void {
$this->calls[] = ['welcome', $text, $attachments];
if ($this->welcomeError) { $e = $this->welcomeError; $this->welcomeError = null; throw $e; }
}
public function markTags(string $userId, string $externalUserId, array $tagIds): void {
$this->calls[] = ['tags', $userId, $externalUserId, $tagIds];
if ($this->tagsFail) { $this->tagsFail = false; throw new QywxPromotionContactApiException('mock tags failure', 45009); }
}
public function remark(string $userId, string $externalUserId, array $fields): void { $this->calls[] = ['remark', $fields]; }
public function getExternalContact(string $externalUserId, string $cursor = ''): array { return ['external_contact' => ['name' => '客户昵称']]; }
public function getUser(string $userId): array { return ['name' => '企微员工']; }
}
final class AutomationMediaFake extends QywxPromotionMediaService
{
public bool $expired = false;
public function __construct() {}
public function materialize(array $attachments, array $config): array {
if ($this->expired) { throw new RuntimeException('mock media expired'); }
return $attachments;
}
}
final class AutomationMemoryStore extends QywxPromotionAutomationStore
{
public array $rows = [];
public array $logs = [];
public array $syncs = [];
public array $config;
public bool $schemaInstalled = true;
public bool $schemaCheckFail = false;
public bool $enqueueFail = false;
public int $now;
public function installed(): bool {
if ($this->schemaCheckFail) { throw new RuntimeException('mock DB unavailable during schema check'); }
return $this->schemaInstalled;
}
public function attribution(string $state, string $linkId, string $userId): ?array {
if (($state === 'zyt_pool:1' || ($state === '' && $linkId === 'real_link')) && $userId === 'staff') {
return ['pool_id' => 1, 'member_admin_id' => 7, 'config' => $this->config];
}
return null;
}
public function enqueue(array $row): int {
if ($this->enqueueFail) { throw new RuntimeException('mock DB failure'); }
foreach ($this->rows as $id => $existing) { if ($existing['event_key'] === $row['event_key']) { return $id; } }
$row['id'] = count($this->rows) + 1;
$this->rows[$row['id']] = $row;
return $row['id'];
}
public function due(string $lane, int $now, int $limit): array {
return array_keys(array_filter($this->rows, static function (array $r) use ($lane, $now): bool {
$pending = in_array($r['welcome_status'], ['pending', 'retry', 'running'], true);
return $r['status'] !== 'done' && $r['lock_until'] <= $now && ($lane === 'welcome'
? $pending && $r['welcome_next_retry'] <= $now
: (!$pending || $r['welcome_expires_at'] <= $now) && $r['next_retry'] <= $now);
}));
}
public function claim(int $id, string $lane, int $now): ?array {
if (!in_array($id, $this->due($lane, $now, 100), true)) { return null; }
$this->rows[$id]['lock_token'] = 'lease'; $this->rows[$id]['lock_until'] = $now + 30;
return $this->rows[$id];
}
public function save(array $row, ?array $log = null): void {
automationCheck($row['lock_token'] === $this->rows[$row['id']]['lock_token'], 'lease token');
$this->rows[$row['id']] = $row;
if ($log) { $this->logs[] = $log; }
}
public function localNames(array $task): array { return ['customer' => '', 'employee' => '本地员工']; }
public function dispatch(array $task): void { $this->syncs[] = 'dispatch'; }
public function syncRange(array $task): void { $this->syncs[] = 'range'; }
public function syncCustomer(array $task): void { $this->syncs[] = 'sync'; }
}
$now = strtotime('2026-08-31 10:00:00 Asia/Shanghai');
$baseConfig = array_replace(QywxPromotionConfig::defaults(), [
'welcome_mode' => 'channel', 'welcome' => ['text' => '您好 {customer_name},我是{employee_name}', 'attachments' => []],
'tags_enabled' => true, 'tag_ids' => ['tag1'], 'remark_enabled' => true, 'remark_template' => '{customer_name}-{employee_name}',
'description_enabled' => true, 'description' => '推广客户',
]);
$fixture = static function () use (&$now, $baseConfig): array {
$store = new AutomationMemoryStore(); $store->config = $baseConfig; $store->now = $now;
$api = new AutomationApiFake(); $media = new AutomationMediaFake();
$cipher = new QywxPromotionCodeCipher(str_repeat('test-secret-key-', 4));
$service = new QywxPromotionAutomationService($api, $media, $store, $cipher, static function () use (&$now): int { return $now; });
return [$service, $store, $api, $media, $cipher];
};
$event = ['ToUserName' => 'corp', 'ChangeType' => 'add_external_contact', 'State' => 'zyt_pool:1', 'UserID' => 'staff',
'ExternalUserID' => 'customer', 'CreateTime' => $now, 'WelcomeCode' => 'NEVER_LOG_THIS_CODE'];
[$service, $store, $api, $media, $cipher] = $fixture();
automationCheck($service->enqueueVerifiedEvent($event), 'valid promotion event handled');
automationCheck($api->calls === [] && $store->syncs === [], 'enqueue contains no API or slow sync');
automationCheck(!str_contains(json_encode($store->rows), 'NEVER_LOG_THIS_CODE'), 'code encrypted at rest');
automationCheck($cipher->decrypt($store->rows[1]['welcome_cipher']) === $event['WelcomeCode'], 'AES round trip');
$service->enqueueVerifiedEvent($event);
automationCheck(count($store->rows) === 1, 'duplicate event idempotency');
$service->retryPending();
automationCheck($api->calls === [], 'minute lane cannot lock active welcome');
$service->processWelcomes();
automationCheck($api->calls[0][0] === 'welcome' && str_contains($api->calls[0][1], '客户昵称') && str_contains($api->calls[0][1], '企微员工'), 'welcome first and template names');
automationCheck($store->syncs === [] && $store->rows[1]['welcome_status'] === 'sent' && $store->rows[1]['welcome_cipher'] === '', 'clear code on success and defer slow sync');
$api->tagsFail = true;
$service->retryPending();
$actions = json_decode($store->rows[1]['actions_json'], true);
automationCheck($actions['tags']['status'] === 'retry' && $actions['remark']['status'] === 'success' && $actions['description']['status'] === 'success', 'failed tags do not block enabled remarks');
automationCheck($store->syncs === ['dispatch', 'range', 'sync'], 'original sync preserved despite tag failure');
$now += 60; $service->retryPending(); $service->processWelcomes();
automationCheck($store->rows[1]['status'] === 'done', 'failed action retried to completion');
automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v[0] === 'welcome')) === 1, 'never resend success');
automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v[0] === 'remark')) === 2, 'do not repeat successful remark/description');
automationCheck(!str_contains(json_encode($store->logs), 'NEVER_LOG_THIS_CODE'), 'audit contains no code');
foreach (['default', 'none'] as $mode) {
[$service, $store, $api] = $fixture(); $store->config['welcome_mode'] = $mode;
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]));
$service->processWelcomes();
automationCheck($store->rows[1]['welcome_status'] === 'skipped' && $api->calls === [] && $store->rows[1]['welcome_cipher'] === '', 'default/none do not send or retain code');
}
[$service, $store, $api] = $fixture();
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => 'zyt_pool:999'])), 'untrusted state cannot authorize');
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['UserID' => 'outsider'])), 'nonmember cannot authorize');
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => ''])), 'missing state and link stays legacy');
$store->schemaInstalled = false;
automationCheck(!$service->enqueueVerifiedEvent($event), 'missing migration stays legacy');
$store->schemaInstalled = true; $store->enqueueFail = true;
$failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; }
automationCheck($failed, 'enqueue failure propagates for callback retry');
[$service, $store] = $fixture(); $store->schemaCheckFail = true;
$failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; }
automationCheck($failed, 'schema lookup failure propagates for callback retry instead of acknowledging lost event');
[$service, $store, $api] = $fixture();
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'WelcomeCode' => '']));
automationCheck($store->rows[1]['welcome_status'] === 'skipped', 'missing code explicit skip');
[$service, $store, $api] = $fixture();
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now - 21]));
automationCheck($store->rows[1]['welcome_status'] === 'expired', 'expired code not queued for send');
[$service, $store, $api] = $fixture();
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'ChangeType' => 'add_half_external_contact']));
$service->processWelcomes(); $service->retryPending();
automationCheck($api->calls[0][0] === 'welcome' && str_contains($api->calls[0][1], '您好 您'), 'half-contact welcome uses safe nickname fallback');
automationCheck($store->syncs === [] && $store->rows[1]['status'] === 'done', 'half-contact does not create customer or modify relation');
[$service, $store, $api] = $fixture();
$api->welcomeError = new QywxPromotionContactApiException('network unknown', 0, true);
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes(); $service->processWelcomes();
automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && count($api->calls) === 1 && $store->rows[1]['welcome_cipher'] === '', 'uncertain network never retried');
[$service, $store, $api] = $fixture();
$api->welcomeError = new QywxPromotionContactApiException('other app currently sending', 41096);
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes();
automationCheck($store->rows[1]['welcome_status'] === 'retry', 'explicit 41096 can retry within window');
$now++; $service->processWelcomes();
automationCheck($store->rows[1]['welcome_status'] === 'sent' && count($api->calls) === 2, 'safe explicit retry succeeded');
[$service, $store, $api] = $fixture();
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]));
$actions = json_decode($store->rows[1]['actions_json'], true); $actions['welcome']['status'] = 'running';
$store->rows[1]['actions_json'] = json_encode($actions); $store->rows[1]['welcome_status'] = 'running';
$service->processWelcomes();
automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && $api->calls === [], 'crashed running send is never replayed');
[$service, $store, $api, $media] = $fixture(); $media->expired = true;
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes();
automationCheck($store->rows[1]['welcome_status'] === 'retry' && $api->calls === [], 'unprepared media never sends partial payload');
$now += 21; $service->retryPending();
automationCheck($store->rows[1]['welcome_status'] === 'expired' && $store->rows[1]['welcome_cipher'] === '', 'minute job expires and clears code');
$config = $baseConfig; $config['welcome_schedule_enabled'] = true;
$config['welcome_schedule'] = [['weekdays' => [1], 'start' => '10:00', 'end' => '11:00', 'text' => '上午', 'attachments' => []]];
automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 10:00 Asia/Shanghai'))['text'] === '上午', 'schedule uses event time');
automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 11:00 Asia/Shanghai'))['text'] === $baseConfig['welcome']['text'], 'schedule fallback explicit base welcome');
echo "QYWX_PROMOTION_AUTOMATION_OK\n";