CaiWuXiTong/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php

835 lines
42 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\Jobs\Business\ArchiveCourierQuoteSignature;
use App\Models\Auth\Permission;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Tests\Feature\FeatureTestCase;
class CourierQuoteSignatureWorkflowTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
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',
'services.esign.notify_url' => null,
'services.esign.initiator_accounts' => '{"默认企业":{"org_id":"org-default","psn_id":"person-default"}}',
'services.esign.default_initiator' => '默认企业',
]);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
public function test_approved_version_generates_an_immutable_private_pdf(): void
{
$version = $this->createVersion();
$signature = app(CourierQuoteSignatureService::class)->prepareVersion($version, $this->user->id);
Storage::disk('uploads')->assertExists($signature->source_pdf_path);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$this->assertStringStartsWith('%PDF-', $bytes);
$this->assertSame(hash('sha256', $bytes), $signature->source_pdf_sha256);
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->status);
$this->assertSame('待推送', $version->fresh()->signature_status);
$again = app(CourierQuoteSignatureService::class)->prepareVersion($version->fresh(), $this->user->id);
$this->assertSame($signature->id, $again->id);
$this->assertSame($signature->source_pdf_sha256, $again->source_pdf_sha256);
$dated = app(CourierQuoteSignatureService::class)->prepareVersion($version->fresh(), $this->user->id, '2026-09-21');
$this->assertSame($signature->id, $dated->id);
$this->assertNotSame($signature->source_pdf_sha256, $dated->source_pdf_sha256);
$html = view('business.pdf.courier-quote', [
'version' => $version,
'change' => $version->change,
'values' => [['省份', '面单'], ['广东省', 6]],
'formulas' => [[], []],
'partyA' => $this->company->name,
'expiryDate' => '长期',
'faceFee' => '6',
'bubbleCoefficient' => '1.2',
'signatureDate' => '2026年9月21日',
'generatedAt' => now(),
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
])->render();
$this->assertStringContainsString('签署日期:', $html);
$this->assertStringContainsString('2026年9月21日', strip_tags($html));
}
public function test_default_initiator_is_printed_as_party_a_instead_of_the_system_company(): void
{
$text = [];
$pdf = app('dompdf.wrapper');
$pdf->getDomPDF()->setCallbacks([[
'event' => 'end_frame',
'f' => static function ($frame) use (&$text): void {
if ($frame instanceof \Dompdf\FrameDecorator\Text) {
$text[] = $frame->get_text();
}
},
]]);
app()->instance('dompdf.wrapper', $pdf);
app(CourierQuoteSignatureService::class)->prepareVersion($this->createVersion(), $this->user->id);
$rendered = implode('', $text);
$this->assertStringContainsString('默认企业', $rendered);
$this->assertStringNotContainsString($this->company->name, $rendered);
}
public function test_admin_can_push_the_exact_pdf_to_esign_sandbox(): void
{
$signature = $this->createPreparedSignature();
Http::fake(function (ClientRequest $request) {
return match (true) {
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([
'code' => 0,
'data' => ['fileId' => 'file-001', 'fileUploadUrl' => 'https://upload.esign.test/file-001'],
]),
$request->url() === 'https://upload.esign.test/file-001' => Http::response(['errCode' => 0]),
$request->url() === 'https://smlopenapi.esign.test/v3/files/file-001' => Http::response([
'code' => 0,
'data' => ['status' => 2],
]),
$request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([
'code' => 0,
'data' => ['signFlowId' => 'flow-001'],
]),
default => Http::response(['code' => 404, 'message' => 'unexpected request'], 404),
};
});
$this->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'signature_date' => '2026-09-21',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('file-001', $signature->esign_file_id);
$this->assertSame('flow-001', $signature->esign_sign_flow_id);
$this->assertSame('待签署', $signature->version->fresh()->signature_status);
Http::assertSent(function (ClientRequest $request) use ($signature): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/files/file-upload-url') {
return false;
}
$payload = json_decode($request->body(), true);
$pdfBytes = Storage::disk('uploads')->get($signature->source_pdf_path);
return $request->hasHeader('X-Tsign-Open-Ca-Signature')
&& $payload['contentMd5'] === base64_encode(md5($pdfBytes, true))
&& $payload['fileName'] === 'quote-QB-900.pdf';
});
Http::assertSent(function (ClientRequest $request): bool {
if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') {
return false;
}
$payload = json_decode($request->body(), true);
return data_get($payload, 'signFlowInitiator.orgInitiator.orgId') === 'org-default'
&& data_get($payload, 'signFlowInitiator.orgInitiator.transactor.psnId') === 'person-default'
&& data_get($payload, 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司'
&& data_get($payload, 'signers.0.normalSignFieldConfig') === null
&& data_get($payload, 'signers.0.signFields.0.normalSignFieldConfig.freeMode') === true;
});
}
/** @dataProvider signerAccounts */
public function test_quote_can_be_sent_with_only_a_verified_signing_account(string $account, string $noticeType): void
{
Http::preventStrayRequests();
$signature = $this->createPreparedSignature(['operator_name' => '旧经办人']);
$organizationUrl = 'https://smlopenapi.esign.test/v3/organizations/identity-info?orgName=' . rawurlencode('新北中通快递有限公司');
$personUrl = 'https://smlopenapi.esign.test/v3/persons/identity-info?psnAccount=' . rawurlencode($account);
Http::fake([
$organizationUrl => Http::response(['code' => 0, 'data' => ['realnameStatus' => 1, 'orgId' => 'org-courier', 'authorizeUserInfo' => false]]),
$personUrl => Http::response(['code' => 0, 'data' => ['realnameStatus' => 1, 'psnId' => 'person-courier', 'authorizeUserInfo' => false]]),
'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response(['code' => 0, 'data' => ['fileId' => 'file-account', 'fileUploadUrl' => 'https://upload.esign.test/file-account']]),
'https://upload.esign.test/file-account' => Http::response(['errCode' => 0]),
'https://smlopenapi.esign.test/v3/files/file-account' => Http::response(['code' => 0, 'data' => ['status' => 2]]),
'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response(['code' => 0, 'data' => ['signFlowId' => 'flow-account']]),
]);
$this->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_account' => $account,
'notice_type' => $noticeType,
'signature_date' => '2026-09-21',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertSessionHasNoErrors()->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame($account, $signature->operator_account);
$this->assertNull($signature->operator_name);
$this->assertSame('flow-account', $signature->esign_sign_flow_id);
$requests = Http::recorded()->map(fn (array $record) => $record[0]);
$this->assertSame([$organizationUrl, $personUrl], $requests->take(2)->map->url()->all());
foreach ($requests->take(2) as $request) {
$this->assertSame('GET', $request->method());
$this->assertTrue($request->hasHeader('X-Tsign-Open-Ca-Signature'));
}
$payload = json_decode($requests->last()->body(), true);
$this->assertSame([
'orgId' => 'org-courier',
'transactorInfo' => ['psnId' => 'person-courier'],
], data_get($payload, 'signers.0.orgSignerInfo'));
$this->assertSame($noticeType, data_get($payload, 'signers.0.noticeConfig.noticeTypes'));
$this->assertSame('org-default', data_get($payload, 'signFlowInitiator.orgInitiator.orgId'));
Http::assertSentCount(6);
}
public static function signerAccounts(): array
{
return ['sms' => ['13800138000', '1'], 'email' => ['courier+sign@example.com', '2']];
}
/** @dataProvider unavailableSignerIdentities */
public function test_unverified_or_unavailable_signing_identity_stops_before_upload(array $organization, array $person, int $httpStatus, string $expectedError, int $requestCount): void
{
Http::preventStrayRequests();
$signature = $this->createPreparedSignature();
Http::fake([
'https://smlopenapi.esign.test/v3/organizations/identity-info*' => Http::response($organization),
'https://smlopenapi.esign.test/v3/persons/identity-info*' => Http::response($person, $httpStatus),
]);
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_account' => '13800138000',
'notice_type' => '1',
'signature_date' => '2026-09-21',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertSessionHasErrors(['signature' => $expectedError]);
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_FAILED, $signature->status);
$this->assertSame($expectedError, $signature->last_error);
$this->assertNull($signature->esign_file_id);
$this->assertNull($signature->esign_sign_flow_id);
Http::assertNotSent(fn (ClientRequest $request) => $request->method() !== 'GET');
Http::assertSentCount($requestCount);
}
public static function unavailableSignerIdentities(): array
{
$organization = ['code' => 0, 'data' => ['realnameStatus' => 1, 'orgId' => 'org-courier']];
$person = ['code' => 0, 'data' => ['realnameStatus' => 1, 'psnId' => 'person-courier']];
$organizationError = '签署企业尚未完成 e签宝实名认证,请填写已认证的企业全称。';
$personError = '该签署账号尚未完成 e签宝实名认证,请对方完成实名认证后重试。';
$queryError = '无法查询该 e签宝签署账号,请核对手机号或邮箱后重试。';
return [
'unverified organization' => [['code' => 0, 'data' => ['realnameStatus' => 0, 'orgId' => 'org-courier']], $person, 200, $organizationError, 1],
'missing organization id' => [['code' => 0, 'data' => ['realnameStatus' => 1]], $person, 200, $organizationError, 1],
'unverified person' => [$organization, ['code' => 0, 'data' => ['realnameStatus' => 0, 'psnId' => 'person-courier']], 200, $personError, 2],
'missing person id' => [$organization, ['code' => 0, 'data' => ['realnameStatus' => 1]], 200, $personError, 2],
'business error' => [$organization, ['code' => 1001, 'message' => 'private provider error'], 200, $queryError, 2],
'http error' => [$organization, [], 503, $queryError, 2],
];
}
public function test_quote_signing_form_uses_the_account_without_a_name_input(): void
{
$signature = $this->createPreparedSignature(['operator_account' => '13800138000']);
$this->loginAs()->get(route('business.courier-quote-changes.show', $signature->version->source_change_id))
->assertOk()
->assertSee('e签宝签署账号(手机号或邮箱)')
->assertSee('无需填写姓名')
->assertSee('13800138000')
->assertDontSee('name="operator_name"', false);
}
/** @dataProvider signerAccounts */
public function test_quote_signing_account_is_prefilled_from_the_current_courier_profile(string $account, string $noticeType): void
{
$signature = $this->createPreparedSignature();
$signature->version->update(['courier_expcode' => 'EXP-QUOTE']);
$mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
$mirror->shouldReceive('signingAccountSource')->once()->with('EXP-QUOTE', '新北中通')
->andReturn((object) ['expcode' => 'EXP-QUOTE', 'dzqzjbr' => '13900139000']);
$this->app->instance(CourierInformationMirrorRepository::class, $mirror);
\App\Models\Business\CourierProfile::create([
'company_id' => $this->company->id, 'expcode' => 'EXP-QUOTE', 'courier_name' => '新北中通',
'overrides' => ['dzqzjbr' => $account], 'created_by' => $this->user->id, 'updated_by' => $this->user->id,
]);
$html = $this->loginAs()->get(route('business.courier-quote-changes.show', $signature->version->source_change_id))
->assertOk()->assertSee('电子签章经办人')->getContent();
$this->assertStringContainsString('name="operator_account" value="' . $account . '"', $html);
$this->assertStringContainsString('name="notice_type" value="' . $noticeType . '" checked', $html);
$this->assertNull($signature->fresh()->operator_account);
}
public function test_signing_account_validation_input_is_retained_instead_of_the_profile_default(): void
{
$signature = $this->createPreparedSignature();
$mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
$mirror->shouldReceive('signingAccountSource')->once()->andReturn((object) ['expcode' => 'EXP-QUOTE', 'dzqzjbr' => '13800138000']);
$this->app->instance(CourierInformationMirrorRepository::class, $mirror);
$html = $this->loginAs()->withSession(['_old_input' => ['operator_account' => 'corrected@example.com', 'notice_type' => '2']])
->get(route('business.courier-quote-changes.show', $signature->version->source_change_id))->assertOk()->getContent();
$this->assertStringContainsString('name="operator_account" value="corrected@example.com"', $html);
$this->assertStringContainsString('name="notice_type" value="2" checked', $html);
}
public function test_missing_courier_mirror_does_not_block_manual_signing_account_entry(): void
{
$signature = $this->createPreparedSignature();
$mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
$mirror->shouldReceive('signingAccountSource')->once()->andThrow(new \RuntimeException('business_mirror_dataset_not_ready'));
$this->app->instance(CourierInformationMirrorRepository::class, $mirror);
$this->loginAs()->get(route('business.courier-quote-changes.show', $signature->version->source_change_id))
->assertOk()->assertSee('资料未维护时请补填')->assertSee('name="operator_account" value=""', false);
}
/** @dataProvider missingInitiatorConfigurations */
public function test_unmapped_quote_initiator_stops_before_upload(string $defaultInitiator): void
{
config([
'services.esign.initiator_accounts' => '{}',
'services.esign.default_initiator' => $defaultInitiator,
]);
$signature = $this->createPreparedSignature();
Http::fake();
try {
app(CourierQuoteSignatureService::class)->send($signature, [
'signing_subject' => '未配置企业',
'organization_name' => '测试快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7),
], $this->user->id);
$this->fail('An unmapped company must not fall back to the default initiator.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertStringContainsString('所选签署主体未配置完整', $exception->errors()['signature'][0]);
}
Http::assertNothingSent();
$this->assertSame(CourierQuoteSignature::STATUS_FAILED, $signature->fresh()->status);
$this->assertNull($signature->fresh()->esign_sign_flow_id);
}
public static function missingInitiatorConfigurations(): array
{
return ['default configured' => ['默认企业'], 'all settings absent' => ['']];
}
public function test_tampered_source_pdf_is_not_sent(): void
{
$signature = $this->createPreparedSignature();
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered');
Http::fake();
try {
app(CourierQuoteSignatureService::class)->send($signature, [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
], $this->user->id);
$this->fail('A tampered source PDF must not be sent.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertSame(
'待签报价 PDF 缺失或校验不一致,请重新生成后再推送。',
$exception->errors()['signature'][0],
);
}
Http::assertNothingSent();
$this->assertNull($signature->fresh()->esign_sign_flow_id);
}
public function test_quote_send_requires_a_signature_date(): void
{
$signature = $this->createPreparedSignature();
Http::fake();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])->assertSessionHasErrors('signature_date');
Http::assertNothingSent();
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->fresh()->status);
}
public function test_source_pdf_cannot_be_regenerated_after_signing_has_started(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-immutable',
'esign_sign_flow_id' => 'flow-immutable',
'remote_status' => '1',
'sent_at' => now(),
]);
Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-tampered-after-send');
try {
app(CourierQuoteSignatureService::class)->prepareVersion($signature->version, $this->user->id);
$this->fail('A sent quote must not regenerate its immutable source PDF.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertSame(
'该报价已经进入签署流程,签署原文不可重新生成。',
$exception->errors()['signature'][0],
);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status);
$this->assertSame('flow-immutable', $signature->esign_sign_flow_id);
}
public function test_non_admin_cannot_push_a_quote_to_esign(): void
{
$signature = $this->createPreparedSignature();
$operator = user_model_class()::create([
'name' => 'Quote Operator',
'email' => 'quote-operator-' . uniqid('', true) . '@example.com',
'password' => 'password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$operator->companies()->attach($this->company->id);
$operator->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
Http::fake();
$this->withExceptionHandling()->loginAs($operator, $this->company)
->post(route('business.courier-quote-signatures.send', $signature), [
'organization_name' => '新北中通快递有限公司',
'operator_name' => '张经理',
'operator_account' => '13800138000',
'notice_type' => '1',
'signature_date' => '2026-09-21',
'expires_at' => now()->addDays(7)->format('Y-m-d H:i:s'),
])
->assertForbidden();
Http::assertNothingSent();
$this->assertSame(CourierQuoteSignature::STATUS_PREPARED, $signature->fresh()->status);
}
public function test_manual_sync_downloads_signed_pdf_and_completion_certificate(): void
{
config(['services.esign.initiator_accounts' => '{invalid']);
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-001',
'esign_sign_flow_id' => 'flow-001',
'remote_status' => '1',
'sent_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/detail' => Http::response([
'code' => 0,
'data' => ['signFlowStatus' => 2],
]),
'https://smlopenapi.esign.test/v3/sign-flow/flow-001/file-download-url' => Http::response([
'code' => 0,
'data' => [
'files' => [['fileId' => 'file-001', 'fileName' => 'signed.pdf', 'downloadUrl' => 'https://download.esign.test/signed']],
'certificateDownloadUrl' => 'https://download.esign.test/certificate',
],
]),
'https://download.esign.test/signed' => Http::response('%PDF-signed-file'),
'https://download.esign.test/certificate' => Http::response('%PDF-completion-certificate'),
]);
$this->loginAs()->post(route('business.courier-quote-signatures.synchronize', $signature))
->assertRedirect(route('business.courier-quote-changes.show', $signature->version->source_change_id));
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_ARCHIVED, $signature->status);
$this->assertSame('已签署并归档', $signature->version->fresh()->signature_status);
Storage::disk('uploads')->assertExists($signature->signed_pdf_path);
Storage::disk('uploads')->assertExists($signature->certificate_path);
$this->assertSame(hash('sha256', '%PDF-signed-file'), $signature->signed_pdf_sha256);
$this->assertSame(hash('sha256', '%PDF-completion-certificate'), $signature->certificate_sha256);
}
public function test_archive_job_propagates_failures_so_the_queue_can_retry(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_COMPLETED,
'esign_file_id' => 'file-retry',
'esign_sign_flow_id' => 'flow-retry',
'remote_status' => '2',
'completed_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-retry/detail' => Http::response([], 503),
]);
try {
(new ArchiveCourierQuoteSignature($signature->id))->handle(app(CourierQuoteSignatureService::class));
$this->fail('The archive job must fail so the queue can retry it.');
} catch (\Illuminate\Validation\ValidationException $exception) {
$this->assertStringContainsString('HTTP 503', $exception->errors()['signature'][0]);
}
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertStringContainsString('HTTP 503', $signature->last_error);
$this->assertNull($signature->archived_at);
}
public function test_manual_sync_archives_when_mainland_flow_has_no_completion_certificate(): void
{
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => 'file-mainland',
'esign_sign_flow_id' => 'flow-mainland',
'remote_status' => '1',
'sent_at' => now(),
]);
Http::fake([
'https://smlopenapi.esign.test/v3/sign-flow/flow-mainland/detail' => Http::response([
'code' => 0,
'data' => ['signFlowStatus' => 2],
]),
'https://smlopenapi.esign.test/v3/sign-flow/flow-mainland/file-download-url' => Http::response([
'code' => 0,
'data' => [
'files' => [['downloadUrl' => 'https://download.esign.test/mainland-signed']],
'certificateDownloadUrl' => null,
],
]),
'https://download.esign.test/mainland-signed' => Http::response('%PDF-mainland-signed'),
]);
$result = app(CourierQuoteSignatureService::class)->synchronize($signature, $this->user->id);
$this->assertSame(CourierQuoteSignature::STATUS_ARCHIVED, $result->status);
Storage::disk('uploads')->assertExists($result->signed_pdf_path);
$this->assertNull($result->certificate_path);
$this->assertNull($result->certificate_sha256);
}
public function test_verified_completion_callback_is_idempotent_and_queues_archiving(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-callback',
'remote_status' => '1',
'sent_at' => now(),
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-callback',
'signFlowStatus' => '2',
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$timestamp = (string) now()->getTimestampMs();
$callbackSignature = hash_hmac('sha256', $timestamp . '12' . $payload, 'sandbox-app-secret');
$server = [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => $timestamp,
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => $callbackSignature,
'CONTENT_TYPE' => 'application/json',
];
$this->call(
'POST',
route('integrations.esign.callback') . '?zeta=2&alpha=1',
[],
[],
[],
$server,
$payload,
)->assertOk()->assertExactJson(['code' => '200', 'msg' => 'success']);
$signature->refresh();
$this->assertSame(CourierQuoteSignature::STATUS_COMPLETED, $signature->status);
$this->assertSame(hash('sha256', $payload), $signature->callback_payload_hash);
Queue::assertPushed(ArchiveCourierQuoteSignature::class, fn ($job): bool => $job->signatureId === $signature->id);
$this->assertNull(app(CourierQuoteSignatureService::class)->acceptCallback(
'flow-callback',
'2',
hash('sha256', $payload),
));
Queue::assertPushed(ArchiveCourierQuoteSignature::class, 1);
}
public function test_callback_with_wrong_signature_is_rejected_without_state_change(): void
{
Queue::fake();
$signature = $this->createPreparedSignature([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-invalid',
'remote_status' => '1',
]);
$payload = json_encode([
'action' => 'SIGN_FLOW_COMPLETE',
'signFlowId' => 'flow-invalid',
'signFlowStatus' => '2',
], JSON_THROW_ON_ERROR);
$this->call('POST', route('integrations.esign.callback'), [], [], [], [
'HTTP_X_TSIGN_OPEN_TIMESTAMP' => (string) now()->getTimestampMs(),
'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256',
'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id',
'HTTP_X_TSIGN_OPEN_SIGNATURE' => str_repeat('0', 64),
'CONTENT_TYPE' => 'application/json',
], $payload)
->assertUnauthorized();
$this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->fresh()->status);
Queue::assertNothingPushed();
}
public function test_deleted_expired_quote_can_be_sent_again_without_changing_its_version_or_history(): void
{
$old = $this->createPreparedSignature([
// The user may delete the expired task before its local status has been synchronized.
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_sign_flow_id' => 'flow-old',
'expires_at' => now()->subDay(),
]);
$version = $old->version;
$snapshot = array_intersect_key($version->getRawOriginal(), array_flip(['version_code', 'values_json', 'formulas_json', 'effective_date', 'status']));
$oldBytes = Storage::disk('uploads')->get($old->source_pdf_path);
$old->delete();
Http::fake(function (ClientRequest $request) {
return match (true) {
str_ends_with($request->url(), '/flow-old/detail') => Http::response(['code' => 0, 'data' => ['signFlowStatus' => 5]]),
str_ends_with($request->url(), '/file-upload-url') => Http::response([
'code' => 0, 'data' => ['fileId' => 'file-new', 'fileUploadUrl' => 'https://upload.esign.test/file-new'],
]),
$request->url() === 'https://upload.esign.test/file-new' => Http::response(['errCode' => 0]),
str_ends_with($request->url(), '/files/file-new') => Http::response(['code' => 0, 'data' => ['status' => 2]]),
str_ends_with($request->url(), '/create-by-file') => Http::response(['code' => 0, 'data' => ['signFlowId' => 'flow-new']]),
str_ends_with($request->url(), '/flow-new/detail') => Http::response(['code' => 0, 'data' => ['signFlowStatus' => 2]]),
str_ends_with($request->url(), '/flow-new/file-download-url') => Http::response([
'code' => 0, 'data' => ['files' => [['downloadUrl' => 'https://files.esign.test/new.pdf']]],
]),
$request->url() === 'https://files.esign.test/new.pdf' => Http::response('%PDF-new-signed'),
default => Http::response(['code' => 404, 'message' => 'unexpected request'], 404),
};
});
$this->loginAs()->post(route('business.courier-quote-signatures.prepare', $version), [
'signature_date' => today()->toDateString(),
])->assertRedirect()->assertSessionHasNoErrors();
$new = $version->fresh()->signature;
$this->assertNotSame($old->id, $new->id);
$this->assertNotSame($old->source_pdf_path, $new->source_pdf_path);
$this->assertSoftDeleted($old);
$this->assertSame('flow-old', $old->fresh()->esign_sign_flow_id);
$this->assertSame(2, CourierQuoteSignature::withTrashed()->where('quote_version_id', $version->id)->count());
$this->assertSame($snapshot, array_intersect_key($version->fresh()->getRawOriginal(), $snapshot));
$this->post(route('business.courier-quote-signatures.send', $new), [
'organization_name' => '新北中通快递有限公司', 'operator_name' => '张经理',
'operator_account' => '13800138000', 'notice_type' => '1',
'signature_date' => today()->toDateString(), 'expires_at' => now()->addDays(7)->toDateTimeString(),
])->assertRedirect()->assertSessionHasNoErrors();
$this->assertSame('flow-new', $new->fresh()->esign_sign_flow_id);
$this->assertSame('signing', $new->fresh()->status);
$this->assertSame($oldBytes, Storage::disk('uploads')->get($old->source_pdf_path));
$this->assertNull(app(CourierQuoteSignatureService::class)->acceptCallback('flow-old', '2', 'late-old-callback'));
$this->assertSame('待签署', $version->fresh()->signature_status);
// An old polling request can finish after deletion and after a new task was created.
app(CourierQuoteSignatureService::class)->synchronize($old, $this->user->id);
$this->assertSame('待签署', $version->fresh()->signature_status);
app(CourierQuoteSignatureService::class)->synchronize($new->fresh(), $this->user->id);
$new->refresh();
$this->assertSame('archived', $new->status);
$this->assertSame(dirname($new->source_pdf_path) . '/signed.pdf', $new->signed_pdf_path);
$this->assertSame('%PDF-new-signed', Storage::disk('uploads')->get($new->signed_pdf_path));
$this->assertSame($oldBytes, Storage::disk('uploads')->get($old->source_pdf_path));
$this->assertSame($snapshot, array_intersect_key($version->fresh()->getRawOriginal(), $snapshot));
}
public function test_reissue_is_blocked_when_the_old_remote_flow_is_active_completed_unknown_or_unavailable(): void
{
$this->withExceptionHandling();
$old = $this->createPreparedSignature([
'status' => 'expired', 'esign_sign_flow_id' => 'flow-old', 'expires_at' => now()->subDay(),
]);
$old->delete();
Http::fake(['*/flow-old/detail' => Http::sequence()
->push(['code' => 0, 'data' => ['signFlowStatus' => 1]])
->push(['code' => 0, 'data' => ['signFlowStatus' => 2]])
->push(['code' => 0, 'data' => []])
->push(['code' => 500, 'message' => 'unavailable'], 500)]);
for ($attempt = 0; $attempt < 4; $attempt++) {
$this->loginAs()->from('/retry-test')->post(route('business.courier-quote-signatures.prepare', $old->version), [
'signature_date' => today()->toDateString(),
])->assertRedirect('/retry-test')->assertSessionHasErrors('signature');
$this->assertSame(0, CourierQuoteSignature::count());
$this->assertSame(1, CourierQuoteSignature::withTrashed()->count());
}
}
public function test_deleted_signed_quote_cannot_be_reissued_and_environment_must_match(): void
{
$this->withExceptionHandling();
Http::fake();
$old = $this->createPreparedSignature(['status' => 'archived', 'esign_sign_flow_id' => 'flow-signed']);
$old->delete();
$this->loginAs()->post(route('business.courier-quote-signatures.prepare', $old->version), [
'signature_date' => today()->toDateString(),
])->assertSessionHasErrors('signature');
$old->update(['status' => 'expired', 'environment' => 'production']);
$this->post(route('business.courier-quote-signatures.prepare', $old->version), [
'signature_date' => today()->toDateString(),
])->assertSessionHasErrors('signature');
$this->assertSame(0, CourierQuoteSignature::count());
Http::assertNothingSent();
}
public function test_repeated_preparation_reuses_the_active_attempt_and_database_rejects_a_second_active_task(): void
{
$old = $this->createPreparedSignature(['status' => 'expired', 'esign_sign_flow_id' => 'flow-old']);
$old->delete();
Http::fake(['*/flow-old/detail' => Http::response(['code' => 0, 'data' => ['signFlowStatus' => 5]])]);
$service = app(CourierQuoteSignatureService::class);
$new = $service->prepareVersion($old->version, $this->user->id);
$again = $service->prepareVersion($old->version, $this->user->id);
$this->assertSame($new->id, $again->id);
$this->assertSame(1, CourierQuoteSignature::count());
Http::assertSentCount(1);
$migration = require database_path('migrations/2026_11_06_120000_allow_courier_quote_signature_reissues.php');
try {
$migration->down();
$this->fail('Rollback must not remove signing history to restore the old unique index.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('多轮签署历史', $exception->getMessage());
}
$this->expectException(\Illuminate\Database\QueryException::class);
$duplicate = $new->replicate(['active_quote_version_id']);
$duplicate->save();
}
public function test_reissue_migration_preserves_existing_records_and_can_reverse_before_reissues(): void
{
$migration = require database_path('migrations/2026_11_06_120000_allow_courier_quote_signature_reissues.php');
$migration->down();
$old = $this->createPreparedSignature(['status' => 'expired', 'esign_sign_flow_id' => 'flow-old']);
$original = $old->fresh()->getRawOriginal();
$migration->up();
$upgraded = $old->fresh();
$this->assertSame($original, array_intersect_key($upgraded->getRawOriginal(), $original));
$this->assertSame($old->quote_version_id, $upgraded->active_quote_version_id);
$migration->down();
$this->assertSame($original, $old->fresh()->getRawOriginal());
$migration->up();
}
private function createPreparedSignature(array $overrides = []): CourierQuoteSignature
{
$version = $this->createVersion();
$path = "courier-quotes/{$this->company->id}/QB-900/source.pdf";
$bytes = '%PDF-source-quote';
Storage::disk('uploads')->put($path, $bytes);
return CourierQuoteSignature::create(array_merge([
'company_id' => $this->company->id,
'quote_version_id' => $version->id,
'provider' => 'esign',
'environment' => 'sandbox',
'status' => CourierQuoteSignature::STATUS_PREPARED,
'organization_name' => '新北中通',
'source_pdf_path' => $path,
'source_pdf_sha256' => hash('sha256', $bytes),
'source_pdf_generated_at' => now(),
'created_by' => $this->user->id,
'updated_by' => $this->user->id,
], $overrides));
}
private function createVersion(): CourierQuoteVersion
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-' . now()->format('Ymd') . '-' . str_pad((string) (CourierQuoteChange::count() + 1), 3, '0', STR_PAD_LEFT),
'source_quote_id' => 'QB-001',
'source_sheet_name' => '新北中通报价',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => now()->addDay()->toDateString(),
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '广东省面单价格调整。',
'created_by' => $this->user->id,
'submitted_by' => $this->user->id,
'submitted_at' => now(),
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
$change->items()->create([
'company_id' => $this->company->id,
'position' => 0,
'source_cell' => 'B2',
'source_row' => 1,
'source_column' => 1,
'region' => '广东省',
'weight_band' => '面单',
'charge_item' => '面单',
'original_amount' => '5.0000',
'new_amount' => '6.0000',
'unit' => '元/单',
'note' => '快递通知调价',
]);
return CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => 'QB-900',
'source_quote_id' => 'QB-001',
'source_change_id' => $change->id,
'sheet_name' => '新北中通报价',
'company_name' => '新北中通',
'carrier_name' => '新北中通',
'quote_type' => '标准报价',
'effective_date' => now()->addDay()->toDateString(),
'source_range' => 'A1:C2',
'values_json' => json_encode([['省份', '面单', '续重'], ['广东省', 6.0, 1.2]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[], []], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
}
}