'http://localhost', 'cache.default' => 'array', 'database.default' => 'sqlite', 'database.connections.sqlite' => [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => false, ], 'sms.driver' => 'tencentcloud', 'sms.enabled' => false, 'sms.resend_interval_seconds' => 60, 'sms.code_ttl_seconds' => 300, 'sms.tencentcloud.endpoint' => 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms', 'sms.tencentcloud.sdk_app_id' => null, 'sms.tencentcloud.app_key' => null, 'sms.tencentcloud.sign_name' => '元禾控股', 'sms.tencentcloud.template_id' => '2175650', 'sms.tencentcloud.template_param_count' => 2, ]); DB::purge(); DB::reconnect(); Cache::flush(); $this->createSchema(); } public function test_command_is_registered(): void { $this->artisan('list') ->expectsOutputToContain('sms:send-test') ->assertOk(); } public function test_disabled_sms_is_rejected_by_default_without_record_or_http_call(): void { Http::fake(); $exitCode = Artisan::call('sms:send-test', [ 'mobile' => '13800138100', ]); $this->assertSame(1, $exitCode); $this->assertStringContainsString('SMS_ENABLED=false', Artisan::output()); $this->assertSame(0, SmsVerification::query()->count()); Http::assertNothingSent(); } public function test_allow_disabled_dry_run_creates_record_without_exposing_debug_code_in_production(): void { $this->app->detectEnvironment(fn () => 'production'); Http::fake(); $exitCode = Artisan::call('sms:send-test', [ 'mobile' => '13800138101', '--allow-disabled' => true, ]); $record = SmsVerification::query()->where('mobile', '13800138101')->firstOrFail(); $output = Artisan::output(); $this->assertSame(0, $exitCode); $this->assertSame(SmsVerification::STATUS_SENT, $record->status); $this->assertSame(SmsVerification::PROVIDER_DISABLED, $record->provider); $this->assertStringContainsString('verification_id: '.$record->id, $output); $this->assertStringContainsString('provider: disabled', $output); $this->assertStringContainsString('provider_code: DISABLED_OK', $output); $this->assertStringNotContainsString('debug_code', $output); $this->assertStringNotContainsString($record->code, $output); Http::assertNothingSent(); } public function test_real_send_success_outputs_provider_sid_without_sensitive_payload(): void { $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([ 'result' => 0, 'errmsg' => 'OK', 'sid' => 'command-success-sid', 'fee' => 1, ], 200), ]); $exitCode = Artisan::call('sms:send-test', [ 'mobile' => '13800138102', '--yes' => true, ]); $output = Artisan::output(); $record = SmsVerification::query()->where('mobile', '13800138102')->firstOrFail(); $this->assertSame(0, $exitCode); $this->assertSame(SmsVerification::STATUS_SENT, $record->status); $this->assertStringContainsString('status: sent', $output); $this->assertStringContainsString('mobile: 138****8102', $output); $this->assertStringNotContainsString('13800138102', $output); $this->assertStringContainsString('provider: tencentcloud', $output); $this->assertStringContainsString('provider_code: 0', $output); $this->assertStringContainsString('provider_request_id: command-success-sid', $output); $this->assertStringNotContainsString('test-app-key', $output); $this->assertStringNotContainsString('params', $output); $this->assertStringNotContainsString('sig', $output); $this->assertStringNotContainsString($record->code, $output); Http::assertSent(function (Request $request): bool { return parse_url($request->url(), PHP_URL_HOST) === 'yun.tim.qq.com'; }); } public function test_real_send_failure_returns_non_zero_and_records_failed(): void { $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([ 'result' => 1014, 'errmsg' => 'template incorrect', 'sid' => 'command-failed-sid', ], 200), ]); $exitCode = Artisan::call('sms:send-test', [ 'mobile' => '13800138103', '--yes' => true, ]); $output = Artisan::output(); $this->assertSame(1, $exitCode); $this->assertStringContainsString('status: failed', $output); $this->assertStringContainsString('provider_code: 1014', $output); $this->assertStringContainsString('provider_request_id: command-failed-sid', $output); $this->assertDatabaseHas('sms_verifications', [ 'mobile' => '13800138103', 'status' => SmsVerification::STATUS_FAILED, 'provider_request_id' => 'command-failed-sid', 'provider_code' => '1014', ]); } public function test_http_500_with_result_zero_still_returns_failure_and_records_failed(): void { $this->enableTencentCloudConfig(); Http::fake([ 'https://yun.tim.qq.com/v5/tlssmssvr/sendsms*' => Http::response([ 'result' => 0, 'errmsg' => 'upstream unavailable', 'sid' => 'command-http-500-sid', ], 500), ]); $exitCode = Artisan::call('sms:send-test', [ 'mobile' => '13800138104', '--yes' => true, ]); $record = SmsVerification::query()->where('mobile', '13800138104')->firstOrFail(); $this->assertSame(1, $exitCode); $this->assertSame(SmsVerification::STATUS_FAILED, $record->status); $this->assertSame('HTTP_500', $record->provider_code); $this->assertSame('command-http-500-sid', $record->provider_request_id); $this->assertSame(500, $record->response_json['http_status']); $this->assertSame(0, $record->response_json['result']); } public function test_production_real_send_requires_yes_or_confirmation(): void { $this->app->detectEnvironment(fn () => 'production'); $this->enableTencentCloudConfig(); Http::fake(); $this->artisan('sms:send-test', [ 'mobile' => '13800138105', ])->expectsConfirmation('production 环境将向真实手机号发送短信,是否继续?', 'no') ->expectsOutputToContain('已取消发送。') ->assertExitCode(1); $this->assertSame(0, SmsVerification::query()->count()); Http::assertNothingSent(); } private function enableTencentCloudConfig(): void { config([ 'sms.enabled' => true, 'sms.tencentcloud.sdk_app_id' => '1400000000', 'sms.tencentcloud.app_key' => 'test-app-key', ]); } private function createSchema(): void { Schema::create('sms_verifications', function (Blueprint $table) { $table->id(); $table->string('scene', 64)->default('participant_login'); $table->string('mobile', 20)->index(); $table->string('code', 10); $table->string('provider', 32)->default('disabled'); $table->string('status', 16)->default('pending'); $table->string('template_id', 64)->nullable(); $table->string('sign_name', 64)->nullable(); $table->json('request_payload_json')->nullable(); $table->json('response_json')->nullable(); $table->string('provider_request_id', 128)->nullable(); $table->string('provider_code', 128)->nullable(); $table->string('provider_message', 500)->nullable(); $table->timestamp('expires_at'); $table->timestamp('sent_at')->nullable(); $table->timestamp('failed_at')->nullable(); $table->timestamp('used_at')->nullable(); $table->timestamps(); }); } }