artisan('migrate', ['--path' => 'plugins/larablog/payment/database/migrations']); $this->artisan('migrate', ['--path' => 'plugins/larablog/paid-content/database/migrations']); app()->register(PaymentProvider::class); app()->register(PaidContentProvider::class); } public function test_plugin_requires_payment_before_paid_content(): void { $manager = app(PluginManager::class); $manager->syncDiscoveredPlugins(); Plugin::query()->where('name', 'larablog/payment')->update(['enabled' => false]); Plugin::query()->where('name', 'larablog/paid-content')->update(['enabled' => false]); $this->expectException(RuntimeException::class); $manager->enable('larablog/paid-content'); } public function test_password_article_blocked_on_api_and_rss(): void { [$article] = $this->makeArticle([ 'content' => str_repeat('SECRET-BODY ', 20), 'read_password' => 's3cret', 'description' => 'short desc', ]); $this->getJson('/api/v1/articles/'.$article->id) ->assertOk() ->assertJsonPath('data.access.status', AccessDecision::NEED_PASSWORD) ->assertJsonPath('data.content', null); $rss = (string) $this->get('/rss.xml')->assertOk()->getContent(); $this->assertStringNotContainsString('SECRET-BODY', $rss); $this->assertStringContainsString('short desc', $rss); } public function test_paid_article_teaser_then_purchase_unlocks_web(): void { $buyer = User::factory()->create(); $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), ]); ArticleProduct::query()->create([ 'article_id' => $article->id, 'enabled' => true, 'price' => '9.90', 'currency' => 'CNY', 'trial_mode' => 'chars', 'trial_value' => 40, ]); $this->get('/show-'.$article->id.'.shtml') ->assertOk() ->assertSee(__('frontend.article.paywall_title')) ->assertDontSee('UNIQUE_FULL_TOKEN'); $this->getJson('/api/v1/articles/'.$article->id) ->assertOk() ->assertJsonPath('data.access.status', AccessDecision::NEED_PURCHASE) ->assertJsonPath('data.content', null); $orders = app(OrderService::class); $order = $orders->createOrder( $buyer, ProductType::ARTICLE, (int) $article->id, (string) $article->title, '9.90', ); $orders->markPaid($order, ['source' => 'test']); $this->actingAs($buyer) ->get('/show-'.$article->id.'.shtml') ->assertOk() ->assertSee('UNIQUE_FULL_TOKEN') ->assertDontSee(__('frontend.article.paywall_title')); } public function test_stub_rejects_duplicate_entitlement_order(): void { $user = User::factory()->create(); [$article] = $this->makeArticle(); $orders = app(OrderService::class); $first = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00'); $orders->markPaid($first); $this->expectException(RuntimeException::class); $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00'); } public function test_guest_checkout_redirects_to_login_not_500(): void { [$article] = $this->makeArticle(); ArticleProduct::query()->create([ 'article_id' => $article->id, 'enabled' => true, 'price' => '1.00', 'currency' => 'CNY', 'trial_mode' => 'chars', 'trial_value' => 20, ]); $this->get('/plugins/payment/checkout?'.http_build_query([ 'product_type' => ProductType::ARTICLE, 'product_id' => $article->id, ]))->assertRedirect('/login.shtml'); } public function test_home_list_does_not_leak_paid_body(): void { [$article] = $this->makeArticle([ 'description' => null, 'content' => str_repeat('preview ', 30)."\n\nHOME_LEAK_TOKEN\n\n".str_repeat('tail ', 30), ]); ArticleProduct::query()->create([ 'article_id' => $article->id, 'enabled' => true, 'price' => '2.00', 'currency' => 'CNY', 'trial_mode' => 'chars', 'trial_value' => 20, ]); $this->get('/') ->assertOk() ->assertDontSee('HOME_LEAK_TOKEN'); } public function test_cannot_disable_payment_while_paid_content_enabled(): void { $manager = app(PluginManager::class); $manager->syncDiscoveredPlugins(); $manager->enable('larablog/payment'); $manager->enable('larablog/paid-content'); $this->expectException(RuntimeException::class); $manager->disable('larablog/payment'); } public function test_teaser_never_returns_full_body(): void { [$article] = $this->makeArticle([ 'description' => null, 'content' => 'TEASER_HEAD short paid body ending with TEASER_TAIL_TOKEN', ]); ArticleProduct::query()->create([ 'article_id' => $article->id, 'enabled' => true, 'price' => '1.00', 'currency' => 'CNY', 'trial_mode' => 'chars', 'trial_value' => 9999, ]); $this->get('/show-'.$article->id.'.shtml') ->assertOk() ->assertSee(__('frontend.article.paywall_title')) ->assertDontSee('TEASER_TAIL_TOKEN'); $this->getJson('/api/v1/articles/'.$article->id) ->assertOk() ->assertJsonPath('data.access.status', AccessDecision::NEED_PURCHASE) ->assertJsonMissing(['teaser_html' => 'TEASER_TAIL_TOKEN']); } public function test_access_filter_can_only_tighten(): void { [$article] = $this->makeArticle(); Hook::listen('article.access', fn (AccessDecision $decision): AccessDecision => $decision->tightenWith( AccessDecision::needPurchase('teaser', '/buy') )); Hook::listen('article.access', fn (AccessDecision $decision): AccessDecision => AccessDecision::allow()); Hook::listen('article.access', fn (): string => 'not-a-decision'); $decision = app(ArticleAccess::class)->resolve($article, null, []); $this->assertSame(AccessDecision::NEED_PURCHASE, $decision->status); } public function test_plugin_docs_cannot_escape_plugin_directory(): void { $root = storage_path('framework/testing/plugins-'.uniqid()); $pluginDir = $root.'/acme/evil'; mkdir($pluginDir, 0777, true); file_put_contents($pluginDir.'/plugin.json', json_encode([ 'name' => 'acme/evil', 'title' => 'Evil', 'version' => '1.0.0', 'docs' => '../../../../.env', ])); config(['larablog.plugin_path' => $root]); $manager = app(PluginManager::class); try { $this->assertNull($manager->docsPath('acme/evil')); $this->assertNull($manager->readDocs('acme/evil')); } finally { // Only remove the temp tree this test created. unlink($pluginDir.'/plugin.json'); rmdir($pluginDir); rmdir($root.'/acme'); rmdir($root); } } public function test_duplicate_pending_order_is_reused(): void { $user = User::factory()->create(); [$article] = $this->makeArticle(); $orders = app(OrderService::class); $first = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00'); $second = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00'); $this->assertSame($first->id, $second->id); } public function test_reused_pending_order_is_repriced(): void { $user = User::factory()->create(); [$article] = $this->makeArticle(); $orders = app(OrderService::class); $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'Old', '1.00'); $reused = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'New', '9.90'); $this->assertSame('9.90', (string) $reused->amount); $this->assertSame('9.90', (string) $reused->items->first()->amount); $this->assertSame('New', $reused->items->first()->title); $this->assertSame(1, Order::query()->count()); } public function test_mark_paid_rejects_product_owned_by_another_order(): void { $user = User::factory()->create(); [$article] = $this->makeArticle(); $orders = app(OrderService::class); $paid = $orders->createOrder($user, ProductType::ARTICLE, (int) $article->id, 'T', '1.00'); $orders->markPaid($paid); $duplicate = Order::query()->create([ 'user_id' => $user->id, 'status' => Order::STATUS_PENDING, 'amount' => '1.00', 'currency' => 'CNY', 'gateway' => 'stub', ]); OrderItem::query()->create([ 'order_id' => $duplicate->id, 'product_type' => ProductType::ARTICLE, 'product_id' => $article->id, 'title' => 'T', 'amount' => '1.00', ]); $this->expectException(RuntimeException::class); $orders->markPaid($duplicate); } public function test_checkout_rejects_unpublished_paid_article(): void { $user = User::factory()->create(); [$article] = $this->makeArticle(['visible' => false]); ArticleProduct::query()->create([ 'article_id' => $article->id, 'enabled' => true, 'price' => '5.00', 'currency' => 'CNY', 'trial_mode' => 'chars', 'trial_value' => 20, ]); $this->actingAs($user) ->get('/plugins/payment/checkout?'.http_build_query([ 'product_type' => ProductType::ARTICLE, 'product_id' => $article->id, ])) ->assertStatus(422); $this->assertSame(0, Order::query()->count()); } public function test_author_and_admin_bypass_paywall(): void { Role::findOrCreate('admin'); $author = User::factory()->create(); $admin = User::factory()->create(); $admin->assignRole('admin'); [$article] = $this->makeArticle([ 'user_id' => $author->id, 'content' => str_repeat('full-access-body ', 30), ]); ArticleProduct::query()->create([ 'article_id' => $article->id, 'enabled' => true, 'price' => '3.00', 'currency' => 'CNY', 'trial_mode' => 'chars', 'trial_value' => 10, ]); $access = app(ArticleAccess::class); $this->assertTrue($access->resolve($article, $author)->isAllow()); $this->assertTrue($access->resolve($article, $admin)->isAllow()); $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 $overrides * @return array{0: Article, 1: User, 2: Category} */ protected function makeArticle(array $overrides = []): array { $userId = $overrides['user_id'] ?? null; $user = $userId ? User::query()->findOrFail($userId) : User::factory()->create(); $category = Category::query()->create(['name' => 'Cat', 'display_order' => 0]); $payload = array_merge([ 'category_id' => $category->id, 'user_id' => $user->id, 'title' => 'Paid Post', 'content' => 'Hello body', 'content_format' => 'markdown', 'published_at' => now()->subMinute(), 'visible' => true, ], $overrides); $article = Article::query()->create($payload); return [$article, $user, $category]; } }