company->makeCurrent(); config(['courier_onboarding.dingtalk' => [ 'enabled' => true, 'client_id' => 'onboarding-app', 'client_secret' => 'onboarding-secret', 'process_code' => 'PROC-TEST', 'originator_user_id' => 'originator-test', 'dept_id' => 123, ], 'courier_onboarding.public_url' => 'https://finance.example.com:8442']); Http::preventStrayRequests(); } public function test_uses_separate_app_and_omits_original_and_internal_credentials(): void { $record = $this->record(); config(['dingtalk.client_id' => 'login-app', 'dingtalk.client_secret' => 'login-secret']); $this->fakeSuccess(); $this->assertSame('started', $this->service()->start($record->company_id, $record->id)); $this->assertSame('instance-test', $record->fresh()->dingtalk_instance_id); $this->assertSame(CourierOnboarding::STATUS_APPROVED, $record->fresh()->status); Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/oauth2/accessToken') && $request['appKey'] === 'onboarding-app' && $request['appSecret'] === 'onboarding-secret'); Http::assertSent(function (Request $request) use ($record): bool { if (! str_ends_with($request->url(), '/workflow/processInstances')) { return false; } $values = array_column($request['formComponentValues'], 'value', 'name'); $this->assertSame('originator-test', $request['originatorUserId']); $this->assertSame(123, $request['deptId']); $this->assertSame('否', $values['快递是否为物流']); $this->assertSame('是', $values['是否需要核对物流点']); $this->assertSame('13800001111', $values['电话']); $this->assertSame('测试快递', $values['绑定快递名称']); foreach (['报价单原件', '附件', '系统对接方式', '查件记账账号', '查件记账密码', '财务账单账号', '财务账单密码', '聚水潭聚工单账号', '聚水潭聚工单密码'] as $field) { $this->assertArrayNotHasKey($field, $values); } $body = json_encode($request->data(), JSON_UNESCAPED_UNICODE); $this->assertStringNotContainsString($record->token, $body); $this->assertStringContainsString('/' . $record->company_id . '/workspace/courier-payables/courier-onboarding/applications/' . $record->id, $values['原因']); return true; }); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); Http::assertSentCount(2); } public function test_pending_claim_blocks_reentry_and_resubmission_does_not_create_another_approval(): void { $record = $this->record(); Http::fake(function (Request $request) use ($record) { if (str_ends_with($request->url(), '/oauth2/accessToken')) { $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); return Http::response(['accessToken' => 'test-token']); } return Http::response(['instanceId' => 'instance-test']); }); $this->assertSame('started', $this->service()->start($record->company_id, $record->id)); $record->update(['status' => CourierOnboarding::STATUS_RETURNED]); $record->update(['status' => CourierOnboarding::STATUS_SUBMITTED]); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); $record->update(['status' => CourierOnboarding::STATUS_APPROVED]); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); Http::assertSentCount(2); } public function test_rejected_request_can_only_be_retried_explicitly_and_does_not_store_raw_errors(): void { $record = $this->record(); Http::fake([ '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), '*/workflow/processInstances' => Http::sequence() ->push(['code' => 'Forbidden.AccessDenied', 'message' => 'secret-raw-data'], 403) ->push(['instanceId' => 'instance-test']), ]); $this->assertSame('failed', $this->service()->start($record->company_id, $record->id)); $this->assertNull($record->fresh()->dingtalk_instance_id); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); $event = $record->events()->reorder('id', 'desc')->firstOrFail(); $this->assertSame('dingtalk_failed', $event->event_type); $this->assertStringContainsString('Forbidden.AccessDenied', $event->note); $this->assertStringNotContainsString('secret-raw-data', $event->note); $this->assertSame('started', $this->service()->start($record->company_id, $record->id, true)); } public function test_timeout_server_error_and_incomplete_success_never_retry_blindly(): void { foreach (['timeout', 'server-error', 'incomplete'] as $scenario) { $record = $this->record(); Http::fake(function (Request $request) use ($scenario) { if (str_ends_with($request->url(), '/oauth2/accessToken')) { return Http::response(['accessToken' => 'test-token']); } if ($scenario === 'timeout') { throw new ConnectionException('sensitive-request-context'); } return Http::response([], $scenario === 'server-error' ? 500 : 200); }); $this->assertSame('unknown', $this->service()->start($record->company_id, $record->id)); $this->assertSame('dingtalk_unknown', $record->events()->reorder('id', 'desc')->firstOrFail()->event_type); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); $this->assertNull($record->fresh()->dingtalk_instance_id); } } public function test_explicit_retry_recovers_old_submission_failure_after_final_review_without_duplicate_creation(): void { $record = $this->record(); $record->update(['status' => CourierOnboarding::STATUS_SUBMITTED]); CourierOnboardingEvent::create([ 'company_id' => $record->company_id, 'onboarding_id' => $record->id, 'event_type' => 'dingtalk_failed', 'from_status' => CourierOnboarding::STATUS_SUBMITTED, 'to_status' => CourierOnboarding::STATUS_SUBMITTED, 'actor_type' => 'system', 'note' => '旧版在提交时发起失败。', ]); $this->fakeSuccess(); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); Http::assertNothingSent(); $record->update(['status' => CourierOnboarding::STATUS_APPROVED]); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); $this->assertSame('started', $this->service()->start($record->company_id, $record->id, true)); $this->assertSame(CourierOnboarding::STATUS_APPROVED, $record->fresh()->status); $this->assertSame('instance-test', $record->fresh()->dingtalk_instance_id); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); Http::assertSentCount(2); } public function test_disabled_unsubmitted_and_other_company_records_do_not_call_api(): void { $record = $this->record(); config(['courier_onboarding.dingtalk.enabled' => false]); $this->assertSame('disabled', $this->service()->start($record->company_id, $record->id)); config(['courier_onboarding.dingtalk.enabled' => true]); $this->assertSame('failed', $this->service()->start($record->company_id + 1, $record->id)); foreach ([CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_RETURNED, CourierOnboarding::STATUS_ACTIVATED, CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED] as $status) { $record->update(['status' => $status]); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); } Http::assertNothingSent(); $this->assertSame(0, $record->events()->count()); } public function test_missing_credentials_fail_before_creation_and_unknown_business_type_is_not_invented(): void { $record = $this->record(); $record->update(['service_type' => '其他', 'workplatform_account' => '新开']); $values = array_column($this->service()->payload($record)['formComponentValues'], 'value', 'name'); $this->assertArrayNotHasKey('快递是否为物流', $values); $this->assertArrayNotHasKey('绑定快递名称', $values); config(['courier_onboarding.dingtalk.client_secret' => '']); $this->assertSame('failed', $this->service()->start($record->company_id, $record->id)); $this->assertSame('dingtalk_failed', $record->events()->reorder('id', 'desc')->firstOrFail()->event_type); Http::assertNothingSent(); } public function test_recovery_checks_workflow_originator_and_onboarding_marker_before_binding(): void { $record = $this->record(); $other = $this->record(); $reason = collect($this->service()->payload($record)['formComponentValues'])->firstWhere('name', '原因'); Http::fake([ '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), '*/workflow/processInstances*' => Http::response(['result' => [ 'createTime' => '2026-11-06T03:00Z', 'originatorUserId' => 'originator-test', 'formComponentValues' => [$reason], ]]), '*/workflow/processes/instanceIds/query' => Http::response(['result' => ['list' => ['existing-instance']]]), ]); try { $this->service()->reconcile($other->company_id, $other->id, 'existing-instance'); $this->fail('不同入驻申请不可关联同一审批'); } catch (CourierOnboardingDingTalkException) { $this->assertNull($other->fresh()->dingtalk_instance_id); } $this->service()->reconcile($record->company_id, $record->id, 'existing-instance'); $this->assertSame('existing-instance', $record->fresh()->dingtalk_instance_id); Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST' && str_ends_with($request->url(), '/workflow/processInstances')); } private function record(): CourierOnboarding { return CourierOnboarding::create([ 'company_id' => $this->company->id, 'number' => 'RZ-TEST-' . uniqid(), 'token' => bin2hex(random_bytes(20)), 'status' => CourierOnboarding::STATUS_APPROVED, 'courier_name' => '测试快递', 'company_full_name' => '测试快递公司', 'contact_name' => '测试联系人', 'contact_phone' => '13800001111', 'service_type' => '快递', 'workplatform_account' => '已有', 'need_logistics_check' => '是', ]); } private function fakeSuccess(): void { Http::fake([ '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), '*/workflow/processInstances' => Http::response(['instanceId' => 'instance-test']), ]); } private function service(): CourierOnboardingDingTalk { return app(CourierOnboardingDingTalk::class); } }