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('是', $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_explicit_retry_after_activation_recovers_known_failure_without_changing_review_or_activation(): void { $record = $this->record(); $record->update(['first_reviewed_at' => now()->subMinutes(3), 'final_reviewed_at' => now()->subMinutes(2)]); Http::fake([ '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), '*/workflow/processInstances' => Http::sequence() ->push(['code' => 'processInstanceStartFailed'], 400) ->push(['instanceId' => 'instance-test']), ]); $this->assertSame('failed', $this->service()->start($record->company_id, $record->id)); $record->update(['status' => CourierOnboarding::STATUS_ACTIVATED, 'activated_at' => now()->subMinute()]); $before = $record->fresh()->only(['status', 'first_reviewed_at', 'final_reviewed_at', 'activated_at']); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); $this->artisan('courier-onboarding:dingtalk', [ 'id' => $record->id, '--company' => $record->company_id, '--retry' => true, ])->expectsOutput('已发起钉钉审批。')->assertExitCode(0); $this->assertEquals($before, $record->fresh()->only(array_keys($before))); $this->assertSame('instance-test', $record->fresh()->dingtalk_instance_id); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); $this->assertSame(1, $record->events()->where('event_type', 'dingtalk_started')->count()); Http::assertSentCount(4); } public function test_activated_retry_requires_known_failure_and_both_reviews(): void { $this->fakeSuccess(); foreach ([null, 'dingtalk_starting', 'dingtalk_unknown', 'dingtalk_started', 'missing-first-review', 'missing-final-review'] as $scenario) { $record = $this->record(); $record->update([ 'status' => CourierOnboarding::STATUS_ACTIVATED, 'first_reviewed_at' => $scenario === 'missing-first-review' ? null : now()->subMinutes(3), 'final_reviewed_at' => $scenario === 'missing-final-review' ? null : now()->subMinutes(2), ]); if ($scenario !== null) { CourierOnboardingEvent::create([ 'company_id' => $record->company_id, 'onboarding_id' => $record->id, 'event_type' => str_starts_with($scenario, 'missing-') ? 'dingtalk_failed' : $scenario, 'from_status' => $record->status, 'to_status' => $record->status, 'actor_type' => 'system', 'note' => '测试发起边界。', ]); } $eventCount = $record->events()->count(); $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true), (string) $scenario); $this->assertNull($record->fresh()->dingtalk_instance_id); $this->assertSame($eventCount, $record->events()->count()); } Http::assertNothingSent(); } 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); } }