280 lines
16 KiB
PHP
280 lines
16 KiB
PHP
<?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']);
|
||
}
|
||
}
|