Files
coruna-lab/tests/Feature/ChannelProjectServiceTest.php
T
2026-08-14 02:59:21 +08:00

531 lines
19 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Services\ChannelProjectService;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use RuntimeException;
use Tests\TestCase;
class ChannelProjectServiceTest extends TestCase
{
use RefreshDatabase;
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
private const NEW_CHANNEL_ID = 'FAFA9988';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.channel_builder.python' => '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'],
]);
}
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', '<script src="index.js"></script>');
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
{
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 ?? []);
$this->assertSame(Channel::BUILDER_OLD, $channel->builderType());
}
#[Test]
public function it_packs_new_channel_from_staged_weifile(): void
{
$this->stageFakeWeifile();
Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([
'builder_type' => 'new',
'channel_code' => self::NEW_CHANNEL_ID,
'weifile_path' => null,
'support_path' => '',
'details_path' => '/details/',
'daily_path' => '/details/',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
'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('', $result['support_path']);
$this->assertSame('/details/', $result['daily_path']);
$this->assertSame('/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', $result['zip_path']);
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-code')
&& str_contains($joined, self::NEW_CHANNEL_ID)
&& ! str_contains($joined, 'build.py')
&& ! str_contains($joined, '--apply');
});
}
#[Test]
public function new_builder_requires_staged_weifile(): void
{
$index = storage_path('app/channel-builder-new-test/out/weifile/index.js');
if (is_file($index)) {
unlink($index);
}
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('请先运行构建脚本');
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',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
])),
]);
$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_weifile_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->supportLinks());
$this->assertSame('/weifile/weifile.html', $channel->landingPath());
}
#[Test]
public function store_new_builder_returns_zip_path(): void
{
$this->stageFakeWeifile();
Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([
'weifile_path' => null,
'support_path' => '',
'details_path' => '/details/',
'daily_path' => '/details/',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
])),
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$response = $this->actingAs($admin, 'admin')
->post(route('admin.channels.store'), [
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
])
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.builder_type', Channel::BUILDER_NEW)
->assertJsonPath('data.zip_path', '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip');
$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->assertNotEmpty($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)
&& ! str_contains($joined, 'build.py');
});
}
#[Test]
public function destroy_new_builder_deletes_zip_not_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,
]);
$zipDir = storage_path('app/channel-artifacts-test/channel-source-new');
if (! is_dir($zipDir)) {
mkdir($zipDir, 0775, true);
}
$zip = $zipDir.'/'.self::NEW_CHANNEL_ID.'.zip';
file_put_contents($zip, 'zip');
$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->assertFileDoesNotExist($zip);
$this->assertFileExists($details.'/show.html');
$this->assertDatabaseMissing('channels', ['id' => $channel->id]);
}
#[Test]
public function reserved_names_cannot_be_new_channel_ids(): void
{
$this->assertNull(Channel::normalizeNewChannelId('ADMIN'));
$this->assertNull(Channel::normalizeNewChannelId('weifile'));
$this->assertSame('FAFA9988', Channel::normalizeNewChannelId('fafa9988'));
}
}