201 lines
10 KiB
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);
|
|
}
|
|
}
|