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 $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]; } }