wip: article AI polish, category SEO fields, cover generator, membership plan seeder
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped

This commit is contained in:
2026-09-07 18:48:37 +00:00
parent 263b98b218
commit 3cec4c5e18
121 changed files with 4701 additions and 313 deletions
+243
View File
@@ -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];
}
}