CaiWuXiTong/tests/Feature/Business/CourierAgreementTaskDeletionTest.php
limeng 48553c001d 协议签署:每条任务支持自主删除
全部状态、两类任务支持确认软删除,保留文件及外部签署流程。相关72项测试通过,权限及公司隔离保持不变。
2026-10-11 11:24:30 +08:00

280 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierAgreementSignatureEvent;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Tests\Feature\FeatureTestCase;
class CourierAgreementTaskDeletionTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}