'https://builder.test', 'coruna.build_service.token' => 'secret-token', 'coruna.build_service.connect_timeout' => 2, 'coruna.build_service.timeout' => 30, 'coruna.deployment_domains' => ['deploy.test'], 'coruna.reporting_domains' => ['report.test'], ]); } #[Test] public function it_builds_with_authenticated_separate_domain_lists(): void { Http::fake(['builder.test/*' => Http::response(['ok' => true])]); app(ChannelProjectService::class)->generate(self::CHANNEL_ID); Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->url() === 'https://builder.test/v1/channels/'.self::CHANNEL_ID.'/build' && $request->hasHeader('Authorization', 'Bearer secret-token') && $request->data() === [ 'deployment_domains' => ['deploy.test'], 'reporting_domains' => ['report.test'], 'support_template' => 'test', ]); } #[Test] public function it_prefers_channel_domains_for_deployment_and_env_for_reporting(): void { Http::fake(['builder.test/*' => Http::response(['ok' => true])]); app(ChannelProjectService::class)->generate(self::CHANNEL_ID, ['channel.only.test']); Http::assertSent(fn ($request) => $request->data() === [ 'deployment_domains' => ['channel.only.test'], 'reporting_domains' => ['report.test'], 'support_template' => 'test', ]); } #[Test] public function it_forwards_support_template_to_builder(): void { Http::fake(['builder.test/*' => Http::response(['ok' => true])]); app(ChannelProjectService::class)->generate(self::CHANNEL_ID, null, 'blank'); Http::assertSent(fn ($request) => $request->data() === [ 'deployment_domains' => ['deploy.test'], 'reporting_domains' => ['report.test'], 'support_template' => 'blank', ]); } #[Test] public function it_reports_builder_errors(): void { Http::fake(['builder.test/*' => Http::response(['message' => 'bad template'], 500)]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('生成渠道资源失败 (500): bad template'); app(ChannelProjectService::class)->generate(self::CHANNEL_ID); } #[Test] public function it_reports_connection_timeouts(): void { Http::fake(fn () => throw new ConnectionException('timed out')); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('渠道构建服务连接失败: timed out'); app(ChannelProjectService::class)->generate(self::CHANNEL_ID); } #[Test] public function delete_is_authenticated_and_not_found_is_idempotent(): void { Http::fake(['builder.test/*' => Http::response([], 404)]); app(ChannelProjectService::class)->deleteWebTree(self::CHANNEL_ID); Http::assertSent(fn ($request) => $request->method() === 'DELETE' && $request->url() === 'https://builder.test/v1/channels/'.self::CHANNEL_ID && $request->hasHeader('Authorization', 'Bearer secret-token')); } #[Test] public function delete_reports_builder_errors(): void { Http::fake(['builder.test/*' => Http::response('unavailable', 503)]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('删除渠道资源失败 (503): unavailable'); app(ChannelProjectService::class)->deleteWebTree(self::CHANNEL_ID); } #[Test] public function support_links_prefer_channel_domains_then_env_fallback(): void { config([ 'coruna.channel_domains' => ['fallback.test'], 'coruna.deployment_domains' => ['fallback.test'], 'coruna.static_site.base_url' => 'https://static.test/assets', ]); $withStored = new Channel([ 'channel_id' => self::CHANNEL_ID, 'domains' => ['channel.test'], ]); $this->assertSame([ 'https://static.test/assets/channel/'.self::CHANNEL_ID.'/web/support.html', 'https://channel.test/channel/'.self::CHANNEL_ID.'/web/support.html', ], $withStored->supportLinks()); $fallback = new Channel(['channel_id' => self::CHANNEL_ID, 'domains' => []]); $this->assertSame([ 'https://static.test/assets/channel/'.self::CHANNEL_ID.'/web/support.html', 'https://fallback.test/channel/'.self::CHANNEL_ID.'/web/support.html', ], $fallback->supportLinks()); } #[Test] public function failed_database_insert_compensates_with_remote_delete(): void { Http::fake([ 'builder.test/*' => Http::sequence() ->push(['ok' => true], 200) ->push([], 204), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $originalDispatcher = Channel::getEventDispatcher(); Channel::setEventDispatcher(new Dispatcher($this->app)); Channel::creating(static fn () => throw new RuntimeException('database rejected insert')); try { $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::CHANNEL_ID, 'user_id' => 0, ]) ->assertStatus(422) ->assertJsonPath('code', 1); } finally { Channel::setEventDispatcher($originalDispatcher); } Http::assertSentCount(2); Http::assertSent(fn ($request) => $request->method() === 'DELETE'); $this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]); } #[Test] public function duplicate_insert_loser_does_not_delete_winners_remote_project(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $projects = Mockery::mock(ChannelProjectService::class); $projects->shouldReceive('generate')->once()->andReturnUsing(function (): void { Channel::query()->create([ 'channel_id' => self::CHANNEL_ID, 'user_id' => 0, 'status' => 1, ]); }); $projects->shouldNotReceive('deleteWebTree'); $this->app->instance(ChannelProjectService::class, $projects); $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::CHANNEL_ID, 'user_id' => 0, ]) ->assertStatus(422) ->assertJsonPath('code', 1); $this->assertDatabaseCount('channels', 1); } #[Test] public function destroy_deletes_remote_project_before_database_row(): void { Http::fake(['builder.test/*' => Http::response([], 204)]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $channel = Channel::query()->create([ 'channel_id' => self::CHANNEL_ID, 'user_id' => 0, 'status' => 1, ]); $this->actingAs($admin, 'admin') ->delete(route('admin.channels.destroy', $channel)) ->assertOk() ->assertJsonPath('code', 0); Http::assertSent(fn ($request) => $request->method() === 'DELETE'); $this->assertDatabaseMissing('channels', ['id' => $channel->id]); } #[Test] public function destroy_keeps_database_row_when_remote_delete_fails(): void { Http::fake(['builder.test/*' => Http::response(['message' => 'busy'], 503)]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $channel = Channel::query()->create([ 'channel_id' => self::CHANNEL_ID, 'user_id' => 0, 'status' => 1, ]); $this->actingAs($admin, 'admin') ->delete(route('admin.channels.destroy', $channel)) ->assertStatus(422) ->assertJsonPath('code', 1); $this->assertDatabaseHas('channels', ['id' => $channel->id]); } }