210 lines
11 KiB
PHP
210 lines
11 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Business;
|
||
|
||
use App\Models\Auth\Permission;
|
||
use App\Models\Business\CourierAgreementSignature;
|
||
use App\Models\Business\CourierAgreementSignatureEvent;
|
||
use App\Support\CourierAgreementSignatureService;
|
||
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_in_flight_completed_and_ambiguous_tasks_cannot_be_deleted(): void
|
||
{
|
||
$cases = [
|
||
['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'],
|
||
['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])->assertStatus(422);
|
||
$this->assertFalse($task->fresh()->trashed());
|
||
}
|
||
Http::assertNothingSent();
|
||
}
|
||
|
||
public function test_ended_flows_are_checked_remotely_before_deletion(): void
|
||
{
|
||
Http::fakeSequence()->push(['code' => 0, 'data' => ['signFlowStatus' => '3']])
|
||
->push(['code' => 0, 'data' => ['signFlowStatus' => '5']])
|
||
->push(['code' => 0, 'data' => ['signFlowStatus' => '7']]);
|
||
$checked = 0;
|
||
foreach (['revoked' => '3', 'expired' => '5', 'rejected' => '7'] as $status => $remoteStatus) {
|
||
$task = $this->task(['status' => $status, 'remote_status' => $remoteStatus, 'esign_sign_flow_id' => 'flow-' . $status]);
|
||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertOk();
|
||
$this->assertSoftDeleted($task);
|
||
Http::assertSentCount(++$checked);
|
||
Http::assertSent(fn ($request) => $request->method() === 'GET' && str_contains($request->url(), $task->esign_sign_flow_id));
|
||
}
|
||
}
|
||
|
||
public function test_remote_errors_and_stale_terminal_states_preserve_the_task(): void
|
||
{
|
||
$sequence = Http::fakeSequence();
|
||
foreach (['1', '2', '999', ''] as $remoteStatus) {
|
||
$sequence->push(['code' => 0, 'data' => ['signFlowStatus' => $remoteStatus]]);
|
||
}
|
||
$sequence->push(['code' => 500, 'message' => 'provider unavailable'], 500);
|
||
foreach (['1', '2', '999', ''] as $remoteStatus) {
|
||
$task = $this->task(['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'flow-' . uniqid()]);
|
||
$this->loginAs()->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])->assertStatus(422);
|
||
$this->assertFalse($task->fresh()->trashed());
|
||
}
|
||
$task = $this->task(['status' => 'revoked', 'remote_status' => '3', 'esign_sign_flow_id' => 'flow-error']);
|
||
$this->deleteJson($this->deleteUrl($task), ['delete_confirmed' => true])
|
||
->assertStatus(422)->assertJsonValidationErrors('signature');
|
||
$this->assertFalse($task->fresh()->trashed());
|
||
}
|
||
|
||
public function test_environment_mismatch_and_missing_confirmation_block_deletion(): 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']);
|
||
$this->deleteJson($this->deleteUrl($remoteTask), ['delete_confirmed' => true])->assertStatus(422);
|
||
$this->assertFalse($remoteTask->fresh()->trashed());
|
||
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_retrying_a_failed_push_blocks_concurrent_deletion(): 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])->assertStatus(422);
|
||
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->assertFalse($task->fresh()->trashed());
|
||
$this->assertSame('failed', $task->fresh()->status);
|
||
}
|
||
|
||
public function test_delete_button_is_only_shown_for_safe_tasks_in_the_active_view(): void
|
||
{
|
||
$safe = $this->task();
|
||
$signing = $this->task(['status' => 'signing', 'esign_sign_flow_id' => 'flow-active', 'remote_status' => '1']);
|
||
$this->loginAs()->get($this->pageUrl())->assertOk()
|
||
->assertSee($this->deleteUrl($safe), false)->assertDontSee($this->deleteUrl($signing) . '"', false);
|
||
$this->get($this->pageUrl() . '?agreement_scope=all')->assertOk()->assertDontSee('data-delete-task-form data-task-number=', false);
|
||
$this->get($this->pageUrl() . '?agreement_scope=archived')->assertOk()->assertDontSee('data-delete-task-form data-task-number=', false);
|
||
}
|
||
|
||
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 deleteUrl(CourierAgreementSignature $task): string
|
||
{
|
||
return route('business.courier-agreement-signatures.destroy', $task);
|
||
}
|
||
|
||
private function pageUrl(): string
|
||
{
|
||
return route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']);
|
||
}
|
||
}
|