fn_b/tests/Feature/EditorialReleaseTest.php

291 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Services\Editorial\ProcessApi;
use App\Services\Editorial\ReleaseRunner;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Http;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
class EditorialReleaseTest extends \Tests\TestCase
{
private string $releaseRoot;
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'editorial.enabled' => false, 'editorial.base_url' => 'http://localhost',
'scout.driver' => 'collection', 'responsecache.enabled' => false, 'cache.default' => 'array']);
DB::purge('sqlite');
Http::preventStrayRequests();
Schema::create('categories', function (Blueprint $t) {
$t->id();
$t->string('sSlug');
$t->string('sName');
$t->integer('iParentId')->default(0);
$t->integer('iStatus')->default(1);
$t->integer('iSort')->default(0);
$t->integer('iShowChildren')->default(0);
$t->string('sHtmlClass')->nullable();
$t->text('aOpt')->nullable();
});
Schema::create('articles', function (Blueprint $t) {
$t->id();
$t->integer('iCategoryId');
$t->integer('iUserId')->default(1);
$t->string('sSlug')->unique();
$t->string('sTitle');
$t->string('sTitleSub')->nullable();
$t->text('sContent');
$t->string('sThumb')->nullable();
$t->integer('iStatus')->default(1);
$t->boolean('bPageThumb')->default(false);
foreach (['iViews', 'iLikeCount', 'i7like', 'i365like', 'iIsTop'] as $field) {
$t->integer($field)->default(0);
}
$t->timestamp('sLastLikedAt')->nullable();
$t->timestamps();
});
Schema::create('tags', function (Blueprint $t) {
$t->id();
$t->string('sSlug');
$t->string('sName');
$t->integer('iArticleCount')->default(0);
});
Schema::create('article_tag', function (Blueprint $t) {
$t->integer('iArticleId');
$t->integer('iTagId');
});
(require database_path('migrations/2026_10_27_190000_create_editorial_tables.php'))->up();
(require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up();
(require database_path('migrations/2026_10_31_150000_add_article_display_timestamp.php'))->up();
DB::table('categories')->insert([
['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'],
['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'],
]);
DB::table('tags')->insert(['id' => 1, 'sSlug' => 'clash', 'sName' => 'Clash']);
$this->releaseRoot = sys_get_temp_dir().'/fna-release-test-'.Str::uuid();
config(['editorial.enabled' => false, 'editorial.release_directory' => $this->releaseRoot]);
}
protected function tearDown(): void
{
foreach (glob($this->releaseRoot.'/*') ?: [] as $file) {
unlink($file);
}
@rmdir($this->releaseRoot);
parent::tearDown();
}
private function bundle(): array
{
return ['operation' => 'prepare', 'release_id' => (string) Str::uuid(), 'articles' => [[
'content_key' => 'release-example', 'expected_revision' => 0, 'slug' => 'release-example',
'title' => '发布演练', 'summary' => '演练摘要', 'category_slug' => 'advanced',
'body' => '<h2 id="check">检查</h2><p>正文测试。</p>',
]]];
}
public function test_release_retry_after_lost_response_preserves_ids_and_single_publication(): void
{
$runner = app(ReleaseRunner::class);
$bundle = $this->bundle();
$prepared = $runner->run($bundle);
$repeat = $runner->run($bundle);
$this->assertSame($prepared['publication_request_id'], $repeat['publication_request_id']);
$this->assertSame(1, DB::table('editorial_documents')->value('revision'));
$this->assertFalse(config('editorial.enabled'));
$this->assertNull(config('editorial.process_credential'));
$review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]);
$this->assertStringContainsString('正文测试', $review['previews']['release-example']);
$publish = ['operation' => 'publish', 'release_id' => $bundle['release_id'],
'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']];
// Server committed, but the caller lost its response before saving the local receipt.
app(ProcessApi::class)->call('POST', 'drafts/'.$prepared['draft_id'].'/publish', [
'request_id' => $prepared['publication_request_id'], 'expected_hash' => $review['review']['hash']]);
$done = $runner->run($publish);
$this->assertSame('complete', $done['phase']);
$again = $runner->run($publish);
$this->assertSame($done['receipt'], $again['receipt']);
$this->assertSame(1, DB::table('articles')->count());
$this->assertSame(1, DB::table('editorial_publications')->count());
$this->assertFalse(config('editorial.enabled'));
$this->assertNull(config('editorial.process_credential'));
$this->getJson('/api/editorial/v1/catalog')->assertStatus(503);
}
public function test_release_changed_input_and_unreviewed_publish_fail_closed(): void
{
$runner = app(ReleaseRunner::class);
$bundle = $this->bundle();
$runner->run($bundle);
try {
$runner->run(['operation' => 'publish', 'release_id' => $bundle['release_id']]);
$this->fail('Unreviewed publication accepted');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$bundle['articles'][0]['body'] .= '变动';
try {
$runner->run($bundle);
$this->fail('Changed bundle accepted');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame(0, DB::table('articles')->count());
$this->assertFalse(config('editorial.enabled'));
}
public function test_release_lock_and_failure_restore_process_permission(): void
{
mkdir($this->releaseRoot, 0700, true);
$lock = fopen($this->releaseRoot.'/runner.lock', 'c');
flock($lock, LOCK_EX);
try {
app(ReleaseRunner::class)->run($this->bundle());
$this->fail('Concurrent release accepted');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
$bundle = $this->bundle();
$bundle['articles'][0]['category_slug'] = 'unknown';
try {
app(ReleaseRunner::class)->run($bundle);
$this->fail('Unknown category accepted');
} catch (HttpException $e) {
$this->assertSame(422, $e->getStatusCode());
}
$this->assertFalse(config('editorial.enabled'));
$this->assertNull(config('editorial.process_credential'));
$this->assertSame(0, DB::table('articles')->count());
}
public function test_release_expired_process_credential_is_rejected(): void
{
config(['editorial.enabled' => true, 'editorial.process_credential' => ['expires_at' => time() - 1]]);
$this->getJson('/api/editorial/v1/catalog')->assertStatus(401);
}
public function test_release_asset_batch_and_pending_effects_recover_without_republishing(): void
{
$bundle = $this->bundle();
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXZkAAAAASUVORK5CYII=');
$sha = hash('sha256', $bytes);
$bundle['assets'] = [['name' => 'diagram', 'base64' => base64_encode($bytes)]];
$bundle['articles'][0]['body'] .= '<img src="file:diagram" alt="示意图">';
$runner = app(ReleaseRunner::class);
try {
$runner->run($bundle);
$review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]);
$publish = ['operation' => 'publish', 'release_id' => $bundle['release_id'],
'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']];
config(['scout.driver' => null]);
$pending = $runner->run($publish);
$this->assertSame('published_pending', $pending['phase']);
$this->assertStringContainsString('/images/editorial/'.$sha.'.png', DB::table('articles')->value('sContent'));
$this->assertSame($sha, hash_file('sha256', public_path('images/editorial/'.$sha.'.png')));
config(['scout.driver' => 'collection']);
$done = $runner->run($publish);
$this->assertSame('complete', $done['phase']);
$this->assertTrue($done['readback'][0]['content_matches']);
$this->assertNotEmpty($done['published_at']);
$this->assertSame(1, DB::table('editorial_publications')->count());
// An external edit cannot be reported as verified merely because revision metadata matches.
DB::table('articles')->update(['sTitle' => '并行修改']);
$check = $runner->run(['operation' => 'status', 'release_id' => $bundle['release_id']]);
$this->assertSame('published_pending', $check['phase']);
} finally {
@unlink(app(\App\Services\Editorial\ContentRenderer::class)->privateImage($sha));
@unlink(public_path('images/editorial/'.$sha.'.png'));
}
}
public function test_release_superseded_draft_does_not_publish(): void
{
$runner = app(ReleaseRunner::class);
$bundle = $this->bundle();
$runner->run($bundle);
$review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]);
$other = $bundle;
$other['release_id'] = (string) Str::uuid();
$other['articles'][0]['expected_revision'] = 1;
$other['articles'][0]['body'] = '其他编辑的新版本';
$runner->run($other);
try {
$runner->run(['operation' => 'publish', 'release_id' => $bundle['release_id'],
'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]);
$this->fail('Superseded draft accepted');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame(0, DB::table('articles')->count());
$this->assertFalse(config('editorial.enabled'));
}
public function test_release_display_time_is_frozen_and_real_publish_time_is_preserved(): void
{
$this->travelTo(\Carbon\Carbon::parse('2026-11-02 15:37:42'));
$runner = app(ReleaseRunner::class);
$bundle = $this->bundle();
$prepared = $runner->run($bundle);
$offset = $prepared['draft']['articles'][0]['display_offset_seconds'];
$this->assertGreaterThanOrEqual(0, $offset);
$this->assertLessThanOrEqual(21600, $offset);
$this->assertSame($offset, $runner->run($bundle)['draft']['articles'][0]['display_offset_seconds']);
$review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]);
$runner->run(['operation' => 'publish', 'release_id' => $bundle['release_id'],
'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]);
$article = DB::table('articles')->first();
$this->assertSame('2026-11-02 15:37:42', $article->created_at);
$this->assertSame($article->created_at, $article->sYearLikeStartedAt);
$this->assertSame(now()->subSeconds($offset)->toDateTimeString(), $article->sDisplayPublishedAt);
$model = new \App\Models\Articles;
$model->forceFill(['created_at' => now(), 'sDisplayPublishedAt' => now()->subHours(4)->toDateTimeString()]);
$this->assertSame('4小时前', $model->sCreatedAtDiff);
$edit = $this->bundle();
$edit['articles'][0]['expected_revision'] = 1;
$edit['articles'][0]['body'] = '更新正文';
$runner->run($edit);
$review = $runner->run(['operation' => 'review', 'release_id' => $edit['release_id']]);
$runner->run(['operation' => 'publish', 'release_id' => $edit['release_id'],
'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]);
$this->assertSame($article->sDisplayPublishedAt, DB::table('articles')->value('sDisplayPublishedAt'));
$this->travelBack();
}
public function test_release_tracks_indexnow_pending_and_recovers_without_a_new_publication(): void
{
config(['editorial.indexnow_enabled' => true, 'editorial.base_url' => 'https://example.org',
'app.url' => 'https://example.org', 'app.site_code' => 'example.org', 'key.indexnow' => 'test-key-12345678']);
Http::fake(['api.indexnow.org/*' => Http::sequence()->push('', 503)->push('', 200)]);
$runner = app(ReleaseRunner::class);
$bundle = $this->bundle();
$runner->run($bundle);
$review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]);
$publish = ['operation' => 'publish', 'release_id' => $bundle['release_id'],
'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']];
$pending = $runner->run($publish);
$this->assertSame('published_pending', $pending['phase']);
$this->assertSame('pending', $pending['receipt']['effects']['indexnow']['status']);
Http::assertSentCount(1);
$this->travel(6)->minutes();
$this->artisan('editorial:indexnow-retry')->assertExitCode(0);
$done = $runner->run(['operation' => 'status', 'release_id' => $bundle['release_id']]);
$this->assertSame('complete', $done['phase']);
$this->assertSame('done', $done['receipt']['effects']['indexnow']['status']);
$this->assertSame(1, DB::table('articles')->count());
$this->assertSame(1, DB::table('editorial_publications')->count());
Http::assertSentCount(2);
}
}