835 lines
42 KiB
PHP
835 lines
42 KiB
PHP
<?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,
|
||
]);
|
||
}
|
||
}
|