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); } }