fn_b/tests/Feature/SeoContractTest.php

150 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Articles;
use App\Services\Seo;
use App\Services\ArticlesService;
use App\Services\ResourcePublication;
use App\Services\SeoNotifications;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
class SeoContractTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default'=>'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'=>'<b>示例</b> &amp; 摘要','sContent'=>'<h2>操作步骤</h2><p>示例正文</p>','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('<h1 class="mb-3">日期标题</h1>',$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->assertTrue(Seo::indexable($old));$this->assertNull(Seo::modified($old));
$new=Articles::create(['sSlug'=>'new','sTitle'=>'新的记录','sTitleSub'=>'共10个可用节点','sContent'=>'<p>有效资源</p>','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='<p>更新资源内容</p>';$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'=>'标题 </script> & "引用"']);
$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'=>'<p>内容</p>','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='<a href="https://xn--123-9i9dj97q.com/" rel="nofollow">推广</a><a href="https://example.net/doc">参考</a>';
$body=Seo::body($html);$this->assertStringContainsString('rel="nofollow sponsored noopener"',$body);
$this->assertStringContainsString('<a href="https://example.net/doc">参考</a>',$body);
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
ResourcePublication::validate(['title'=>'资源','title_sub'=>'数量未知','content'=>'内容','tags'=>[],'valid_count'=>'0']);
}
}