'python3', 'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'), 'coruna.channel_builder.timeout' => 30, 'coruna.channel_builder_new.python' => 'python3', 'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'), 'coruna.channel_domains' => ['fallback.test'], 'coruna.xxbb.channel_c' => '33333333333333333333333333333333', ]); } 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"; } private function stageFakeWeifile(): string { $dir = storage_path('app/channel-builder-new-test/out/weifile'); if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) { $this->fail('unable to create staged weifile dir'); } file_put_contents($dir.'/index.js', 'const V="CACACACA";'); file_put_contents($dir.'/weifile.html', ''); $details = storage_path('app/channel-artifacts-test/details'); if (! is_dir($details) && ! mkdir($details, 0775, true) && ! is_dir($details)) { $this->fail('unable to create details dir'); } file_put_contents($details.'/show.html', 'show'); return $dir; } #[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://channel.test/web/'.self::CHANNEL_ID.'/support.html', ], $withStored->supportLinks()); $fallback = new Channel(['channel_id' => self::CHANNEL_ID, 'domains' => []]); $this->assertSame([ 'https://fallback.test/web/'.self::CHANNEL_ID.'/support.html', ], $fallback->supportLinks()); } #[Test] public function failed_database_insert_compensates_with_delete(): void { $this->stageFakeWeifile(); Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', 'channel_id' => self::NEW_CHANNEL_ID, 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, ])), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $channelDir = storage_path('app/channel-artifacts-test/channel/'.self::NEW_CHANNEL_ID); if (! is_dir($channelDir) && ! mkdir($channelDir, 0775, true) && ! is_dir($channelDir)) { $this->fail('unable to create channel dir for compensation test'); } $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::NEW_CHANNEL_ID, 'user_id' => 0, ]) ->assertStatus(422) ->assertJsonPath('code', 1); } finally { Channel::setEventDispatcher($originalDispatcher); } $this->assertDirectoryDoesNotExist($channelDir); $this->assertDatabaseMissing('channels', ['channel_id' => self::NEW_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::NEW_CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, 'user_id' => 0, 'status' => 1, ]); return [ 'channel_id' => self::NEW_CHANNEL_ID, 'seeds' => ['deployment_seed' => 'a', 'reporting_seed' => 'b'], 'domains' => ['deployment' => [], 'reporting' => []], 'seeds_initialized' => false, 'sync_rebuilt' => false, 'support_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', ]; }); $projects->shouldNotReceive('deleteWebTree'); $this->app->instance(ChannelProjectService::class, $projects); $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::NEW_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 { $this->stageFakeWeifile(); Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', 'channel_id' => self::NEW_CHANNEL_ID, 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, ])), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::NEW_CHANNEL_ID, 'user_id' => 0, ]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.builder_type', Channel::BUILDER_NEW) ->assertJsonPath('data.seeds.deployment_seed', '11111111111111111111111111111111') ->assertJsonPath('data.domains.deployment.0', 'www.dep.example') ->assertJsonPath('data.daily_path', '/channel/'.self::NEW_CHANNEL_ID.'/details/'); $this->assertDatabaseHas('channels', [ 'channel_id' => self::NEW_CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, ]); $channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first(); $this->assertSame([], $channel->domains ?? []); $this->assertSame(Channel::BUILDER_NEW, $channel->builderType()); } #[Test] public function store_rejects_old_builder_channel_id(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::CHANNEL_ID, 'builder_type' => Channel::BUILDER_OLD, 'user_id' => 0, ]) ->assertSessionHasErrors('channel_id'); $this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]); } #[Test] public function it_packs_new_channel_from_staged_weifile(): void { $this->stageFakeWeifile(); Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', 'channel_id' => self::NEW_CHANNEL_ID, 'channel_ver' => self::NEW_CHANNEL_ID, 'weifile_path' => null, 'support_path' => '', 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, 'domains' => ['deployment' => [], 'reporting' => []], ])), ]); $result = app(ChannelProjectService::class)->generate( self::NEW_CHANNEL_ID, 'test', null, null, Channel::BUILDER_NEW, ); $this->assertSame(Channel::BUILDER_NEW, $result['builder_type']); $this->assertNull($result['weifile_path']); $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $result['support_path']); $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/details/', $result['daily_path']); $this->assertSame('/channel/'.self::NEW_CHANNEL_ID, $result['channel_dir']); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'build.py') && str_contains($joined, '--channel-c') && str_contains($joined, '33333333333333333333333333333333') && str_contains($joined, '--apply') && ! str_contains($joined, '--random-c'); }); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'pack_channel.py') && str_contains($joined, '--channel-ver') && str_contains($joined, self::NEW_CHANNEL_ID) && str_contains($joined, '--channel-out') && str_contains($joined, '--landing-template') && str_contains($joined, 'test'); }); } #[Test] public function new_builder_requires_env_channel_c(): void { config(['coruna.xxbb.channel_c' => '']); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('XXBB_CHANNEL_C'); app(ChannelProjectService::class)->generate( self::NEW_CHANNEL_ID, 'test', null, null, Channel::BUILDER_NEW, ); } #[Test] public function new_builder_ignores_seed_mismatch(): void { $this->stageFakeWeifile(); Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, ])), ]); $result = app(ChannelProjectService::class)->generate( self::NEW_CHANNEL_ID, 'test', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', Channel::BUILDER_NEW, ); $this->assertSame(self::NEW_CHANNEL_ID, $result['channel_id']); } #[Test] public function support_links_use_channel_path_for_new_builder(): void { $channel = new Channel([ 'channel_id' => self::NEW_CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, 'domains' => ['channel.test'], ]); $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $channel->landingPath()); $this->assertSame(['https://channel.test/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html'], $channel->supportLinks()); } #[Test] public function store_new_builder_returns_channel_dir(): void { $this->stageFakeWeifile(); Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'weifile_path' => null, 'support_path' => '', 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, ])), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $response = $this->actingAs($admin, 'admin') ->post(route('admin.channels.store'), [ 'channel_id' => self::NEW_CHANNEL_ID, 'user_id' => 0, ]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.builder_type', Channel::BUILDER_NEW) ->assertJsonPath('data.channel_dir', '/channel/'.self::NEW_CHANNEL_ID) ->assertJsonPath('data.support_path', '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html'); $channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first(); $this->assertNotNull($channel); $this->assertSame(Channel::BUILDER_NEW, $channel->builderType()); $this->assertNull($response->json('data.weifile_path')); $this->assertNull($response->json('data.zip_path')); $this->assertNull($response->json('data.download_url')); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'pack_channel.py') && str_contains($joined, self::NEW_CHANNEL_ID); }); } #[Test] public function destroy_new_builder_deletes_channel_dir_not_shared_details(): void { Process::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $channel = Channel::query()->create([ 'channel_id' => self::NEW_CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, 'user_id' => 0, 'status' => 1, ]); $channelDir = storage_path('app/channel-artifacts-test/channel/'.self::NEW_CHANNEL_ID); if (! is_dir($channelDir.'/weifile') && ! mkdir($channelDir.'/weifile', 0775, true) && ! is_dir($channelDir.'/weifile')) { $this->fail('unable to create channel weifile dir'); } if (! is_dir($channelDir.'/details') && ! mkdir($channelDir.'/details', 0775, true) && ! is_dir($channelDir.'/details')) { $this->fail('unable to create channel dir'); } file_put_contents($channelDir.'/weifile/weifile.html', 'landing'); $details = storage_path('app/channel-artifacts-test/details'); if (! is_dir($details)) { mkdir($details, 0775, true); } file_put_contents($details.'/show.html', 'keep'); $this->actingAs($admin, 'admin') ->delete(route('admin.channels.destroy', $channel)) ->assertOk() ->assertJsonPath('code', 0); Process::assertDidntRun(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'delete_channel.py') || str_contains($joined, 'delete_channel_web.py'); }); $this->assertDirectoryDoesNotExist($channelDir); $this->assertFileExists($details.'/show.html'); $this->assertDatabaseMissing('channels', ['id' => $channel->id]); } #[Test] public function it_rebuilds_existing_new_channels_with_shared_build_once(): void { $this->stageFakeWeifile(); Channel::query()->create([ 'channel_id' => '0.0.01', 'builder_type' => Channel::BUILDER_NEW, 'user_id' => 0, 'status' => 1, ]); Channel::query()->create([ 'channel_id' => self::NEW_CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, 'user_id' => 0, 'status' => 1, ]); Channel::query()->create([ 'channel_id' => self::CHANNEL_ID, 'builder_type' => Channel::BUILDER_OLD, 'user_id' => 0, 'status' => 1, ]); Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', 'landing_path' => '/channel/0.0.01/weifile/weifile.html', 'details_path' => '/channel/0.0.01/details/', 'channel_dir' => '/channel/0.0.01', ])), ]); $result = app(ChannelProjectService::class)->rebuildNewChannels(); $this->assertSame(['0.0.01', self::NEW_CHANNEL_ID], array_column($result['channels'], 'channel_id')); $this->assertArrayHasKey('shared', $result); Process::assertRanTimes(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'build.py') && str_contains($joined, '--channel-c'); }, 1); Process::assertRanTimes(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'pack_channel.py'); }, 2); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'pack_channel.py') && str_contains($joined, '0.0.01'); }); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'pack_channel.py') && str_contains($joined, self::NEW_CHANNEL_ID); }); } #[Test] public function version_format_required_for_new_channel_ids(): void { $this->assertNull(Channel::normalizeNewChannelId('FAFA9988')); $this->assertNull(Channel::normalizeNewChannelId('2.2.6')); $this->assertNull(Channel::normalizeNewChannelId('22.2.66')); $this->assertSame('3.1.07', Channel::normalizeNewChannelId('3.1.07')); $this->assertSame('2.2.66', Channel::normalizeNewChannelId('2.2.66')); $this->assertSame('A.B.C1', Channel::normalizeNewChannelId('a.b.c1')); $this->assertSame('X.9.Z0', Channel::normalizeNewChannelId('x.9.z0')); } }