'python3', 'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'), 'coruna.channel_builder.timeout' => 30, 'coruna.channel_domains' => ['fallback.test'], 'coruna.static_site.base_url' => 'https://static.test/assets', ]); } private function fakeBuildResult(array $overrides = []): string { $payload = array_merge([ 'schema_version' => 1, 'status' => 'built', 'channel_id' => self::CHANNEL_ID, 'seeds' => [ 'deployment_seed' => '11111111111111111111111111111111', 'reporting_seed' => '11111111111111111111111111111111', ], 'domains' => [ 'deployment' => ['www.dep.example'], 'reporting' => ['www.dep.example'], ], 'seeds_initialized' => true, 'sync_rebuilt' => true, 'support_path' => '/web/'.self::CHANNEL_ID.'/support.html', 'daily_path' => '/sync/daily.html', ], $overrides); return "log line\n".ChannelProjectService::RESULT_MARKER.json_encode($payload)."\n"; } #[Test] public function it_invokes_new_project_script_and_returns_seeds(): void { Process::fake([ '*' => Process::result(output: $this->fakeBuildResult()), ]); $result = app(ChannelProjectService::class)->generate(self::CHANNEL_ID, 'blank'); $this->assertTrue($result['seeds_initialized']); $this->assertSame('11111111111111111111111111111111', $result['seeds']['deployment_seed']); $this->assertSame(['www.dep.example'], $result['domains']['deployment']); $this->assertSame('/web/'.self::CHANNEL_ID.'/support.html', $result['support_path']); Process::assertRan(function ($process) { $cmd = $process->command; if (is_array($cmd)) { $joined = implode(' ', $cmd); } else { $joined = (string) $cmd; } return str_contains($joined, 'new_project.py') && str_contains($joined, self::CHANNEL_ID) && str_contains($joined, '--support-template') && str_contains($joined, 'blank'); }); } #[Test] public function it_forwards_optional_seeds_to_builder(): void { Process::fake([ '*' => Process::result(output: $this->fakeBuildResult(['seeds_initialized' => false, 'sync_rebuilt' => false])), ]); app(ChannelProjectService::class)->generate( self::CHANNEL_ID, 'test', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, '--deployment-seed') && str_contains($joined, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') && str_contains($joined, '--reporting-seed') && substr_count($joined, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') >= 2; }); } #[Test] public function it_rejects_partial_seed_pair(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('deployment_seed 与 reporting_seed 必须同时提供'); app(ChannelProjectService::class)->generate(self::CHANNEL_ID, 'test', 'only-one', null); } #[Test] public function it_rejects_mismatched_seed_pair(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('deployment_seed 与 reporting_seed 必须相同'); app(ChannelProjectService::class)->generate( self::CHANNEL_ID, 'test', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ); } #[Test] public function it_reports_builder_errors(): void { Process::fake([ '*' => Process::result(output: '', errorOutput: 'boom', exitCode: 2), ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('生成渠道资源失败: boom'); app(ChannelProjectService::class)->generate(self::CHANNEL_ID); } #[Test] public function delete_invokes_delete_script(): void { Process::fake([ '*' => Process::result(output: ChannelProjectService::RESULT_MARKER.json_encode([ 'status' => 'deleted', 'channel_id' => self::CHANNEL_ID, 'removed' => true, ])."\n"), ]); app(ChannelProjectService::class)->deleteWebTree(self::CHANNEL_ID); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'delete_channel_web.py') && str_contains($joined, self::CHANNEL_ID); }); } #[Test] public function support_links_use_online_web_path(): void { $withStored = new Channel([ 'channel_id' => self::CHANNEL_ID, 'domains' => ['channel.test'], ]); $this->assertSame([ 'https://static.test/assets/web/'.self::CHANNEL_ID.'/support.html', 'https://channel.test/web/'.self::CHANNEL_ID.'/support.html', ], $withStored->supportLinks()); $fallback = new Channel(['channel_id' => self::CHANNEL_ID, 'domains' => []]); $this->assertSame([ 'https://static.test/assets/web/'.self::CHANNEL_ID.'/support.html', 'https://fallback.test/web/'.self::CHANNEL_ID.'/support.html', ], $fallback->supportLinks()); } #[Test] public function failed_database_insert_compensates_with_delete(): void { Process::fake([ '*' => Process::sequence() ->push(Process::result(output: $this->fakeBuildResult())) ->push(Process::result(output: ChannelProjectService::RESULT_MARKER.json_encode([ 'status' => 'deleted', 'removed' => true, ])."\n")), ]); $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); } Process::assertRan(fn ($process) => str_contains( is_array($process->command) ? implode(' ', $process->command) : (string) $process->command, 'delete_channel_web.py' )); $this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]); } #[Test] public function duplicate_insert_loser_does_not_delete_winners_project(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $projects = Mockery::mock(ChannelProjectService::class); $projects->shouldReceive('generate')->once()->andReturnUsing(function (): array { Channel::query()->create([ 'channel_id' => self::CHANNEL_ID, 'user_id' => 0, 'status' => 1, ]); return [ 'channel_id' => self::CHANNEL_ID, 'seeds' => ['deployment_seed' => 'a', 'reporting_seed' => 'b'], 'domains' => ['deployment' => [], 'reporting' => []], 'seeds_initialized' => false, 'sync_rebuilt' => false, 'support_path' => '/web/'.self::CHANNEL_ID.'/support.html', 'daily_path' => '/sync/daily.html', ]; }); $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_web_tree_before_database_row(): void { Process::fake([ '*' => Process::result(output: ChannelProjectService::RESULT_MARKER.json_encode([ 'status' => 'deleted', 'removed' => true, ])."\n"), ]); $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); Process::assertRan(fn ($process) => str_contains( is_array($process->command) ? implode(' ', $process->command) : (string) $process->command, 'delete_channel_web.py' )); $this->assertDatabaseMissing('channels', ['id' => $channel->id]); } #[Test] public function destroy_keeps_database_row_when_delete_fails(): void { Process::fake([ '*' => Process::result(output: '', errorOutput: 'busy', exitCode: 1), ]); $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]); } #[Test] public function store_returns_seeds_and_domains(): void { Process::fake([ '*' => Process::result(output: $this->fakeBuildResult()), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::CHANNEL_ID, 'user_id' => 0, ]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.seeds.deployment_seed', '11111111111111111111111111111111') ->assertJsonPath('data.domains.deployment.0', 'www.dep.example') ->assertJsonPath('data.seeds_initialized', true) ->assertJsonPath('data.daily_path', '/sync/daily.html'); $this->assertDatabaseHas('channels', ['channel_id' => self::CHANNEL_ID]); $channel = Channel::query()->where('channel_id', self::CHANNEL_ID)->first(); $this->assertSame([], $channel->domains ?? []); } }