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

安装

'."\n\n正文", 'tag_ids' => [1]]; } private function draft(array $items, ?string $id = null): array { return $this->postJson('/api/editorial/v1/drafts', ['request_id' => $id ?? (string) Str::uuid(), 'articles' => $items])->assertCreated()->json(); } private function publish(array $draft, ?string $id = null) { return $this->postJson('/api/editorial/v1/drafts/'.$draft['draft_id'].'/publish', ['request_id' => $id ?? (string) Str::uuid(), 'expected_hash' => $draft['hash']]); } public function test_new_article_like_defaults_are_frozen_and_updates_preserve_progress(): void { $request = (string) Str::uuid(); $d = $this->draft([$this->item()], $request); $settings = $d['articles'][0]['like_settings']; $this->assertGreaterThanOrEqual(5, $settings['seven_day']); $this->assertLessThanOrEqual(35, $settings['seven_day']); $this->assertSame(50, $settings['yearly']); $this->assertGreaterThanOrEqual(100, $settings['one_year']); $this->assertLessThanOrEqual(500, $settings['one_year']); $this->assertSame($settings, $this->draft([$this->item()], $request)['articles'][0]['like_settings']); $this->publish($d)->assertOk(); $a = DB::table('articles')->first(); $this->assertSame($settings['seven_day'], (int) $a->i7like); $this->assertSame(50, (int) $a->i365like); $this->assertSame($settings['one_year'], (int) $a->iYearLikeTarget); $this->assertNotNull($a->sYearLikeStartedAt); $this->assertSame(0, (int) $a->iLikeCount); $start = $a->sYearLikeStartedAt; DB::table('articles')->where('id', $a->id)->update(['iLikeCount' => 72, 'iYearLikeAdded' => 25, 'i7like' => 2]); $updated = $this->draft([$this->item('example', 1)]); $this->assertNull($updated['articles'][0]['like_settings']); $this->publish($updated)->assertOk(); $a = DB::table('articles')->first(); $this->assertSame(72, (int) $a->iLikeCount); $this->assertSame(25, (int) $a->iYearLikeAdded); $this->assertSame(2, (int) $a->i7like); $this->assertSame($settings['one_year'], (int) $a->iYearLikeTarget); $this->assertSame($start, $a->sYearLikeStartedAt); $this->getJson('/api/editorial/v1/source-articles/'.$a->id)->assertOk() ->assertJsonPath('like_growth.one_year_added', 25)->assertJsonPath('like_growth.yearly', 50); $this->publish($d)->assertOk(); $this->assertSame(25, (int) DB::table('articles')->value('iYearLikeAdded')); } public function test_explicit_like_settings_zero_and_invalid_values(): void { foreach ([['yearly' => 366], ['one_year' => -1], ['seven_day' => 1.5], ['unknown' => 100]] as $settings) { $bad = $this->item(); $bad['like_settings'] = $settings; $this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$bad]])->assertUnprocessable(); } $item = $this->item(); $item['like_settings'] = ['seven_day' => 0, 'yearly' => 0, 'one_year' => 0]; $d = $this->draft([$item]); $this->publish($d)->assertOk(); $a = DB::table('articles')->first(); $this->assertSame(0, (int) $a->iYearLikeTarget); $this->assertSame(0, (int) $a->i7like); $this->assertSame(0, (int) $a->i365like); $this->assertNull($a->sYearLikeStartedAt); $item['expected_revision'] = 1; $this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$item]])->assertUnprocessable(); } public function test_existing_tutorial_flowchart_small_labels_are_supported(): void { $item = $this->item(); $item['body'] = '
导入订阅本站免费节点
'; $d = $this->draft([$item]); $this->get($d['articles'][0]['preview_url'])->assertOk()->assertSee('本站免费节点', false); } public function test_authentication_and_environment_binding_and_cache_bypass(): void { $this->withHeaders(['Authorization' => 'Bearer invalid'])->getJson('/api/editorial/v1/catalog')->assertUnauthorized(); $this->withHeaders(['Authorization' => 'Bearer '.$this->token]); $this->getJson('/api/editorial/v1/catalog')->assertOk()->assertHeader('Cache-Control', 'no-store, private'); config(['responsecache.enabled' => true]); $this->assertFalse(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/api/editorial/v1/catalog'))); $this->assertTrue(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/windows'))); file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'production', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]])); $this->getJson('/api/editorial/v1/catalog')->assertStatus(503); } public function test_draft_is_private_and_idempotent_with_signed_real_template_preview(): void { $id = (string) Str::uuid(); $draft = $this->draft([$this->item()], $id); $this->assertSame(0, DB::table('articles')->count()); $again = $this->draft([$this->item()], $id); $this->assertSame($draft['hash'], $again['hash']); $this->assertSame(1, DB::table('editorial_documents')->value('revision')); $this->postJson('/api/editorial/v1/drafts', ['request_id' => $id, 'articles' => [$this->item('different')]])->assertConflict(); $preview = $this->get($draft['articles'][0]['preview_url'])->assertOk()->assertSee('草稿预览')->assertSee('教程标题')->assertDontSee('umami.gemcook.net'); $preview->assertHeader('X-Robots-Tag', 'noindex, nofollow'); $this->get('/api/editorial/v1/preview/'.$id.'/example')->assertForbidden(); $this->travel(31)->minutes(); $this->get($draft['articles'][0]['preview_url'])->assertForbidden(); $this->travelBack(); } public function test_publish_is_atomic_and_replay_does_not_create_duplicates(): void { $a = $this->item('first'); $a['body'] .= "\n\n[下一步](/a/second.html#install)"; $d = $this->draft([$a, $this->item('second')]); $id = (string) Str::uuid(); $r = $this->publish($d, $id)->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('effects.search.status', 'done'); $this->assertSame(2, DB::table('articles')->count()); $this->assertSame(2, DB::table('article_tag')->count()); $this->publish($d, $id)->assertOk()->assertJsonPath('publication_id', $id); $this->publish($d)->assertOk()->assertJsonPath('publication_id', $id); $this->assertSame(2, DB::table('articles')->count()); $this->assertSame(1, DB::table('editorial_publications')->count()); $this->getJson('/api/editorial/v1/publications/'.$id)->assertOk()->assertJsonPath('status', 'published'); $this->getJson('/api/editorial/v1/articles/first')->assertOk()->assertJsonPath('published_revision', 1); } public function test_wrong_hash_stale_drafts_and_expected_revision_conflicts_are_rejected(): void { $d1 = $this->draft([$this->item()]); $this->postJson('/api/editorial/v1/drafts/'.$d1['draft_id'].'/publish', ['request_id' => (string) Str::uuid(), 'expected_hash' => str_repeat('0', 64)])->assertConflict(); $this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$this->item()]])->assertConflict(); $d2 = $this->draft([$this->item('example', 1)]); $this->publish($d1)->assertConflict(); $this->publish($d2)->assertOk(); $this->assertSame(2, DB::table('editorial_documents')->value('published_revision')); } public function test_invalid_links_anchors_categories_and_html_roll_back_entire_batch(): void { foreach ([ ['body', '[失效](/a/missing.html)'], ['body', '[失效](#absent)'], ['body', '[错误分类](/wrong/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![示例](".$asset['reference'].")\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' => '

操作

', 'iCategoryId' => 3]); $first = $this->item('first'); $first['body'] .= "\n\n[步骤](/a/target.html#step)"; $d = $this->draft([$first, $this->item('second')]); DB::table('articles')->where('id', $targetId)->delete(); $this->publish($d)->assertUnprocessable(); $this->assertSame(0, DB::table('articles')->count()); $this->assertSame(0, DB::table('editorial_publications')->count()); } public function test_unmanaged_article_can_be_read_before_explicit_adoption_and_cover_can_be_cleared(): void { $id = DB::table('articles')->insertGetId(['sSlug' => 'existing', 'sTitle' => '旧文', 'sTitleSub' => '旧摘要', '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()); } }