withExceptionHandling(); $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); Storage::fake('uploads'); Http::preventStrayRequests(); config([ 'services.esign.environment' => 'sandbox', 'services.esign.base_url' => 'https://smlopenapi.esign.test', 'services.esign.app_id' => 'sandbox-app-id', 'services.esign.app_secret' => 'sandbox-app-secret', ]); } public function test_failed_and_unpushed_tasks_are_soft_deleted_and_files_and_audit_are_retained(): void { foreach (['prepared', 'failed', 'unknown'] as $status) { $task = $this->task(['status' => $status, 'last_error' => 'E 签宝接口返回业务错误(1437328):签署人信息不一致']); Storage::disk('uploads')->put($task->source_pdf_path, '%PDF-source'); $audit = CourierAgreementSignatureEvent::create([ 'company_id' => $this->company->id, 'agreement_signature_id' => $task->id, 'event_key' => 'audit-' . $task->id, 'action' => 'TEST_EVENT', 'esign_sign_flow_id' => 'test-' . $task->id, 'event_timestamp' => now()->timestamp, 'raw_body' => '{}', 'received_at' => now(), ]); $this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true]) ->assertOk()->assertJson(['message' => '签署任务已删除。']); $this->assertSoftDeleted($task); $this->assertSame($this->user->id, $task->fresh()->updated_by); Storage::disk('uploads')->assertExists($task->source_pdf_path); $this->assertNotNull($audit->fresh()); $this->assertNull(CourierAgreementSignature::find($task->id)); $this->get($this->pageUrl())->assertOk()->assertDontSeeText($task->business_no); $this->get($this->pageUrl() . '?agreement_scope=all')->assertOk()->assertDontSeeText($task->business_no); $this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertNotFound(); } Http::assertNothingSent(); } public function test_every_status_can_be_deleted_without_querying_or_revoking_remote_flows(): void { $cases = [ ['status' => 'prepared'], ['status' => 'sending'], ['status' => 'signing', 'esign_sign_flow_id' => 'active-flow', 'remote_status' => '1'], ['status' => 'completed'], ['status' => 'archived'], ['status' => 'unknown', 'last_error' => 'Connection timed out'], ['status' => 'failed', 'esign_sign_flow_id' => 'failed-after-flow'], ['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'revoked-flow'], ['status' => 'expired', 'remote_status' => '5', 'esign_sign_flow_id' => 'expired-flow'], ['status' => 'rejected', 'remote_status' => '7', 'esign_sign_flow_id' => 'rejected-flow'], ['completed_at' => now()], ['archived_at' => now()], ['signed_pdf_path' => 'signed.pdf'], ['certificate_path' => 'certificate.pdf'], ['remote_status' => '2'], ['remote_status' => '1'], ]; foreach ($cases as $case) { $task = $this->task($case); $this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk(); $this->assertSoftDeleted($task); $this->assertSame($task->esign_sign_flow_id, $task->fresh()->esign_sign_flow_id); } Http::assertNothingSent(); } public function test_signed_files_are_retained_and_callbacks_cannot_restore_deleted_tasks(): void { $task = $this->task([ 'status' => 'archived', 'remote_status' => '2', 'esign_sign_flow_id' => 'signed-flow', 'completed_at' => now(), 'archived_at' => now(), 'signed_pdf_path' => 'delete-tests/signed.pdf', 'certificate_path' => 'delete-tests/certificate.pdf', ]); foreach ([$task->source_pdf_path, $task->signed_pdf_path, $task->certificate_path] as $path) { Storage::disk('uploads')->put($path, '%PDF-retained'); } $this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk(); $this->assertNull(app(CourierAgreementSignatureService::class)->acceptCallback('signed-flow', '2', 'late-callback')); $this->assertSoftDeleted($task); foreach ([$task->source_pdf_path, $task->signed_pdf_path, $task->certificate_path] as $path) { Storage::disk('uploads')->assertExists($path); } $this->get($this->pageUrl() . '?agreement_scope=archived')->assertOk()->assertDontSeeText($task->business_no); Http::assertNothingSent(); } public function test_confirmation_is_required_but_provider_credentials_are_not(): void { $task = $this->task(); $this->loginAs()->deleteJson($this->deleteUrl($task))->assertStatus(422)->assertJsonValidationErrors('delete_confirmed'); $this->assertFalse($task->fresh()->trashed()); $remoteTask = $this->task(['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'flow-production', 'environment' => 'production']); config(['services.esign.app_id' => '', 'services.esign.app_secret' => '']); $this->deleteJson($this->deleteUrl($remoteTask), ['delete_confirmed' => true])->assertOk(); $this->assertSoftDeleted($remoteTask); Http::assertNothingSent(); } public function test_deletion_does_not_cross_company_or_management_permissions(): void { $foreignTask = $this->task(['company_id' => $this->company->id + 100]); $this->loginAs()->deleteJson($this->deleteUrl($foreignTask), ['delete_confirmed' => true])->assertNotFound(); $this->assertFalse($foreignTask->fresh()->trashed()); $task = $this->task(); $this->user->roles()->detach(); $this->user->unsetRelation('roles'); $this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertForbidden(); $this->assertFalse($task->fresh()->trashed()); } public function test_deleted_request_cannot_be_replayed_or_recreated_with_the_same_idempotency_key(): void { $key = 'deleted-request'; $task = $this->task(['idempotency_key' => 'agreement-' . hash('sha256', $this->company->id . '|' . $key)]); $this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk(); try { app(CourierAgreementSignatureService::class)->send(['document_type' => 'account-proof', 'idempotency_key' => $key], $this->user->id); $this->fail('Deleted signing request must not be replayed.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('signature', $exception->errors()); } $this->assertSame(1, CourierAgreementSignature::withTrashed()->count()); $this->assertSoftDeleted($task); Http::assertNothingSent(); } public function test_deletion_during_a_failed_push_is_not_undone_by_the_failure_update(): void { $key = 'retry-request'; $task = $this->task([ 'idempotency_key' => 'agreement-' . hash('sha256', $this->company->id . '|' . $key), 'operator_name' => '测试人员', 'operator_account' => '13800138000', ]); Storage::disk('uploads')->put($task->source_pdf_path, '%PDF-source'); Http::fake(function () use ($task) { $this->assertSame('sending', $task->fresh()->status); $this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk(); return Http::response(['code' => 500, 'message' => 'provider unavailable'], 500); }); try { app(CourierAgreementSignatureService::class)->send(['document_type' => 'account-proof', 'idempotency_key' => $key], $this->user->id); $this->fail('Fake provider should reject the retry.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('signature', $exception->errors()); } Http::assertSentCount(1); $this->assertSoftDeleted($task); $this->assertSame('failed', $task->fresh()->status); } public function test_delete_button_is_shown_for_every_agreement_task_in_each_view(): void { $safe = $this->task(); $signing = $this->task(['status' => 'signing', 'esign_sign_flow_id' => 'flow-active', 'remote_status' => '1']); $archived = $this->task(['status' => 'archived']); $quote = $this->quoteTask(); $archivedQuote = $this->quoteTask(['status' => 'archived']); $this->loginAs()->get($this->pageUrl())->assertOk() ->assertSee($this->deleteUrl($safe), false)->assertSee($this->deleteUrl($signing), false) ->assertSee($this->deleteUrl($quote), false) ->assertDontSee($this->deleteUrl($archived), false); $this->get($this->pageUrl() . '?agreement_scope=all')->assertOk() ->assertSee($this->deleteUrl($safe), false)->assertSee($this->deleteUrl($signing), false) ->assertSee($this->deleteUrl($quote), false)->assertSee($this->deleteUrl($archivedQuote), false) ->assertSee($this->deleteUrl($archived), false); $this->get($this->pageUrl() . '?agreement_scope=archived')->assertOk() ->assertSee($this->deleteUrl($archivedQuote), false) ->assertSee($this->deleteUrl($archived), false); } public function test_quote_tasks_in_every_status_are_soft_deleted_without_changing_the_approved_quote(): void { foreach (['prepared', 'sending', 'signing', 'completed', 'archived', 'revoked', 'expired', 'rejected', 'failed'] as $status) { $task = $this->quoteTask(['status' => $status, 'esign_sign_flow_id' => 'quote-' . $status]); Storage::disk('uploads')->put($task->source_pdf_path, '%PDF-source'); $this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk(); $this->assertSoftDeleted($task); $this->assertSame('approved', $task->version->fresh()->status); Storage::disk('uploads')->assertExists($task->source_pdf_path); $this->assertNull(app(CourierQuoteSignatureService::class)->acceptCallback($task->esign_sign_flow_id, '2', 'late-quote')); $this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertNotFound(); try { app(CourierQuoteSignatureService::class)->prepareVersion($task->version->fresh(), $this->user->id); $this->fail('A deleted quote task must not overwrite retained files.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('signature', $exception->errors()); } } Http::assertNothingSent(); } public function test_quote_deletion_requires_confirmation_company_and_management_permission(): void { $task = $this->quoteTask(); $this->loginAs()->deleteJson($this->deleteUrl($task))->assertStatus(422)->assertJsonValidationErrors('delete_confirmed'); $foreign = $this->quoteTask(['company_id' => $this->company->id + 100]); $this->deleteJson($this->deleteUrl($foreign), ['delete_confirmed' => true])->assertNotFound(); $this->assertFalse($foreign->fresh()->trashed()); $this->user->roles()->detach(); $this->user->unsetRelation('roles'); $this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertForbidden(); $this->assertFalse($task->fresh()->trashed()); } private function task(array $overrides = []): CourierAgreementSignature { $key = uniqid(); return CourierAgreementSignature::create(array_merge([ 'company_id' => $this->company->id, 'business_no' => 'AGR-DELETE-' . $key, 'idempotency_key' => 'delete-' . $key, 'document_type' => 'account-proof', 'template_code' => 'TPL-ACCOUNT-CERT-V2', 'template_version' => '2', 'source_file_name' => '收款账户证明文件.doc', 'status' => 'failed', 'courier_name' => '测试快递', 'company_full_name' => $this->company->name, 'organization_name' => '测试快递有限公司', 'payload' => [], 'environment' => 'sandbox', 'source_pdf_path' => 'delete-tests/' . $key . '/source.pdf', 'source_pdf_sha256' => str_repeat('a', 64), 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ], $overrides)); } private function quoteTask(array $overrides = []): CourierQuoteSignature { $key = uniqid(); $change = CourierQuoteChange::create([ 'company_id' => $this->company->id, 'number' => 'BG-' . $key, 'source_quote_id' => 'QB-' . $key, 'source_sheet_name' => '测试报价', 'carrier_name' => '测试快递', 'quote_type' => '标准报价', 'change_type' => '面单调整', 'status' => CourierQuoteChange::STATUS_APPROVED, 'effective_date' => now()->toDateString(), 'is_retrospective' => false, 'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED, 'summary' => '删除测试报价', 'created_by' => $this->user->id, 'approved_by' => $this->user->id, 'approved_at' => now(), ]); $version = CourierQuoteVersion::create([ 'company_id' => $this->company->id, 'version_code' => 'QB-' . $key, 'source_quote_id' => 'QB-' . $key, 'source_change_id' => $change->id, 'sheet_name' => '测试报价', 'company_name' => $this->company->name, 'carrier_name' => '测试快递', 'quote_type' => '标准报价', 'effective_date' => now()->toDateString(), 'source_range' => 'A1:B2', 'values_json' => '[["地区","面单"],["广东省",6.5]]', 'formulas_json' => '[[],[]]', 'status' => 'approved', 'signature_status' => '未签署', 'created_by' => $this->user->id, 'approved_by' => $this->user->id, ]); return CourierQuoteSignature::create(array_merge([ 'company_id' => $this->company->id, 'quote_version_id' => $version->id, 'provider' => 'esign', 'environment' => 'sandbox', 'status' => 'signing', 'organization_name' => '测试快递', 'source_pdf_path' => 'delete-tests/quotes/' . $key . '.pdf', 'source_pdf_sha256' => str_repeat('b', 64), 'source_pdf_generated_at' => now(), 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ], $overrides)); } private function deleteUrl(CourierAgreementSignature|CourierQuoteSignature $task): string { $route = $task instanceof CourierQuoteSignature ? 'quotes.destroy' : 'destroy'; return route('business.courier-agreement-signatures.' . $route, $task); } private function pageUrl(): string { return route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']); } }