wip: article AI polish, category SEO fields, cover generator, membership plan seeder
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Contracts\LlmProvider;
|
||||
use App\Domain\Ai\Jobs\ModerateCommentJob;
|
||||
use App\Domain\Ai\Jobs\OptimizeArticleContentJob;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use App\Settings\AiSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
@@ -31,13 +33,15 @@ class AiPipelineTest extends TestCase
|
||||
]);
|
||||
|
||||
(new OptimizeArticleContentJob($article->id))->handle(
|
||||
app(\App\Contracts\LlmProvider::class),
|
||||
app(\App\Settings\AiSettings::class),
|
||||
app(LlmProvider::class),
|
||||
app(AiSettings::class),
|
||||
);
|
||||
|
||||
$article->refresh();
|
||||
$this->assertNotEmpty($article->ai_summary);
|
||||
$this->assertIsArray($article->ai_suggestions);
|
||||
$this->assertNotEmpty($article->ai_polished_content);
|
||||
$this->assertStringContainsString('Hello world body', (string) $article->ai_polished_content);
|
||||
}
|
||||
|
||||
public function test_moderate_job_approves_non_spam(): void
|
||||
@@ -63,8 +67,8 @@ class AiPipelineTest extends TestCase
|
||||
]);
|
||||
|
||||
(new ModerateCommentJob($comment->id))->handle(
|
||||
app(\App\Contracts\LlmProvider::class),
|
||||
app(\App\Settings\AiSettings::class),
|
||||
app(LlmProvider::class),
|
||||
app(AiSettings::class),
|
||||
);
|
||||
|
||||
$this->assertSame(Comment::STATUS_APPROVED, $comment->fresh()->moderation_status);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domain\Ai\Jobs\GenerateArticleCoverJob;
|
||||
use App\Domain\Media\ArticleCoverService;
|
||||
use App\Models\Article;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ArticleCoverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected string $attachmentsRoot;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->attachmentsRoot = storage_path('framework/testing/attachments-'.uniqid());
|
||||
File::ensureDirectoryExists($this->attachmentsRoot);
|
||||
|
||||
config([
|
||||
'filesystems.disks.attachments' => [
|
||||
'driver' => 'local',
|
||||
'root' => $this->attachmentsRoot,
|
||||
'url' => rtrim((string) config('app.url'), '/').'/attachments-local',
|
||||
'visibility' => 'public',
|
||||
'throw' => true,
|
||||
],
|
||||
'larablog.attachments_disk' => 'attachments',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->attachmentsRoot) && is_dir($this->attachmentsRoot)) {
|
||||
File::deleteDirectory($this->attachmentsRoot);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_auto_cover_from_markdown_remote_image(): void
|
||||
{
|
||||
$article = $this->makeArticle([
|
||||
'content' => "Hello\n\n\n\nMore text",
|
||||
]);
|
||||
|
||||
app(ArticleCoverService::class)->apply($article, 'auto');
|
||||
$article->refresh();
|
||||
|
||||
$this->assertTrue($article->hasCover());
|
||||
$this->assertSame(ArticleCoverService::SOURCE_CONTENT_IMAGE, $article->cover_source);
|
||||
$this->assertSame('url', $article->cover_disk);
|
||||
$this->assertSame('https://cdn.example.com/hero.jpg', $article->coverUrl());
|
||||
}
|
||||
|
||||
public function test_auto_cover_from_attachment_when_no_body_image(): void
|
||||
{
|
||||
$article = $this->makeArticle(['content' => 'No images here']);
|
||||
|
||||
Attachment::query()->create([
|
||||
'article_id' => $article->id,
|
||||
'disk' => 'attachments',
|
||||
'path' => 'covers/demo.png',
|
||||
'filename' => 'demo.png',
|
||||
'mime' => 'image/png',
|
||||
'size' => 12,
|
||||
'visibility' => Attachment::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
(new GenerateArticleCoverJob($article->id, 'auto'))
|
||||
->handle(app(ArticleCoverService::class));
|
||||
|
||||
$article->refresh();
|
||||
$this->assertTrue($article->hasCover());
|
||||
$this->assertSame(ArticleCoverService::SOURCE_ATTACHMENT, $article->cover_source);
|
||||
$this->assertSame('covers/demo.png', $article->cover_path);
|
||||
}
|
||||
|
||||
public function test_generate_strategy_renders_template_cover(): void
|
||||
{
|
||||
$article = $this->makeArticle([
|
||||
'title' => 'Template Cover Smoke',
|
||||
'description' => 'A short blurb for the generated cover card.',
|
||||
]);
|
||||
|
||||
(new GenerateArticleCoverJob($article->id, 'generate'))
|
||||
->handle(app(ArticleCoverService::class));
|
||||
|
||||
$article->refresh();
|
||||
$this->assertTrue($article->hasCover());
|
||||
$this->assertSame(ArticleCoverService::SOURCE_GENERATED, $article->cover_source);
|
||||
$this->assertTrue(Storage::disk('attachments')->exists((string) $article->cover_path));
|
||||
$this->assertGreaterThan(1000, Storage::disk('attachments')->size((string) $article->cover_path));
|
||||
$this->assertSame(1, Attachment::query()->where('article_id', $article->id)->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function makeArticle(array $overrides = []): Article
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$category = Category::query()->create(['name' => 'Cover', 'display_order' => 0]);
|
||||
|
||||
return Article::query()->create(array_merge([
|
||||
'category_id' => $category->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Cover post',
|
||||
'content' => 'Body',
|
||||
'content_format' => 'markdown',
|
||||
'published_at' => now()->subMinute(),
|
||||
'visible' => true,
|
||||
], $overrides));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Filament\Resources\Articles\ArticleResource;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -37,4 +39,94 @@ class BlogFrontendTest extends TestCase
|
||||
$this->get('/rss.xml')->assertOk();
|
||||
$this->get('/sitemap.xml')->assertOk();
|
||||
}
|
||||
|
||||
public function test_category_page_exposes_intro_and_seo(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$category = Category::query()->create([
|
||||
'name' => '随笔',
|
||||
'display_order' => 0,
|
||||
'description' => '随笔分类摘要',
|
||||
'intro' => '这是分类介绍,给列表页和爬虫看。',
|
||||
'keywords' => '随笔,笔记',
|
||||
]);
|
||||
Article::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => '分类里的文章',
|
||||
'content' => 'body',
|
||||
'content_format' => 'html',
|
||||
'published_at' => now()->subHour(),
|
||||
'visible' => true,
|
||||
]);
|
||||
|
||||
$this->get('/category-'.$category->id.'.shtml')
|
||||
->assertOk()
|
||||
->assertSee('随笔', false)
|
||||
->assertSee('这是分类介绍,给列表页和爬虫看。', false)
|
||||
->assertSee('分类里的文章', false)
|
||||
->assertSee('<meta name="description" content="随笔分类摘要">', false)
|
||||
->assertSee('<meta name="keywords" content="随笔,笔记">', false)
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('/category-'.$category->id.'.shtml', false);
|
||||
|
||||
$this->get('/sitemap.xml')->assertOk()->assertSee('/category-'.$category->id.'.shtml', false);
|
||||
$this->get('/llms.txt')->assertOk()->assertSee('随笔')->assertSee('随笔分类摘要');
|
||||
}
|
||||
|
||||
public function test_comment_author_website_uses_noreferrer(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$category = Category::query()->create(['name' => '随笔', 'display_order' => 0]);
|
||||
$article = Article::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Hello',
|
||||
'content' => 'Hi',
|
||||
'content_format' => 'html',
|
||||
'description' => '文章摘要给评论后台看',
|
||||
'published_at' => now()->subHour(),
|
||||
'visible' => true,
|
||||
'comments_count' => 1,
|
||||
]);
|
||||
Comment::query()->create([
|
||||
'article_id' => $article->id,
|
||||
'author' => '访客甲',
|
||||
'url' => 'https://example.com/me',
|
||||
'content' => '一条评论',
|
||||
'moderation_status' => Comment::STATUS_APPROVED,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get('/show-'.$article->id.'.shtml')
|
||||
->assertOk()
|
||||
->assertSee('访客甲', false)
|
||||
->assertSee('href="https://example.com/me"', false)
|
||||
->assertSee('rel="nofollow noopener noreferrer"', false)
|
||||
->assertDontSee('javascript:', false);
|
||||
|
||||
$this->get('/comments.shtml')
|
||||
->assertOk()
|
||||
->assertSee('rel="nofollow noopener noreferrer"', false);
|
||||
|
||||
$unsafe = new Comment(['url' => 'javascript:alert(1)']);
|
||||
$this->assertNull($unsafe->websiteHref());
|
||||
}
|
||||
|
||||
public function test_category_articles_count_url_targets_article_filter(): void
|
||||
{
|
||||
$category = Category::query()->create(['name' => '随笔', 'display_order' => 0]);
|
||||
$url = ArticleResource::getUrl('index', [
|
||||
'filters' => [
|
||||
'category_id' => ['value' => $category->id],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertStringContainsString((string) $category->id, $url);
|
||||
$decoded = urldecode($url);
|
||||
$this->assertTrue(
|
||||
str_contains($decoded, 'filters[category_id]') || str_contains($url, 'filters'),
|
||||
$url
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domain\Blog\AccessDecision;
|
||||
use App\Domain\Blog\ArticleAccess;
|
||||
use App\Domain\Plugin\Hook;
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Plugins\Larablog\Membership\Database\Seeders\MembershipPlanSeeder;
|
||||
use Plugins\Larablog\Membership\Domain\MembershipService;
|
||||
use Plugins\Larablog\Membership\Models\ArticleMembership;
|
||||
use Plugins\Larablog\Membership\Models\MembershipPlan;
|
||||
use Plugins\Larablog\Membership\PluginServiceProvider as MembershipProvider;
|
||||
use Plugins\Larablog\PaidContent\PluginServiceProvider as PaidContentProvider;
|
||||
use Plugins\Larablog\Payment\Domain\OrderService;
|
||||
use Plugins\Larablog\Payment\Domain\ProductType;
|
||||
use Plugins\Larablog\Payment\Models\Entitlement;
|
||||
use Plugins\Larablog\Payment\PluginServiceProvider as PaymentProvider;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MembershipCommerceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Hook::flush();
|
||||
|
||||
$this->artisan('migrate', ['--path' => 'plugins/larablog/payment/database/migrations']);
|
||||
$this->artisan('migrate', ['--path' => 'plugins/larablog/paid-content/database/migrations']);
|
||||
$this->artisan('migrate', ['--path' => 'plugins/larablog/membership/database/migrations']);
|
||||
|
||||
app()->register(PaymentProvider::class);
|
||||
app()->register(PaidContentProvider::class);
|
||||
app()->register(MembershipProvider::class);
|
||||
|
||||
(new MembershipPlanSeeder)->run();
|
||||
}
|
||||
|
||||
public function test_membership_requires_payment_plugin(): void
|
||||
{
|
||||
$manager = app(PluginManager::class);
|
||||
$manager->syncDiscoveredPlugins();
|
||||
|
||||
Plugin::query()->where('name', 'larablog/payment')->update(['enabled' => false]);
|
||||
Plugin::query()->where('name', 'larablog/membership')->update(['enabled' => false]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$manager->enable('larablog/membership');
|
||||
}
|
||||
|
||||
public function test_subscribe_monthly_sets_expires_at_and_unlocks_article(): void
|
||||
{
|
||||
$manager = app(PluginManager::class);
|
||||
$manager->syncDiscoveredPlugins();
|
||||
$manager->enable('larablog/payment');
|
||||
$manager->enable('larablog/membership');
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$article] = $this->makeArticle([
|
||||
'content' => str_repeat('secret ', 40).'MEMBER_FULL_TOKEN',
|
||||
]);
|
||||
|
||||
ArticleMembership::query()->create([
|
||||
'article_id' => $article->id,
|
||||
'enabled' => true,
|
||||
'required_plan_id' => null,
|
||||
]);
|
||||
|
||||
$this->get('/show-'.$article->id.'.shtml')
|
||||
->assertOk()
|
||||
->assertDontSee('MEMBER_FULL_TOKEN');
|
||||
|
||||
$plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail();
|
||||
$orders = app(OrderService::class);
|
||||
$order = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price);
|
||||
$orders->markPaid($order);
|
||||
|
||||
$status = app(MembershipService::class)->statusFor($user);
|
||||
$this->assertTrue($status['active']);
|
||||
$this->assertSame('monthly', $status['plan_slug']);
|
||||
$this->assertNotNull($status['expires_at']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/show-'.$article->id.'.shtml')
|
||||
->assertOk()
|
||||
->assertSee('MEMBER_FULL_TOKEN');
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/plugins/membership/status')
|
||||
->assertOk()
|
||||
->assertJsonPath('active', true)
|
||||
->assertJsonPath('plan_slug', 'monthly');
|
||||
}
|
||||
|
||||
public function test_expired_membership_can_renew(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail();
|
||||
$orders = app(OrderService::class);
|
||||
|
||||
$first = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price);
|
||||
$orders->markPaid($first);
|
||||
|
||||
Entitlement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('product_type', ProductType::MEMBERSHIP)
|
||||
->where('product_id', $plan->id)
|
||||
->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$this->assertFalse($orders->hasEntitlement((int) $user->id, ProductType::MEMBERSHIP, (int) $plan->id));
|
||||
|
||||
$second = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price);
|
||||
$orders->markPaid($second);
|
||||
|
||||
$this->assertTrue($orders->hasEntitlement((int) $user->id, ProductType::MEMBERSHIP, (int) $plan->id));
|
||||
$entitlement = Entitlement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('product_type', ProductType::MEMBERSHIP)
|
||||
->where('product_id', $plan->id)
|
||||
->firstOrFail();
|
||||
$this->assertTrue($entitlement->expires_at?->isFuture());
|
||||
}
|
||||
|
||||
public function test_required_plan_gate_rejects_other_plan(): void
|
||||
{
|
||||
$manager = app(PluginManager::class);
|
||||
$manager->syncDiscoveredPlugins();
|
||||
$manager->enable('larablog/payment');
|
||||
$manager->enable('larablog/membership');
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$article] = $this->makeArticle(['content' => 'NEED_LIFETIME_TOKEN '.str_repeat('x ', 30)]);
|
||||
$monthly = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail();
|
||||
$lifetime = MembershipPlan::query()->where('slug', 'lifetime')->firstOrFail();
|
||||
|
||||
ArticleMembership::query()->create([
|
||||
'article_id' => $article->id,
|
||||
'enabled' => true,
|
||||
'required_plan_id' => $lifetime->id,
|
||||
]);
|
||||
|
||||
$orders = app(OrderService::class);
|
||||
$order = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $monthly->id, $monthly->name, (string) $monthly->price);
|
||||
$orders->markPaid($order);
|
||||
|
||||
$decision = app(ArticleAccess::class)->resolve($article, $user);
|
||||
$this->assertSame(AccessDecision::NEED_PURCHASE, $decision->status);
|
||||
|
||||
$lifeOrder = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $lifetime->id, $lifetime->name, (string) $lifetime->price);
|
||||
$orders->markPaid($lifeOrder);
|
||||
|
||||
$this->assertTrue(app(ArticleAccess::class)->resolve($article, $user)->isAllow());
|
||||
}
|
||||
|
||||
public function test_forge_membership_amount_query_is_rejected(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/plugins/payment/checkout?'.http_build_query([
|
||||
'product_type' => ProductType::MEMBERSHIP,
|
||||
'product_id' => $plan->id,
|
||||
'amount' => '0.01',
|
||||
'title' => 'hack',
|
||||
]))
|
||||
->assertRedirect();
|
||||
|
||||
// Server-side price must be used; after redirect order amount is plan price.
|
||||
$this->assertDatabaseHas('orders', [
|
||||
'user_id' => $user->id,
|
||||
'amount' => '9.90',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/plugins/payment/checkout?'.http_build_query([
|
||||
'product_type' => 'theme',
|
||||
'product_id' => 1,
|
||||
'amount' => '0.01',
|
||||
'title' => 'hack',
|
||||
]))
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_mutex_validate_hook_rejects_paid_and_membership(): void
|
||||
{
|
||||
$data = [
|
||||
'read_password' => null,
|
||||
'paid_content' => ['enabled' => true, 'price' => 1],
|
||||
'membership' => ['enabled' => true],
|
||||
];
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
Hook::filter('filament.article.validate_access_restrictions', $data, null);
|
||||
}
|
||||
|
||||
public function test_cannot_delete_plan_with_entitlement(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail();
|
||||
$orders = app(OrderService::class);
|
||||
$order = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price);
|
||||
$orders->markPaid($order);
|
||||
|
||||
$this->assertTrue($plan->fresh()->hasEntitlements());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array{0: Article, 1: User, 2: Category}
|
||||
*/
|
||||
protected function makeArticle(array $overrides = []): array
|
||||
{
|
||||
$user = isset($overrides['user_id'])
|
||||
? User::query()->findOrFail($overrides['user_id'])
|
||||
: User::factory()->create();
|
||||
$category = Category::query()->create(['name' => 'Mem', 'display_order' => 0]);
|
||||
|
||||
$article = Article::query()->create(array_merge([
|
||||
'category_id' => $category->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Members post',
|
||||
'content' => 'Hello',
|
||||
'content_format' => 'markdown',
|
||||
'published_at' => now()->subMinute(),
|
||||
'visible' => true,
|
||||
], $overrides));
|
||||
|
||||
return [$article, $user, $category];
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class PaidContentCommerceTest extends TestCase
|
||||
$author = User::factory()->create();
|
||||
[$article] = $this->makeArticle([
|
||||
'user_id' => $author->id,
|
||||
'content' => "## Intro\n\n".str_repeat('teaser ', 10)."UNIQUE_FULL_TOKEN ".str_repeat('tail ', 10),
|
||||
'content' => "## Intro\n\n".str_repeat('teaser ', 10).'UNIQUE_FULL_TOKEN '.str_repeat('tail ', 10),
|
||||
]);
|
||||
|
||||
ArticleProduct::query()->create([
|
||||
@@ -353,6 +353,20 @@ class PaidContentCommerceTest extends TestCase
|
||||
$this->assertSame(AccessDecision::NEED_PURCHASE, $access->resolve($article, null)->status);
|
||||
}
|
||||
|
||||
public function test_article_table_hooks_expose_price_column_and_filter(): void
|
||||
{
|
||||
$columnNames = collect(Hook::collect('filament.article.table.columns'))
|
||||
->map(fn ($column) => $column->getName())
|
||||
->all();
|
||||
$this->assertContains('paid_content_enabled', $columnNames);
|
||||
$this->assertContains('paid_content_price', $columnNames);
|
||||
|
||||
$filterNames = collect(Hook::collect('filament.article.table.filters'))
|
||||
->map(fn ($filter) => $filter->getName())
|
||||
->all();
|
||||
$this->assertContains('paid_enabled', $filterNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array{0: Article, 1: User, 2: Category}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PluginDocsTest extends TestCase
|
||||
{
|
||||
public function test_paid_content_docs_render_chinese_html_under_zh_cn(): void
|
||||
{
|
||||
app()->setLocale('zh_CN');
|
||||
$manager = app(PluginManager::class);
|
||||
|
||||
$markdown = $manager->readDocs('larablog/paid-content');
|
||||
$html = $manager->readDocsHtml('larablog/paid-content');
|
||||
|
||||
$this->assertNotNull($markdown);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringContainsString('付费内容', $markdown);
|
||||
$this->assertStringContainsString('<h1', $html);
|
||||
$this->assertStringContainsString('付费内容', $html);
|
||||
$this->assertStringContainsString('<strong>', $html);
|
||||
$this->assertStringNotContainsString('# Paid Content', (string) $markdown);
|
||||
$this->assertStringNotContainsString('# 付费内容', $html);
|
||||
}
|
||||
|
||||
public function test_docs_prefer_locale_file_then_fallback_to_readme(): void
|
||||
{
|
||||
$root = storage_path('framework/testing/plugins-'.uniqid());
|
||||
$pluginDir = $root.'/acme/docsdemo';
|
||||
mkdir($pluginDir, 0777, true);
|
||||
file_put_contents($pluginDir.'/plugin.json', json_encode([
|
||||
'name' => 'acme/docsdemo',
|
||||
'title' => 'Docs demo',
|
||||
'version' => '1.0.0',
|
||||
'docs' => 'README.md',
|
||||
]));
|
||||
file_put_contents($pluginDir.'/README.md', "# English\n\nHello **world**.");
|
||||
file_put_contents($pluginDir.'/README.zh_CN.md', "# 中文说明\n\n这是**加粗**。\n");
|
||||
|
||||
config(['larablog.plugin_path' => $root]);
|
||||
$manager = app(PluginManager::class);
|
||||
|
||||
try {
|
||||
$zh = (string) $manager->readDocs('acme/docsdemo', 'zh_CN');
|
||||
$this->assertStringContainsString('中文说明', $zh);
|
||||
|
||||
$html = (string) $manager->readDocsHtml('acme/docsdemo', 'zh_CN');
|
||||
$this->assertStringContainsString('<h1', $html);
|
||||
$this->assertStringContainsString('中文说明', $html);
|
||||
$this->assertStringContainsString('<strong>', $html);
|
||||
$this->assertStringNotContainsString('# 中文说明', $html);
|
||||
|
||||
$en = (string) $manager->readDocs('acme/docsdemo', 'en');
|
||||
$this->assertStringContainsString('English', $en);
|
||||
$this->assertStringNotContainsString('中文说明', $en);
|
||||
} finally {
|
||||
unlink($pluginDir.'/README.zh_CN.md');
|
||||
unlink($pluginDir.'/README.md');
|
||||
unlink($pluginDir.'/plugin.json');
|
||||
rmdir($pluginDir);
|
||||
rmdir($root.'/acme');
|
||||
rmdir($root);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_unsafe_locale_does_not_escape_plugin_directory(): void
|
||||
{
|
||||
$root = storage_path('framework/testing/plugins-'.uniqid());
|
||||
$pluginDir = $root.'/acme/safe';
|
||||
mkdir($pluginDir, 0777, true);
|
||||
file_put_contents($pluginDir.'/plugin.json', json_encode([
|
||||
'name' => 'acme/safe',
|
||||
'title' => 'Safe',
|
||||
'version' => '1.0.0',
|
||||
'docs' => 'README.md',
|
||||
]));
|
||||
file_put_contents($pluginDir.'/README.md', "# Safe\n");
|
||||
|
||||
config(['larablog.plugin_path' => $root]);
|
||||
$manager = app(PluginManager::class);
|
||||
|
||||
try {
|
||||
$path = $manager->docsPath('acme/safe', '../..');
|
||||
$this->assertNotNull($path);
|
||||
$this->assertSame(realpath($pluginDir.'/README.md'), $path);
|
||||
$this->assertStringContainsString('Safe', (string) $manager->readDocs('acme/safe', '../..'));
|
||||
} finally {
|
||||
unlink($pluginDir.'/README.md');
|
||||
unlink($pluginDir.'/plugin.json');
|
||||
rmdir($pluginDir);
|
||||
rmdir($root.'/acme');
|
||||
rmdir($root);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
# sablog 最小样例包
|
||||
|
||||
用于 `sablog:import` 回归测试,不依赖真实 sablog 库。
|
||||
用于 `sablog:import` 回归测试,不依赖真实 sablog 库。完整导入步骤见 [docs/ops/import.md](../../../docs/ops/import.md)。
|
||||
|
||||
- `schema.sql` / `seed.sql`:SQLite 源库结构与数据
|
||||
- `attachments/`:本地附件目录(与 `filepath` 对齐)
|
||||
|
||||
Reference in New Issue
Block a user