'sqlite', 'database.connections.sqlite.database' => ':memory:', 'editorial.enabled' => true, 'editorial.base_url' => 'http://localhost', 'scout.driver' => 'collection', 'responsecache.enabled' => false, 'cache.default' => 'array']); DB::purge('sqlite'); $this->credential = tempnam(sys_get_temp_dir(), 'editorial-test-'); $this->token = 'fna_ed_'.str_repeat('a', 64); file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'testing', 'base_url' => 'http://localhost', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]])); config(['editorial.credential_file' => $this->credential]); 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(); 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->withHeaders(['Authorization' => 'Bearer '.$this->token]); } protected function tearDown(): void { @unlink($this->credential); foreach ($this->uploaded as $sha) { @unlink(app(ContentRenderer::class)->privateImage($sha)); foreach (glob(public_path('images/editorial/'.$sha.'.*')) as $path) { @unlink($path); } } parent::tearDown(); } private function item(string $key = 'example', int $revision = 0): array { return ['content_key' => $key, 'expected_revision' => $revision, 'slug' => $key, 'title' => '教程标题', 'summary' => '一篇操作教程', 'category_slug' => 'windows', 'body' => '
'], ['body', 'x'],
['category_slug', 'freenode'], ['tag_ids', [999]],
] as [$field, $value]) {
$bad = $this->item('bad');
$bad[$field] = $value;
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$this->item('good'), $bad]])->assertUnprocessable();
$this->assertSame(0, DB::table('editorial_documents')->count());
$this->assertSame(0, DB::table('editorial_drafts')->count());
}
}
public function test_explicit_adoption_preserves_identity_counters_and_original_revision(): void
{
$id = DB::table('articles')->insertGetId(['sSlug' => 'existing', 'sTitle' => '旧文', 'sTitleSub' => '旧摘要', 'sContent' => '旧正文
', 'iCategoryId' => 3, 'iLikeCount' => 19, 'iViews' => 30, 'created_at' => '2020-01-01 00:00:00']); $item = $this->item('existing'); $this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$item]])->assertConflict(); $item['adopt_article_id'] = $id; $d = $this->draft([$item]); $this->assertSame('旧文', DB::table('articles')->value('sTitle')); $this->publish($d)->assertOk(); $this->assertSame($id, DB::table('editorial_documents')->value('article_id')); $this->assertSame(19, DB::table('articles')->value('iLikeCount')); $this->assertSame('2020-01-01 00:00:00', DB::table('articles')->value('created_at')); $this->assertSame(2, DB::table('editorial_versions')->count()); $this->getJson('/api/editorial/v1/articles/existing/versions/0')->assertOk()->assertJsonPath('content.title', '旧文'); } public function test_external_edit_and_removed_target_prevent_publish_without_partial_changes(): void { $first = $this->draft([$this->item()]); $this->publish($first)->assertOk(); $d = $this->draft([$this->item('example', 1), $this->item('another')]); DB::table('articles')->where('sSlug', 'example')->update(['sTitle' => '并行修改']); $this->publish($d)->assertConflict(); $this->assertSame(1, DB::table('articles')->count()); $this->assertSame('并行修改', DB::table('articles')->value('sTitle')); } public function test_existing_incoming_anchor_cannot_be_broken(): void { $d = $this->draft([$this->item('target')]); $this->publish($d)->assertOk(); DB::table('articles')->insert(['sSlug' => 'legacy', 'sTitle' => '旧入口', 'sContent' => '安装', 'iCategoryId' => 3]); $changed = $this->item('target', 1); $changed['body'] = '删除原锚点
'; $next = $this->draft([$changed]); $this->publish($next)->assertUnprocessable(); $this->assertStringContainsString('id="install"', DB::table('articles')->where('sSlug', 'target')->value('sContent')); } public function test_rollback_creates_new_reviewable_revision_and_preserves_url(): void { $one = $this->draft([$this->item()]); $this->publish($one)->assertOk(); $changed = $this->item('example', 1); $changed['title'] = '第二版'; $two = $this->draft([$changed]); $this->publish($two)->assertOk(); $request = ['request_id' => (string) Str::uuid(), 'target_revision' => 1, 'expected_revision' => 2]; $rollback = $this->postJson('/api/editorial/v1/articles/example/rollback', $request)->assertCreated()->json(); $this->postJson('/api/editorial/v1/articles/example/rollback', $request)->assertCreated(); $this->assertSame('第二版', DB::table('articles')->value('sTitle')); $this->publish($rollback)->assertOk()->assertJsonPath('articles.0.revision', 3); $this->assertSame('教程标题', DB::table('articles')->value('sTitle')); $this->assertSame('example', DB::table('articles')->value('sSlug')); } public function test_uploaded_images_stay_private_until_publication_and_code_newlines_survive(): void { // Valid raster fixture, no external fetch or image toolkit required. $bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQ0AAAAASUVORK5CYII='); $file = UploadedFile::fake()->createWithContent('pixel.png', $bytes); $asset = $this->post('/api/editorial/v1/assets', ['image' => $file])->assertCreated()->json(); $this->uploaded[] = $asset['sha256']; $this->assertFileDoesNotExist(public_path('images/editorial/'.$asset['sha256'].'.png')); $item = $this->item(); $item['body'] .= "\n\n\n\n```yaml\none: 1\ntwo: 2\n```"; $d = $this->draft([$item]); $this->get($d['articles'][0]['preview_url'])->assertOk()->assertSee('preview-assets'); $this->publish($d)->assertOk(); $this->assertFileExists(public_path('images/editorial/'.$asset['sha256'].'.png')); $body = DB::table('articles')->value('sContent'); $this->assertStringContainsString(' ', $body); $this->assertStringNotContainsString("\n", $body); $this->assertStringContainsString('/images/editorial/', $body); } public function test_search_failure_returns_published_receipt_and_can_be_retried(): void { config(['scout.driver' => 'unsupported']); $d = $this->draft([$this->item()]); $r = $this->publish($d)->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('effects.search.status', 'pending')->json(); config(['scout.driver' => 'collection']); $this->postJson('/api/editorial/v1/publications/'.$r['publication_id'].'/synchronize')->assertOk()->assertJsonPath('effects.search.status', 'done'); $this->assertSame(1, DB::table('articles')->count()); } public function test_preview_privacy_even_when_response_cache_is_enabled(): void { config(['responsecache.enabled' => true]); $d = $this->draft([$this->item()]); $this->get($d['articles'][0]['preview_url'])->assertOk(); $this->withHeaders(['Authorization' => 'Bearer '.$this->token])->getJson('/api/editorial/v1/catalog')->assertOk(); $this->withHeaders(['Authorization' => 'Bearer invalid'])->getJson('/api/editorial/v1/catalog')->assertUnauthorized(); config(['editorial.enabled' => false]); $this->get($d['articles'][0]['preview_url'])->assertStatus(503); } public function test_target_removed_after_preview_blocks_entire_publication(): void { $targetId = DB::table('articles')->insertGetId(['sSlug' => 'target', 'sTitle' => '引用目标', 'sContent' => '正文
', 'sThumb' => 'https://example.com/old.png', 'bPageThumb' => true, 'iCategoryId' => 3]); $this->getJson('/api/editorial/v1/source-articles/'.$id)->assertOk()->assertJsonPath('show_cover', true)->assertJsonPath('body_html', '正文
'); $item = $this->item('existing'); $item['adopt_article_id'] = $id; $item['cover'] = null; $item['show_cover'] = false; $d = $this->draft([$item]); $this->publish($d)->assertOk(); $this->assertNull(DB::table('articles')->value('sThumb')); $this->assertSame(0, DB::table('articles')->value('bPageThumb')); $this->getJson('/api/editorial/v1/articles/existing/versions/0')->assertJsonPath('content.show_cover', true); $resourceId = DB::table('articles')->insertGetId(['sSlug' => 'resource', 'sTitle' => '自动资源', 'sContent' => 'resource
', 'iCategoryId' => 1]); $resource = $this->item('resource'); $resource['adopt_article_id'] = $resourceId; $this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$resource]])->assertUnprocessable(); $this->getJson('/api/editorial/v1/source-articles/'.$resourceId)->assertUnprocessable(); } public function test_raw_script_markup_is_escaped_and_publish_request_id_cannot_be_rebound(): void { $item = $this->item(); $item['body'] .= "\n\n"; $d = $this->draft([$item]); $this->get($d['articles'][0]['preview_url'])->assertOk()->assertDontSee('', false); $request = (string) Str::uuid(); $this->publish($d, $request)->assertOk(); $next = $this->draft([$this->item('other')]); $this->publish($next, $request)->assertConflict(); $this->assertSame(1, DB::table('articles')->count()); } }