'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, '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]] 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('有效资源
','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)->assertSee('/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/','https://example.org/freenode']); $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']); } }