'sqlite','database.connections.sqlite.database'=>':memory:','scout.driver'=>'collection', 'app.url'=>'https://example.org','editorial.base_url'=>'https://example.org','app.site_code'=>'example.org', 'app.site_code_short'=>'cfn','seo.brand'=>'Example','responsecache.enabled'=>false,'seo.page_size'=>2,'seo.history_page_size'=>2, 'illustrations.enabled'=>false,'editorial.indexnow_enabled'=>false]); DB::purge('sqlite'); Http::preventStrayRequests(); $this->withoutVite(); $this->travelTo(\Carbon\Carbon::parse('2026-10-31 12:00:00','UTC')); 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();$t->text('sDescription')->nullable();$t->text('aBanner')->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->text('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 $f)$t->integer($f)->default(0);$t->timestamp('sLastLikedAt')->nullable();$t->timestamps();}); Schema::create('tags',function(Blueprint $t){$t->id();$t->string('sSlug');$t->string('sName');}); Schema::create('article_tag',function(Blueprint $t){$t->integer('iArticleId');$t->integer('iTagId');}); Schema::create('article_likes',function(Blueprint $t){$t->id();$t->integer('iArticleId');$t->string('sIp');$t->string('sFingerprint');$t->timestamps();}); (require database_path('migrations/2026_10_31_160000_add_article_seo_state.php'))->up(); foreach([[1,'freenode','免费节点',0],[2,'client','入门教程',0],[3,'windows','Windows',2],[4,'advanced','综合教程',0],[5,'shadowrocketids','免费小火箭ID',0]] as [$id,$slug,$name,$parent]) DB::table('categories')->insert(['id'=>$id,'sSlug'=>$slug,'sName'=>$name,'iParentId'=>$parent,'iShowChildren'=>$id===2?1:0,'aOpt'=>$id===1?' {"noindexday":7}':null]); DB::table('tags')->insert(['id'=>1,'sSlug'=>'clash','sName'=>'Clash']); } protected function tearDown(): void { $this->travelBack(); parent::tearDown(); } private function article(string $slug, array $extra=[]): int { return DB::table('articles')->insertGetId($extra+['sSlug'=>$slug,'sTitle'=>'教程 '.$slug,'sTitleSub'=>'示例 & 摘要','sContent'=>'

操作步骤

示例正文

','iCategoryId'=>3,'created_at'=>'2026-10-30 10:00:00','updated_at'=>'2026-10-31 01:00:00']); } public function test_category_aliases_pagination_and_parameters(): void { foreach(range(1,5) as $n)$this->article('guide'.$n); $this->get('/windows?page=2&utm_source=x')->assertRedirect('https://example.org/client/windows?page=2')->assertStatus(301); foreach(['/wrong/windows','/wrong/freenode','/client/freenode','/client/windows?page=0','/client/windows?page=-1','/client/windows?page=abc','/client/windows?page=99','/client/windows?page[]=2','/client/windows?page=2&page=3'] as $url)$this->get($url)->assertNotFound(); $this->get('/client/windows?page=1')->assertStatus(301)->assertRedirect('https://example.org/client/windows'); $html=$this->get('/client/windows?page=2&utm_source=x')->assertOk()->getContent(); $this->assertStringContainsString('rel="canonical" href="https://example.org/client/windows?page=2"',$html); $this->assertStringContainsString('rel="next" href="https://example.org/client/windows?page=3"',$html); $this->assertStringContainsString('rel="prev" href="https://example.org/client/windows"',$html); $this->assertStringNotContainsString('guide5.html',$html); $dom=new \DOMDocument();@$dom->loadHTML($html);$xp=new \DOMXPath($dom); $this->assertSame(1,$xp->query('//head/link[@class="pagination-next"]')->length); $this->assertSame(0,$xp->query('//body/nav[@class="seo-pagination"]')->length); $this->assertSame(0,$xp->query('//*[contains(@class,"seo-card-image")]')->length); $this->get('/client/windows?page=3')->assertOk()->assertDontSee('class="pagination-next"',false); $this->get('/robots.txt')->assertOk()->assertDontSee('Disallow: /*?*',false); } public function test_hidden_and_future_content_are_absent_from_every_public_query(): void { $good=$this->article('good'); foreach(['draft'=>['iStatus'=>0],'future'=>['created_at'=>'2026-11-01 00:00:00']] as $slug=>$extra){$id=$this->article($slug,$extra);DB::table('article_tag')->insert(['iArticleId'=>$id,'iTagId'=>1]);$this->get('/a/'.$slug.'.html')->assertNotFound();} $this->get('/search/'.rawurlencode('教程'))->assertOk()->assertSee('good.html',false)->assertDontSee('draft.html',false)->assertDontSee('future.html',false)->assertSee('noindex, follow',false); $this->get('/?s='.rawurlencode('教程'))->assertStatus(301)->assertRedirect(Seo::url('/search/'.rawurlencode('教程'))); $this->get('/search/'.rawurlencode('不存在的内容'))->assertOk()->assertSee('看起来这里没有任何东西…'); $this->get('/tag/clash')->assertOk()->assertDontSee('draft.html',false); DB::table('categories')->where('id',2)->update(['iStatus'=>0]); $this->get('/a/good.html')->assertNotFound();$this->get('/client/windows')->assertNotFound(); $this->get('/sitemap.xml')->assertOk()->assertDontSee('good.html',false); $this->get('/search/'.rawurlencode('教程'))->assertOk()->assertDontSee('good.html',false); } public function test_home_is_stable_and_failed_refresh_does_not_replace_it(): void { $this->article('fn-good',['iCategoryId'=>1,'sTitle'=>'日期标题','sTitleSub'=>'共20个可用节点']); $this->article('fn-zero',['iCategoryId'=>1,'sTitleSub'=>'本次更新共0个可用节点','created_at'=>'2026-10-31 11:00:00']); $this->assertSame('fn-good',app(ArticlesService::class)->getLatestFreeNodeSlug()); $html=$this->get('/')->assertOk()->getContent(); $this->assertStringContainsString('

日期标题

',$html); $this->assertStringContainsString('rel="canonical" href="https://example.org/"',$html); $this->assertStringContainsString('property="og:type" content="website"',$html); $this->assertStringContainsString('示例正文',$html); $this->assertStringNotContainsString('resource-context',$html); $this->assertStringNotContainsString('/css/seo.css',$html); $this->assertStringContainsString('发布于',$html); $this->travel(10)->days();$this->get('/')->assertOk()->assertSee('content="index, follow"',false); $this->get('/a/fn-good.html')->assertOk()->assertSee('content="noindex, follow"',false)->assertDontSee('resource-context',false); DB::table('articles')->delete();$this->get('/')->assertStatus(503)->assertHeader('Retry-After','3600'); } public function test_new_snapshots_are_archives_and_content_clock_ignores_likes(): void { $id=$this->article('old',['iCategoryId'=>1]);$old=Articles::find($id); $this->assertFalse(Seo::indexable($old));$this->assertNull(Seo::modified($old)); $new=Articles::create(['sSlug'=>'new','sTitle'=>'新的记录','sTitleSub'=>'共10个可用节点','sContent'=>'

有效资源

','iCategoryId'=>1,'iStatus'=>1]); $this->assertSame('archive',$new->sSeoPolicy);$this->assertFalse(Seo::indexable($new)); $clock=Seo::modified($new);$this->travel(1)->hours();$new->increment('iLikeCount');$this->assertSame($clock,Seo::modified($new->fresh())); $new->sContent='

更新资源内容

';$new->save();$this->assertNotSame($clock,Seo::modified($new->fresh())); $this->get('/sitemap.xml')->assertOk()->assertDontSee('/a/new.html',false)->assertDontSee('/a/old.html',false); $this->travelTo(\Carbon\Carbon::parse('2026-11-06 10:00:01','UTC')); $this->get('/sitemap.xml')->assertOk()->assertDontSee('/a/old.html',false); } public function test_metadata_and_structured_data_are_clean_and_share_article_url(): void { $this->article('clean',['sTitle'=>'标题 & "引用"']); $html=$this->get('/a/clean.html?utm_source=test')->assertOk()->getContent(); $dom=new \DOMDocument();@$dom->loadHTML($html);$xp=new \DOMXPath($dom); $this->assertSame('示例 & 摘要',$xp->query('//meta[@name="description"]')->item(0)->getAttribute('content')); $this->assertSame('https://example.org/a/clean.html',$xp->query('//a[@id="urlCopy"]')->item(0)->getAttribute('data-clipboard-text')); foreach($xp->query('//script[@type="application/ld+json"]') as $script)$this->assertIsArray(json_decode($script->textContent,true,512,JSON_THROW_ON_ERROR)); $this->assertStringNotContainsString('dateModified',$html);$this->assertStringNotContainsString('AggregateRating',$html); $this->assertStringContainsString('href="https://example.org/client/windows"',$html); } public function test_rejected_resource_does_not_change_articles(): void { $id=$this->article('last',['iCategoryId'=>1]); $payload=['title'=>'更新','title_sub'=>'共12个可用节点','content'=>'

内容

','tags'=>[]]; $this->assertSame(12,ResourcePublication::validate($payload)['iResourceCount']); $this->assertSame('unknown',ResourcePublication::validate($payload)['sResourceStatus']); foreach([['check_status'=>'failed'],['valid_count'=>0],['title_sub'=>'本次更新共0个可用节点'],['content'=>'']] as $bad){ try { ResourcePublication::validate(array_replace($payload,$bad));$this->fail('Invalid refresh accepted'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException|\Illuminate\Validation\ValidationException $e){$this->assertNotEmpty($e->getMessage());} } $this->assertSame('last',Articles::find($id)->sSlug);$this->assertSame(1,Articles::count()); } public function test_durable_home_notifications_retry_and_deletions_need_no_http_probe(): void { config(['editorial.indexnow_enabled'=>true,'key.indexnow'=>'test-key-12345678']); Http::fake(['api.indexnow.org/*'=>Http::sequence()->push('',429)->push('',202)->push('',200)]); $id=$this->article('snapshot',['iCategoryId'=>1,'sSeoPolicy'=>'archive']);$service=app(SeoNotifications::class); $service->changed(Articles::find($id));$service->changed(Articles::find($id));$this->assertSame(1,DB::table('seo_notifications')->count()); $service->retry();Http::assertSentCount(1);$service->retry();Http::assertSentCount(1); $this->travel(6)->minutes();$service->retry();Http::assertSentCount(2); Http::assertSent(fn($req)=>$req['urlList']===['https://example.org/']); $service->changed(Articles::find($id),true);DB::table('articles')->where('id',$id)->delete();$service->retry();Http::assertSentCount(3); Http::assertSent(fn($req)=>in_array('https://example.org/a/snapshot.html',$req['urlList'])); } public function test_cleanup_retains_old_resource_urls_and_get_cannot_like(): void { $id=$this->article('historical',['iCategoryId'=>1,'created_at'=>'2025-01-01 00:00:00']); $this->artisan('clear_db')->assertExitCode(0);$this->assertNotNull(Articles::find($id)); $this->get('/like/'.$id)->assertNotFound();$this->assertSame(0,Articles::find($id)->iLikeCount); } public function test_commercial_attributes_and_zero_string_validation(): void { $html='推广参考'; $body=Seo::body($html);$this->assertStringContainsString('rel="nofollow sponsored noopener"',$body); $this->assertStringContainsString('参考',$body); $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); ResourcePublication::validate(['title'=>'资源','title_sub'=>'数量未知','content'=>'内容','tags'=>[],'valid_count'=>'0']); } private function appleBody(): string { return '

免费苹果账号

账号:test@example.invalid,密码:synthetic-test,地区:美区,检测时间:2026-10-30 10:00:00

'; } private function xpath(string $html): \DOMXPath { $dom=new \DOMDocument();@$dom->loadHTML($html);return new \DOMXPath($dom); } public function test_id_entrance_uses_latest_nonempty_snapshot_without_changing_records(): void { $good=$this->article('id-good',['iCategoryId'=>5,'sContent'=>$this->appleBody()]); $this->article('id-pinned',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'iIsTop'=>1,'created_at'=>'2026-10-01 10:00:00']); foreach(['empty'=>['sContent'=>'

免费苹果账号

'], 'unknown'=>['sContent'=>'

格式未知

'], 'failed'=>['sResourceStatus'=>'failed'], 'draft'=>['iStatus'=>0], 'future'=>['created_at'=>'2026-11-01 00:00:00']] as $slug=>$extra) { $this->article('id-'.$slug,$extra+['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-31 11:00:00']); } $before=DB::table('articles')->orderBy('id')->get()->toJson(); $this->assertSame('id-good',app(ArticlesService::class)->getLatestResourceSlug('shadowrocketids')); $html=$this->get('/shadowrocketids')->assertOk()->assertSee('查看往期资源(免费小火箭ID)')->getContent(); $xp=$this->xpath($html); $this->assertSame('https://example.org/shadowrocketids',$xp->query('//link[@rel="canonical"]')->item(0)->getAttribute('href')); $this->assertSame('index, follow',$xp->query('//meta[@name="robots"]')->item(0)->getAttribute('content')); $this->assertSame('https://example.org/shadowrocketids',$xp->query('//a[@id="urlCopy"]')->item(0)->getAttribute('data-clipboard-text')); $this->assertSame('10月30日免费小火箭ID',trim($xp->query('//h1')->item(0)->textContent)); $this->assertSame($before,DB::table('articles')->orderBy('id')->get()->toJson()); DB::table('categories')->where('id',5)->update(['iStatus'=>0]); $this->assertNull(app(ArticlesService::class)->getLatestResourceSlug('shadowrocketids')); $this->get('/shadowrocketids')->assertStatus(503)->assertHeader('Retry-After','3600'); $this->get('/history/shadowrocketids')->assertNotFound(); } public function test_dated_resources_keep_their_urls_and_self_canonical_but_are_noindex(): void { foreach(['freenode'=>1,'shadowrocketids'=>5] as $type=>$category) { $id=$this->article($type.'-date',['iCategoryId'=>$category,'sSeoPolicy'=>'index','sContent'=>$category===5?$this->appleBody():'

节点记录

']); $html=$this->get('/a/'.$type.'-date.html')->assertOk()->assertSee('查看最新')->assertSee('查看往期')->getContent(); $xp=$this->xpath($html); $this->assertSame('往期资源', trim($xp->query('//a[contains(@class,"resource-history-action") and contains(@href,"/history/")]')->item(0)->textContent)); $this->assertSame('noindex, follow',$xp->query('//meta[@name="robots"]')->item(0)->getAttribute('content')); $this->assertSame('https://example.org/a/'.$type.'-date.html',$xp->query('//link[@rel="canonical"]')->item(0)->getAttribute('href')); $this->assertFalse(Seo::indexable(Articles::find($id))); } $this->article('guide');$this->get('/a/guide.html')->assertOk()->assertSee('content="index, follow"',false)->assertDontSee('aria-label="资源更新记录"',false); } public function test_history_overview_combines_both_types_and_category_links_filter_history(): void { config(['seo.history_page_size'=>10]); $this->article('latest-free',['iCategoryId'=>1,'created_at'=>'2026-10-31 10:00:00']); $this->article('latest-id',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-31 10:00:00']); $a=$this->article('free-a',['iCategoryId'=>1,'created_at'=>'2026-10-29 10:00:00']); $b=$this->article('free-b',['iCategoryId'=>1,'created_at'=>'2026-10-28 10:00:00']); $c=$this->article('id-a',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-27 10:00:00']); $xp=$this->xpath($this->get('/history')->assertOk()->getContent()); $this->assertSame([(string)$a,(string)$b,(string)$c],array_map(fn($n)=>$n->getAttribute('data-article-id'),iterator_to_array($xp->query('//*[@data-article-id]')))); foreach(['freenode','shadowrocketids'] as $type) { $this->assertGreaterThan(0,$xp->query('//div[contains(@class,"list-home")]//div[contains(@class,"list-footer")]//a[@href="https://example.org/history/'.$type.'"]')->length); } $this->assertSame(2,$this->xpath($this->get('/history/freenode')->assertOk()->getContent())->query('//*[@data-article-id]')->length); $this->assertSame(1,$this->xpath($this->get('/history/shadowrocketids')->assertOk()->getContent())->query('//*[@data-article-id]')->length); } public function test_history_excludes_current_entries_and_preserves_date_order_and_pagination(): void { $this->article('current-free',['iCategoryId'=>1,'created_at'=>'2026-10-31 10:00:00']); $this->article('current-id',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-31 10:00:00']); $ids=[];foreach(range(1,5) as $n)$ids[]=$this->article('past-'.$n,['iCategoryId'=>1,'iIsTop'=>$n===1?1:0,'created_at'=>'2026-10-2'.$n.' 10:00:00']); $this->article('old-id',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-20 10:00:00']); $this->article('hidden-free',['iCategoryId'=>1,'iStatus'=>0]); $this->article('future-free',['iCategoryId'=>1,'created_at'=>'2026-11-01 00:00:00']); $this->article('not-resource'); foreach(['/history','/history/freenode'] as $path) { $xp=$this->xpath($this->get($path)->assertOk()->getContent()); $this->assertSame(0, $xp->query('//nav[@aria-label="往期内容分类"]')->length); $this->assertSame(0, $xp->query('//main//h1')->length); $cards=$xp->query('//*[@data-article-id]'); $this->assertSame([(string)$ids[4],(string)$ids[3]],array_map(fn($n)=>$n->getAttribute('data-article-id'),iterator_to_array($cards))); $this->assertSame('noindex, follow',$xp->query('//meta[@name="robots"]')->item(0)->getAttribute('content')); } $html=$this->get('/history/freenode?page=2&utm_source=test')->assertOk()->getContent(); $xp=$this->xpath($html);$cards=$xp->query('//*[@data-article-id]'); $this->assertSame([(string)$ids[2],(string)$ids[1]],array_map(fn($n)=>$n->getAttribute('data-article-id'),iterator_to_array($cards))); $this->assertSame('https://example.org/history/freenode?page=2',$xp->query('//link[@rel="canonical"]')->item(0)->getAttribute('href')); $this->assertSame('https://example.org/history/freenode?page=3',$xp->query('//link[@rel="next"]')->item(0)->getAttribute('href')); $xp=$this->xpath($this->get('/history/shadowrocketids')->assertOk()->getContent()); $this->assertSame(1,$xp->query('//*[@data-article-id]')->length); $this->assertStringContainsString('old-id',$xp->query('//a[contains(@class,"list-title")]')->item(0)->getAttribute('href')); $this->get('/history/freenode?page=3')->assertOk()->assertDontSee('class="pagination-next"',false); foreach(['/history/freenode?page=4','/history/freenode?page=0','/history?page=abc','/history?page[]=2','/history?page=2&page=3','/history/client'] as $path)$this->get($path)->assertNotFound(); $this->get('/history/freenode?page=1')->assertStatus(301)->assertRedirect('https://example.org/history/freenode'); } public function test_resource_navigation_redirects_and_sitemap_use_stable_entrances(): void { $this->article('current-free',['iCategoryId'=>1]); $this->article('current-id',['iCategoryId'=>5,'sContent'=>$this->appleBody()]); $this->article('guide'); $html=$this->get('/')->assertOk()->assertSee('查看往期资源(免费节点)')->getContent(); $xp=$this->xpath($html); foreach(['往期资源'=>'/history','免费节点'=>'/','免费小火箭ID'=>'/shadowrocketids'] as $name=>$path) { $links=$xp->query('//header//a[span[text()="'.$name.'"]]');$this->assertSame(2,$links->length); foreach($links as $link)$this->assertSame($path,parse_url($link->getAttribute('href'),PHP_URL_PATH) ?: '/'); } foreach(['/freenode'=>'/','/freenode?page=1'=>'/','/?page=2'=>'/history/freenode?page=2', '/freenode?page=2'=>'/history/freenode?page=2','/shadowrocketids?page=2'=>'/history/shadowrocketids?page=2', '/shadowrocketids?page=1'=>'/shadowrocketids'] as $old=>$target)$this->get($old)->assertStatus(301)->assertRedirect('https://example.org'.$target); $this->get('/shadowrocketids?page=-1')->assertNotFound(); $xml=$this->get('/sitemap.xml')->assertOk()->getContent(); $this->assertStringContainsString('https://example.org/shadowrocketids',$xml); $this->assertStringContainsString('https://example.org/',$xml); foreach(['/freenode','/history','/a/current-free.html','/a/current-id.html'] as $path)$this->assertStringNotContainsString('https://example.org'.$path,$xml); $this->assertStringContainsString('/a/guide.html',$xml); DB::table('articles')->where('iCategoryId',5)->delete(); $this->get('/sitemap.xml')->assertOk()->assertDontSee('/shadowrocketids',false); $this->get('/history/shadowrocketids')->assertOk()->assertSee('看起来这里没有任何东西…'); } public function test_id_updates_notify_only_the_entrance_and_drop_queued_archive_updates(): void { config(['editorial.indexnow_enabled'=>true,'key.indexnow'=>'test-key-12345678']);Http::fake(['api.indexnow.org/*'=>Http::response('',200)]); $id=$this->article('ids',['iCategoryId'=>5,'sContent'=>$this->appleBody()]);$service=app(SeoNotifications::class); $service->changed(Articles::find($id));$service->retry(); Http::assertSentCount(1);Http::assertSent(fn($req)=>$req['urlList']===['https://example.org/shadowrocketids']); $service->enqueue('legacy-queue',['https://example.org/freenode','https://example.org/history','https://example.org/history/shadowrocketids','https://example.org/a/ids.html']); $service->retry();Http::assertSentCount(1); $state=json_decode(DB::table('seo_notifications')->where('event_key','legacy-queue')->value('state'),true); $this->assertSame('skipped',$state['status']); } }