CaiWuXiTong/tests/Feature/Business/CourierOnboardingDingTalkProfilesTest.php

201 lines
10 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Business\CourierProfileData;
use App\Exceptions\CourierOnboardingDingTalkException;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Support\CourierOnboardingDingTalkProfiles;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\Feature\FeatureTestCase;
class CourierOnboardingDingTalkProfilesTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->company->makeCurrent();
config(['courier_onboarding.dingtalk' => [
'enabled' => true, 'client_id' => 'test-app', 'client_secret' => 'test-secret',
'process_code' => 'PROC-TEST', 'originator_user_id' => 'originator-test', 'dept_id' => 123,
]]);
Http::preventStrayRequests();
}
public function test_approved_profile_flows_to_courier_information_without_copying_passwords_or_changing_review(): void
{
$record = $this->record();
$snapshot = $this->snapshot($record);
$snapshot['formComponentValues'][] = ['name' => '财务账单密码', 'value' => 'must-not-be-saved'];
$this->fake([$record->dingtalk_instance_id => $snapshot]);
$this->assertSame('synced', $this->service()->synchronize($record->company_id, $record->id, 'test-token'));
$fresh = $record->fresh();
foreach ($this->profile() as $field => $value) {
$this->assertSame($value, $fresh->{$field});
}
$this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $fresh->status);
$row = (object) ['undefined3' => null, '_info' => ['onboarding' => $fresh]];
$this->assertSame('运费', app(CourierProfileData::class)->fillFromOnboarding($row)->undefined3);
$this->assertSame('00123', app(CourierProfileData::class)->onboardingNetwork($fresh)->netcode);
$this->assertSame(1, $record->events()->where('event_type', 'dingtalk_profile_synced')->count());
$this->assertStringNotContainsString('must-not-be-saved', json_encode($fresh->getAttributes()));
$this->assertStringNotContainsString('must-not-be-saved', $record->events()->firstOrFail()->note);
// 审批完成只同步一次,之后的人工修正不会被轮询覆盖。
$fresh->update(['courier_email' => 'corrected@example.com']);
$this->assertSame('skipped', $this->service()->synchronize($record->company_id, $record->id, 'test-token'));
$this->assertSame('corrected@example.com', $record->fresh()->courier_email);
Http::assertSentCount(1);
}
public function test_pending_refused_and_terminated_approvals_do_not_apply_profile(): void
{
foreach ([['RUNNING', 'agree', 'pending'], ['COMPLETED', 'refuse', 'closed'], ['TERMINATED', '', 'closed']] as [$status, $result, $expected]) {
$record = $this->record();
$record->update(['network_region' => '原地区']);
$snapshots[$record->dingtalk_instance_id] = [...$this->snapshot($record), 'status' => $status, 'result' => $result];
$records[] = [$record, $expected];
}
$this->fake($snapshots);
foreach ($records as [$record, $expected]) {
$this->assertSame($expected, $this->service()->synchronize($record->company_id, $record->id, 'test-token'));
$this->assertSame('原地区', $record->fresh()->network_region);
$this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $record->fresh()->status);
}
}
public function test_wrong_company_originator_and_marker_never_write_profile(): void
{
$record = $this->record();
$wrongOriginator = $this->record();
$wrongMarker = $this->record();
$this->fake([
$record->dingtalk_instance_id => $this->snapshot($record),
$wrongOriginator->dingtalk_instance_id => [...$this->snapshot($wrongOriginator), 'originatorUserId' => 'other-user'],
$wrongMarker->dingtalk_instance_id => $this->snapshot($record),
]);
$this->assertSame('skipped', $this->service()->synchronize($record->company_id + 1, $record->id, 'test-token'));
foreach ([$wrongOriginator, $wrongMarker] as $candidate) {
try {
$this->service()->synchronize($candidate->company_id, $candidate->id, 'test-token');
$this->fail('不匹配的审批不可写入资料');
} catch (CourierOnboardingDingTalkException) {
$this->assertNull($candidate->fresh()->courier_category);
}
}
$this->assertSame(0, CourierOnboardingEvent::query()->count());
Http::assertSentCount(2);
}
public function test_incomplete_or_invalid_profile_is_atomic_and_can_sync_after_it_is_corrected(): void
{
$record = $this->record();
$invalid = $this->snapshot($record);
$invalid['formComponentValues'] = array_values(array_filter($invalid['formComponentValues'], fn (array $field): bool => $field['name'] !== '快递邮箱'));
$invalid['formComponentValues'][] = ['name' => '快递邮箱', 'value' => 'invalid-email'];
Http::fake(['*/workflow/processInstances*' => Http::sequence()->push(['result' => $invalid])->push(['result' => $this->snapshot($record)])]);
try {
$this->service()->synchronize($record->company_id, $record->id, 'test-token');
$this->fail('无效资料不可部分写入');
} catch (CourierOnboardingDingTalkException $exception) {
$this->assertStringContainsString('快递邮箱', $exception->getMessage());
$this->assertNull($record->fresh()->courier_category);
$this->assertNull($record->fresh()->network_code);
}
$this->assertSame('synced', $this->service()->synchronize($record->company_id, $record->id, 'test-token'));
}
public function test_polling_rotates_a_bounded_batch_and_does_not_create_any_approval(): void
{
$first = $this->record();
$second = $this->record();
$this->fake([
$first->dingtalk_instance_id => [...$this->snapshot($first), 'status' => 'RUNNING'],
$second->dingtalk_instance_id => $this->snapshot($second),
]);
$this->artisan('courier-onboarding:sync-dingtalk-profiles', ['--limit' => 1])->assertSuccessful();
$this->assertNull($first->fresh()->courier_category);
$this->assertNull($second->fresh()->courier_category);
$this->artisan('courier-onboarding:sync-dingtalk-profiles', ['--limit' => 1])->assertSuccessful();
$this->assertSame('内采快递', $second->fresh()->courier_category);
$this->artisan('courier-onboarding:sync-dingtalk-profiles', ['--limit' => 1])->assertSuccessful();
$this->assertSame($first->id, Cache::get('courier-onboarding:dingtalk-profile-cursor'));
Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST' && str_contains($request->url(), '/workflow/'));
}
public function test_polling_records_one_sanitized_failure_and_keeps_processing_other_records(): void
{
$failed = $this->record();
$success = $this->record();
$snapshots = [$success->dingtalk_instance_id => $this->snapshot($success)];
$this->fake($snapshots);
$this->artisan('courier-onboarding:sync-dingtalk-profiles')->assertFailed();
$this->assertSame('内采快递', $success->fresh()->courier_category);
$this->artisan('courier-onboarding:sync-dingtalk-profiles')->assertFailed();
$this->assertSame(1, $failed->events()->where('event_type', 'dingtalk_profile_failed')->count());
$this->assertStringNotContainsString('sensitive-data', $failed->events()->firstOrFail()->note);
}
public function test_disabled_or_no_candidates_make_no_api_calls_and_closed_local_records_are_excluded(): void
{
config(['courier_onboarding.dingtalk.enabled' => false]);
$this->artisan('courier-onboarding:sync-dingtalk-profiles')->assertSuccessful();
config(['courier_onboarding.dingtalk.enabled' => true]);
$record = $this->record();
$record->update(['status' => CourierOnboarding::STATUS_CANCELLED]);
$this->artisan('courier-onboarding:sync-dingtalk-profiles')->assertSuccessful();
Http::assertNothingSent();
}
private function record(): CourierOnboarding
{
return CourierOnboarding::create([
'company_id' => $this->company->id, 'number' => 'RZ-SYNC-' . uniqid(), 'token' => bin2hex(random_bytes(20)),
'status' => CourierOnboarding::STATUS_SUBMITTED, 'courier_name' => '测试快递', 'workplatform_expcode' => 'SYNC-' . uniqid(),
'dingtalk_instance_id' => 'instance-' . uniqid(),
]);
}
private function profile(): array
{
return [
'courier_category' => '内采快递', 'cooperation_mode' => '保丢保损', 'courier_attribute' => '简尚',
'deduction_method' => '运费', 'waybill_type' => '系统对接', 'courier_email' => 'courier@example.com',
'need_logistics_check' => '否', 'shipping_mode' => '一单一货', 'workplatform_account' => '新开',
'network_code' => '00123', 'network_region' => '江苏省常州市',
];
}
private function snapshot(CourierOnboarding $record): array
{
$fields = [['name' => '原因', 'value' => '[财务入驻:' . $record->company_id . ':' . $record->id . ':' . $record->number . ']']];
foreach ($this->profile() as $field => $value) {
$fields[] = ['name' => CourierOnboarding::PROFILE_FIELDS[$field][0], 'value' => $value];
}
return ['status' => 'COMPLETED', 'result' => 'agree', 'originatorUserId' => 'originator-test', 'formComponentValues' => $fields];
}
private function fake(array $snapshots): void
{
Http::fake(function (Request $request) use ($snapshots) {
if (str_ends_with($request->url(), '/oauth2/accessToken')) {
return Http::response(['accessToken' => 'test-token']);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/workflow/processInstances')) {
$id = $request['processInstanceId'];
return isset($snapshots[$id]) ? Http::response(['result' => $snapshots[$id]]) : Http::response(['message' => 'sensitive-data'], 503);
}
throw new \LogicException('测试中禁止创建审批或请求其他接口');
});
}
private function service(): CourierOnboardingDingTalkProfiles
{
return app(CourierOnboardingDingTalkProfiles::class);
}
}