feat: subscriptions
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\ReviewIdentityVerification;
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Enums\CreditProductType;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Enums\PaymentTransactionStatus;
|
||||
use App\Enums\UserRole;
|
||||
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
|
||||
use App\Listeners\GrantCreditsFromStripeWebhook;
|
||||
use App\Models\CreditProduct;
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\User;
|
||||
use App\Policies\IdentityVerificationRequestPolicy;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('requires the lifetime purchase before accepting an identity document', function () {
|
||||
Storage::fake('identity_documents');
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$this->post('/api/certification/identity-document', [
|
||||
'document' => UploadedFile::fake()->image('identity.jpg'),
|
||||
])->assertForbidden();
|
||||
|
||||
expect(IdentityVerificationRequest::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('stores identity documents privately without exposing their path', function () {
|
||||
Storage::fake('identity_documents');
|
||||
$user = User::factory()->create([
|
||||
'certification_purchased_at' => now(),
|
||||
]);
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->post('/api/certification/identity-document', [
|
||||
'document' => UploadedFile::fake()->image('my-passport.jpg'),
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.entitlementPurchased', true)
|
||||
->assertJsonPath('data.canSubmitIdentity', false)
|
||||
->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value)
|
||||
->assertJsonMissingPath('data.verification.documentPath');
|
||||
|
||||
$verificationRequest = IdentityVerificationRequest::query()->sole();
|
||||
|
||||
Storage::disk('identity_documents')->assertExists($verificationRequest->document_path);
|
||||
|
||||
expect($verificationRequest->document_path)->toStartWith('submissions/')
|
||||
->not->toContain('my-passport')
|
||||
->and(Storage::disk('identity_documents')->getVisibility($verificationRequest->document_path))
|
||||
->toBe('private');
|
||||
});
|
||||
|
||||
it('prevents replacing a document while it is under review', function () {
|
||||
Storage::fake('identity_documents');
|
||||
$user = User::factory()->create([
|
||||
'certification_purchased_at' => now(),
|
||||
]);
|
||||
IdentityVerificationRequest::factory()->for($user)->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this
|
||||
->withHeader('Accept', 'application/json')
|
||||
->post('/api/certification/identity-document', [
|
||||
'document' => UploadedFile::fake()->image('identity.jpg'),
|
||||
])
|
||||
->assertConflict()
|
||||
->assertJsonPath('message', __('api.certification.already_pending'));
|
||||
});
|
||||
|
||||
it('approves a certification and removes the reviewed identity document', function () {
|
||||
Storage::fake('identity_documents');
|
||||
$user = User::factory()->create([
|
||||
'certification_purchased_at' => now(),
|
||||
]);
|
||||
$admin = User::factory()->create([
|
||||
'role' => UserRole::ADMIN,
|
||||
]);
|
||||
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
|
||||
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
|
||||
|
||||
app(ReviewIdentityVerification::class)->execute(
|
||||
$verificationRequest,
|
||||
IdentityVerificationStatus::APPROVED,
|
||||
$admin,
|
||||
);
|
||||
|
||||
Storage::disk('identity_documents')->assertMissing($verificationRequest->document_path);
|
||||
|
||||
expect($user->fresh()->account_verified_at)->not->toBeNull()
|
||||
->and($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED)
|
||||
->and($verificationRequest->fresh()->document_path)->toBeNull()
|
||||
->and($verificationRequest->fresh()->document_deleted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('allows a new submission after rejection without another purchase', function () {
|
||||
Storage::fake('identity_documents');
|
||||
$user = User::factory()->create([
|
||||
'certification_purchased_at' => now(),
|
||||
]);
|
||||
$admin = User::factory()->create([
|
||||
'role' => UserRole::ADMIN,
|
||||
]);
|
||||
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
|
||||
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
|
||||
|
||||
app(ReviewIdentityVerification::class)->execute(
|
||||
$verificationRequest,
|
||||
IdentityVerificationStatus::REJECTED,
|
||||
$admin,
|
||||
'Le document est illisible.',
|
||||
);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->post('/api/certification/identity-document', [
|
||||
'document' => UploadedFile::fake()->image('identity-new.jpg'),
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.verification.status', IdentityVerificationStatus::PENDING->value);
|
||||
|
||||
expect($verificationRequest->fresh()->rejection_reason)->toBeNull()
|
||||
->and($verificationRequest->fresh()->document_path)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('restricts identity review records to administrators', function () {
|
||||
$policy = new IdentityVerificationRequestPolicy;
|
||||
$request = IdentityVerificationRequest::factory()->make();
|
||||
$admin = User::factory()->make(['role' => UserRole::ADMIN]);
|
||||
$moderator = User::factory()->make(['role' => UserRole::MODERATOR]);
|
||||
|
||||
expect($policy->viewAny($admin))->toBeTrue()
|
||||
->and($policy->view($admin, $request))->toBeTrue()
|
||||
->and($policy->viewAny($moderator))->toBeFalse()
|
||||
->and($policy->view($moderator, $request))->toBeFalse();
|
||||
});
|
||||
|
||||
it('lets an administrator approve the identity request from filament', function () {
|
||||
app()->setLocale('fr');
|
||||
Filament::setCurrentPanel(Filament::getPanel('dashboard'));
|
||||
Storage::fake('identity_documents');
|
||||
|
||||
$admin = User::factory()->create([
|
||||
'role' => UserRole::ADMIN,
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'certification_purchased_at' => now(),
|
||||
]);
|
||||
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
|
||||
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test(ListIdentityVerificationRequests::class)
|
||||
->assertCanSeeTableRecords([$verificationRequest])
|
||||
->callTableAction('approve', $verificationRequest);
|
||||
|
||||
expect($verificationRequest->fresh()->status)->toBe(IdentityVerificationStatus::APPROVED)
|
||||
->and($user->fresh()->account_verified_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('grants lifetime certification entitlement from the paid Stripe webhook once', function () {
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_certification',
|
||||
]);
|
||||
$product = CreditProduct::create([
|
||||
'name' => 'Certification',
|
||||
'type' => CreditProductType::ONE_TIME,
|
||||
'purpose' => BillingProductPurpose::CERTIFICATION,
|
||||
'credits' => 0,
|
||||
'amount' => 1999,
|
||||
'currency' => 'eur',
|
||||
'stripe_price_id' => 'price_certification',
|
||||
]);
|
||||
$event = (object) [
|
||||
'payload' => [
|
||||
'id' => 'evt_certification',
|
||||
'type' => 'checkout.session.completed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'cs_certification',
|
||||
'mode' => 'payment',
|
||||
'payment_status' => 'paid',
|
||||
'customer' => $user->stripe_id,
|
||||
'payment_intent' => 'pi_certification',
|
||||
'amount_total' => 1999,
|
||||
'currency' => 'eur',
|
||||
'metadata' => [
|
||||
'credit_product_id' => $product->getKey(),
|
||||
'billing_product_purpose' => BillingProductPurpose::CERTIFICATION->value,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$listener = app(GrantCreditsFromStripeWebhook::class);
|
||||
$listener->handle($event);
|
||||
$firstPurchaseDate = $user->fresh()->certification_purchased_at;
|
||||
$listener->handle($event);
|
||||
|
||||
expect($firstPurchaseDate)->not->toBeNull()
|
||||
->and($user->fresh()->certification_purchased_at?->equalTo($firstPurchaseDate))->toBeTrue()
|
||||
->and(PaymentTransaction::query()->count())->toBe(1)
|
||||
->and(PaymentTransaction::query()->sole()->status)->toBe(PaymentTransactionStatus::PAID);
|
||||
});
|
||||
Reference in New Issue
Block a user