'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' => '

检查

正文测试。

', ]]]; } 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'] .= '示意图'; $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); } }