fix: ios 16

This commit is contained in:
hashbro
2026-08-09 21:57:08 +08:00
parent 6bf33e2761
commit 6bc172c570
179 changed files with 6246 additions and 400 deletions
+191 -106
View File
@@ -7,8 +7,7 @@ use App\Models\Channel;
use App\Services\ChannelProjectService;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use RuntimeException;
@@ -25,137 +24,176 @@ class ChannelProjectServiceTest extends TestCase
parent::setUp();
config([
'coruna.build_service.url' => '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'],
'coruna.channel_builder.python' => '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',
]);
}
#[Test]
public function it_builds_with_authenticated_separate_domain_lists(): void
private function fakeBuildResult(array $overrides = []): string
{
Http::fake(['builder.test/*' => Http::response(['ok' => true])]);
$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);
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',
]);
return "log line\n".ChannelProjectService::RESULT_MARKER.json_encode($payload)."\n";
}
#[Test]
public function it_prefers_channel_domains_for_deployment_and_env_for_reporting(): void
public function it_invokes_new_project_script_and_returns_seeds(): 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',
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_support_template_to_builder(): void
public function it_forwards_optional_seeds_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',
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
{
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',
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/channel/'.self::CHANNEL_ID.'/web/support.html',
'https://channel.test/channel/'.self::CHANNEL_ID.'/web/support.html',
'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/channel/'.self::CHANNEL_ID.'/web/support.html',
'https://fallback.test/channel/'.self::CHANNEL_ID.'/web/support.html',
'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_remote_delete(): void
public function failed_database_insert_compensates_with_delete(): void
{
Http::fake([
'builder.test/*' => Http::sequence()
->push(['ok' => true], 200)
->push([], 204),
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']);
@@ -175,22 +213,34 @@ class ChannelProjectServiceTest extends TestCase
Channel::setEventDispatcher($originalDispatcher);
}
Http::assertSentCount(2);
Http::assertSent(fn ($request) => $request->method() === 'DELETE');
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_remote_project(): void
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 (): void {
$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);
@@ -207,9 +257,14 @@ class ChannelProjectServiceTest extends TestCase
}
#[Test]
public function destroy_deletes_remote_project_before_database_row(): void
public function destroy_deletes_web_tree_before_database_row(): void
{
Http::fake(['builder.test/*' => Http::response([], 204)]);
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,
@@ -222,14 +277,19 @@ class ChannelProjectServiceTest extends TestCase
->assertOk()
->assertJsonPath('code', 0);
Http::assertSent(fn ($request) => $request->method() === 'DELETE');
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_remote_delete_fails(): void
public function destroy_keeps_database_row_when_delete_fails(): void
{
Http::fake(['builder.test/*' => Http::response(['message' => 'busy'], 503)]);
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,
@@ -244,4 +304,29 @@ class ChannelProjectServiceTest extends TestCase
$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 ?? []);
}
}