Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
383 lines
13 KiB
PHP
383 lines
13 KiB
PHP
<?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 Plugins\Larablog\PaidContent\Models\ArticleProduct;
|
|
use Plugins\Larablog\PaidContent\PluginServiceProvider as PaidContentProvider;
|
|
use Plugins\Larablog\Payment\Domain\OrderService;
|
|
use Plugins\Larablog\Payment\Domain\ProductType;
|
|
use Plugins\Larablog\Payment\Models\Order;
|
|
use Plugins\Larablog\Payment\Models\OrderItem;
|
|
use Plugins\Larablog\Payment\PluginServiceProvider as PaymentProvider;
|
|
use RuntimeException;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
class PaidContentCommerceTest 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']);
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $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];
|
|
}
|
|
}
|