make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
return $app;
}
private string $root;
protected function setUp(): void
{
parent::setUp();
\Illuminate\Support\Facades\URL::forceRootUrl('http://ttc.loc');
Http::preventStrayRequests();
$this->root = sys_get_temp_dir().'/ttc-content-test-'.Str::uuid();
File::makeDirectory($this->root.'/vuepress/node_modules/.bin', 0755, true);
File::makeDirectory($this->root.'/vuepress/docs/jccp', 0755, true);
File::makeDirectory($this->root.'/laravel/storage/app/weekly-ranking', 0755, true);
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', ' 'local', 'content_center.enabled' => true, 'content_center.atom_key' => str_repeat('k', 40), 'content_center.atom_url' => 'http://atom.loc', 'content_center.site_root' => $this->root, 'content_center.node' => PHP_BINARY, 'cache.default' => 'array']);
}
protected function tearDown(): void
{
File::deleteDirectory($this->root);
parent::tearDown();
}
private function input(int $generation = 1): array
{
return ['source_key' => 'telegram:-1001234567890:album:99', 'revision' => str_repeat('a', 64), 'generation' => $generation, 'title' => '测评标题', 'text' => ' {{ danger }}', 'sent_at' => '2026-10-24 08:01:01', 'images' => []];
}
private function signed(string $path, array $data, ?string $nonce = null)
{
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$time = (string) time();
$nonce ??= bin2hex(random_bytes(16));
$sig = hash_hmac('sha256', $time."\n".$nonce."\nPOST\n".$path."\n".hash('sha256', $body), str_repeat('k', 40));
return $this->call('POST', $path, [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_CONTENT_TIME' => $time, 'HTTP_X_CONTENT_NONCE' => $nonce, 'HTTP_X_CONTENT_ENVIRONMENT' => 'local', 'HTTP_X_CONTENT_SIGNATURE' => $sig], $body);
}
public function test_preview_authenticates_and_does_not_write_or_build(): void
{
$this->postJson('/api/content/v1/preview', $this->input())->assertUnauthorized();
$nonce = bin2hex(random_bytes(16));
$response = $this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertOk();
$markdown = $response->json('artifact.markdown');
$this->assertStringContainsString('<script>', $markdown);
$this->assertStringContainsString('
', $markdown);
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
$this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertStatus(409);
}
public function test_legacy_receive_cannot_bypass_content_review(): void
{
$this->postJson('/api/article/receive/schema', ['cmd' => 'new'])->assertStatus(410);
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
}
public function test_wrong_hash_cannot_publish(): void
{
$this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => str_repeat('0', 64), 'input' => $this->input()])->assertStatus(409);
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
}
public function test_approved_publication_is_idempotent_and_old_generation_cannot_overwrite_new(): void
{
$input = $this->input(20);
$artifact = app(ArticleArtifact::class)->build($input);
$payload = ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input];
$one = $this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.status', 'published');
$this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.approval_id', $payload['approval_id']);
$this->assertSame(1, count(File::files($this->root.'/vuepress/docs/jccp')));
$old = $this->input(10);
$this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash(app(ArticleArtifact::class)->build($old)), 'input' => $old])->assertStatus(409);
}
public function test_failed_build_restores_source_and_preserves_previous_dist(): void
{
File::makeDirectory($this->root.'/vuepress/docs/.vuepress/dist', 0755, true);
file_put_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html', 'previous');
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', 'input();
$artifact = app(ArticleArtifact::class)->build($input);
try {
app(Publication::class)->publish(['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]);
$this->fail('Expected build failure');
} catch (\RuntimeException) {
$this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']);
$this->assertSame('previous', file_get_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html'));
}
}
}