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