feat(packages): implement package-based business model
This commit is contained in:
127
tests/Feature/EventControllerTest.php
Normal file
127
tests/Feature/EventControllerTest.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Package;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_event_with_valid_package_succeeds(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'Test Event',
|
||||
'slug' => 'test-event',
|
||||
'date' => '2025-10-01',
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Test Event',
|
||||
'slug' => 'test-event',
|
||||
]);
|
||||
|
||||
$event = Event::latest()->first();
|
||||
$this->assertDatabaseHas('event_packages', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => 'endcustomer_event',
|
||||
'provider_id' => 'manual',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_event_without_package_fails(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'Test Event',
|
||||
'slug' => 'test-event',
|
||||
'date' => '2025-10-01',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['package_id']);
|
||||
}
|
||||
|
||||
public function test_create_event_with_reseller_package_limits_events(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
|
||||
TenantPackage::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
// First event succeeds
|
||||
$response1 = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'First Event',
|
||||
'slug' => 'first-event',
|
||||
'date' => '2025-10-01',
|
||||
'package_id' => $package->id, // Use reseller package for event? Adjust if needed
|
||||
]);
|
||||
|
||||
$response1->assertStatus(201);
|
||||
|
||||
// Second event fails due to limit
|
||||
$response2 = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'Second Event',
|
||||
'slug' => 'second-event',
|
||||
'date' => '2025-10-02',
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response2->assertStatus(402)
|
||||
->assertJson(['error' => 'No available package for event creation']);
|
||||
}
|
||||
|
||||
public function test_upload_exceeds_package_limit_fails(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$event = Event::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0
|
||||
EventPackage::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'used_photos' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/v1/events/{$event->slug}/photos", [
|
||||
'photo' => 'test-photo.jpg',
|
||||
]);
|
||||
|
||||
$response->assertStatus(402)
|
||||
->assertJson(['error' => 'Upload limit reached for this event']);
|
||||
}
|
||||
}
|
||||
120
tests/Feature/StripeWebhookTest.php
Normal file
120
tests/Feature/StripeWebhookTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Controllers\Api\StripeWebhookController;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stripe\Webhook;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StripeWebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Mock Stripe secret
|
||||
config(['services.stripe.webhook_secret' => 'whsec_test_secret']);
|
||||
}
|
||||
|
||||
public function test_handle_payment_intent_succeeded_creates_event_package(): void
|
||||
{
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
$event = \App\Models\Event::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'endcustomer']);
|
||||
|
||||
$payload = [
|
||||
'id' => 'evt_test',
|
||||
'type' => 'payment_intent.succeeded',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'pi_test',
|
||||
'metadata' => [
|
||||
'type' => 'endcustomer_event',
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'event_id' => (string) $event->id,
|
||||
'package_id' => (string) $package->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true));
|
||||
|
||||
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
|
||||
'Stripe-Signature' => $sigHeader,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'type' => 'endcustomer_event',
|
||||
'provider_id' => 'pi_test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('event_packages', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_handle_invoice_paid_renews_tenant_package(): void
|
||||
{
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
$package = Package::factory()->create(['type' => 'reseller']);
|
||||
|
||||
$payload = [
|
||||
'id' => 'evt_test',
|
||||
'type' => 'invoice.paid',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'subscription' => 'sub_test',
|
||||
'metadata' => [
|
||||
'type' => 'reseller_subscription',
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true));
|
||||
|
||||
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
|
||||
'Stripe-Signature' => $sigHeader,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => 'reseller_subscription',
|
||||
]);
|
||||
|
||||
$tenantPackage = TenantPackage::where('tenant_id', $tenant->id)->first();
|
||||
$this->assertNotNull($tenantPackage);
|
||||
$this->assertTrue($tenantPackage->expires_at->isFuture());
|
||||
}
|
||||
|
||||
public function test_webhook_rejects_invalid_signature(): void
|
||||
{
|
||||
$payload = ['type' => 'invalid'];
|
||||
$sigHeader = 'invalid';
|
||||
|
||||
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
|
||||
'Stripe-Signature' => $sigHeader,
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
}
|
||||
}
|
||||
60
tests/e2e/package-flow.test.ts
Normal file
60
tests/e2e/package-flow.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
test.describe('Package Flow in Admin PWA', () => {
|
||||
test('Create event with package and verify limits', async ({ page }) => {
|
||||
// Assume logged in as tenant admin, navigate to events page
|
||||
await page.goto('/admin/events');
|
||||
|
||||
// Click create event button
|
||||
await page.click('[data-testid="create-event"]');
|
||||
await expect(page).toHaveURL(/\/admin\/events\/create/);
|
||||
|
||||
// Fill form
|
||||
await page.fill('[name="name"]', 'Test Package Event');
|
||||
await page.fill('[name="slug"]', 'test-package-event');
|
||||
await page.fill('[name="date"]', '2025-10-01');
|
||||
|
||||
// Select package from dropdown
|
||||
await page.selectOption('[name="package_id"]', '1'); // Assume ID 1 is Starter package
|
||||
await expect(page.locator('[name="package_id"]')).toHaveValue('1');
|
||||
|
||||
// Submit
|
||||
await page.click('[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/admin\/events/);
|
||||
|
||||
// Verify event created and package assigned
|
||||
await expect(page.locator('text=Test Package Event')).toBeVisible();
|
||||
await expect(page.locator('text=Starter')).toBeVisible(); // Package name in table
|
||||
|
||||
// Check dashboard limits
|
||||
await page.goto('/admin/dashboard');
|
||||
await expect(page.locator('text=Remaining Photos')).toContainText('300'); // Starter limit
|
||||
|
||||
// Try to create another event to test reseller limit if applicable
|
||||
// (Skip for endcustomer; assume tenant has reseller package with limit 1)
|
||||
await page.goto('/admin/events');
|
||||
await page.click('[data-testid="create-event"]');
|
||||
await page.fill('[name="name"]', 'Second Event');
|
||||
await page.fill('[name="slug"]', 'second-event');
|
||||
await page.fill('[name="date"]', '2025-10-02');
|
||||
await page.selectOption('[name="package_id"]', '1');
|
||||
await page.click('[type="submit"]');
|
||||
|
||||
// If limit reached, expect error
|
||||
await expect(page.locator('text=No available package')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Upload blocked when package limit reached in Guest PWA', async ({ page }) => {
|
||||
// Assume event with package limit 0 created
|
||||
await page.goto('/e/test-limited-event'); // Slug of event with max_photos = 0
|
||||
|
||||
// Navigate to upload
|
||||
await page.click('text=Upload');
|
||||
await expect(page).toHaveURL(/\/upload/);
|
||||
|
||||
// Expect upload disabled and error message
|
||||
await expect(page.locator('button:disabled')).toBeVisible(); // Upload button disabled
|
||||
await expect(page.locator('text=Upload-Limit erreicht')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user