291 lines
14 KiB
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);
|
|
}
|
|
|
|
}
|