This commit is contained in:
Mark Gregory
2026-10-19 14:11:28 +01:00
parent 26a2b448a5
commit ac0c5410c1
128 changed files with 5593 additions and 217 deletions
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
final class CheckoutPageTest extends TestCase
{
public function test_buy_page_contains_all_four_configured_paddle_prices(): void
{
config()->set('rsync-studio.paddle.client_side_token', 'test_example');
config()->set('rsync-studio.paddle.environment', 'sandbox');
config()->set('rsync-studio.paddle.personal_monthly_price_id', 'pri_personal_monthly');
config()->set('rsync-studio.paddle.personal_annual_price_id', 'pri_personal_annual');
config()->set('rsync-studio.paddle.professional_monthly_price_id', 'pri_professional_monthly');
config()->set('rsync-studio.paddle.professional_annual_price_id', 'pri_professional_annual');
$this->get('/buy')
->assertOk()
->assertSee('7-day free trial')
->assertSee('pri_personal_monthly')
->assertSee('pri_personal_annual')
->assertSee('pri_professional_monthly')
->assertSee('pri_professional_annual')
->assertSee('test_example');
}
}
+70 -6
View File
@@ -1,6 +1,8 @@
<?php
namespace Tests\Feature;
use App\Models\LicenseActivation;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -9,13 +11,75 @@ use Tests\TestCase;
class LicenseActivationTest extends TestCase
{
use RefreshDatabase;
public function test_activation_is_idempotent_and_enforces_machine_limit(): void
{
$product=LicenseProduct::create(['code'=>'test','name'=>'Test','edition'=>'Professional','activation_limit'=>1,'max_major_version'=>1,'active'=>true]);
[, $key]=app(LicenseIssuer::class)->issue($product,'Test','test@example.com');
$one=['licenseKey'=>$key,'installationID'=>'11111111-1111-4111-8111-111111111111','appVersion'=>'1.0.0','platform'=>'macOS'];
$this->postJson('/api/v1/activate',$one)->assertOk()->assertJsonStructure(['licenseToken']);
$this->postJson('/api/v1/activate',$one)->assertOk();
$this->postJson('/api/v1/activate',array_merge($one,['installationID'=>'22222222-2222-4222-8222-222222222222']))->assertStatus(409);
$product = LicenseProduct::create(['code' => 'test', 'name' => 'Test', 'edition' => 'Professional', 'activation_limit' => 1, 'max_major_version' => 1, 'active' => true]);
[, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com');
$one = ['licenseKey' => $key, 'installationID' => '11111111-1111-4111-8111-111111111111', 'appVersion' => '1.0.0', 'platform' => 'macOS'];
$this->postJson('/api/v1/activate', $one)->assertOk()->assertJsonStructure(['licenseToken']);
$this->postJson('/api/v1/activate', $one)->assertOk();
$this->postJson('/api/v1/activate', array_merge($one, ['installationID' => '22222222-2222-4222-8222-222222222222']))->assertStatus(409);
}
public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void
{
$product = LicenseProduct::create([
'code' => 'validate-active',
'name' => 'Validate Active',
'edition' => 'Professional',
'activation_limit' => 1,
'max_major_version' => 1,
'active' => true,
]);
[, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com');
$request = [
'licenseKey' => $key,
'installationID' => '11111111-1111-4111-8111-111111111111',
'appVersion' => '1.0.0',
'platform' => 'macOS',
];
$token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken');
$this->postJson('/api/v1/validate', [
'licenseToken' => $token,
'installationID' => $request['installationID'],
'appVersion' => '1.0.1',
'platform' => 'macOS',
])->assertOk()->assertJson([
'active' => true,
])->assertJsonStructure(['licenseToken']);
}
public function test_validation_rejects_an_installation_deactivated_from_the_portal(): void
{
$product = LicenseProduct::create([
'code' => 'validate-deactivated',
'name' => 'Validate Deactivated',
'edition' => 'Personal',
'activation_limit' => 1,
'max_major_version' => 1,
'active' => true,
]);
[, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com');
$request = [
'licenseKey' => $key,
'installationID' => '22222222-2222-4222-8222-222222222222',
'appVersion' => '1.0.0',
'platform' => 'macOS',
];
$token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken');
LicenseActivation::query()->where('installation_id', strtolower($request['installationID']))->update(['deactivated_at' => now()]);
$this->postJson('/api/v1/validate', [
'licenseToken' => $token,
'installationID' => $request['installationID'],
'appVersion' => '1.0.0',
'platform' => 'macOS',
])->assertStatus(403)->assertJson([
'message' => 'This Mac has been deactivated from the licence portal.',
]);
}
}
@@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PaddleSubscriptionProvisioningTest extends TestCase
{
use RefreshDatabase;
private function product(string $price, string $edition, int $activations): LicenseProduct
{
return LicenseProduct::create([
'code' => strtolower($edition).'-'.$price,
'name' => $edition,
'edition' => $edition,
'activation_limit' => $activations,
'max_major_version' => 1,
'payment_provider' => 'paddle',
'external_product_id' => $edition === 'Personal' ? 'pro_personal' : 'pro_professional',
'external_price_id' => $price,
'active' => true,
]);
}
private function subscription(string $type, string $status, string $price, string $product, array $extra = []): array
{
return [
'event_type' => $type,
'data' => array_replace_recursive([
'id' => 'sub_test',
'status' => $status,
'customer_id' => 'ctm_test',
'transaction_id' => 'txn_test',
'custom_data' => ['customer_name' => 'Mark', 'customer_email' => 'mark@example.com'],
'current_billing_period' => ['starts_at' => '2026-09-09T00:00:00Z', 'ends_at' => '2026-10-09T00:00:00Z'],
'scheduled_change' => null,
'items' => [[
'recurring' => true,
'price' => ['id' => $price, 'product_id' => $product],
]],
], $extra),
];
}
public function test_trialing_subscription_issues_a_usable_license(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
'subscription.created', 'trialing', 'pri_personal_monthly', 'pro_personal'
));
$license = License::firstOrFail();
$this->assertSame('Personal', $license->edition);
$this->assertSame('sub_test', $license->external_subscription_id);
$this->assertSame('trialing', $license->subscription_status);
$this->assertTrue($license->is_usable);
}
public function test_upgrade_changes_entitlement_without_changing_license(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
$this->product('pri_professional_monthly', 'Professional', 3);
$service = app(PurchaseProvisioner::class);
$service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'));
$id = License::firstOrFail()->id;
$service->provision('paddle', $this->subscription('subscription.updated', 'active', 'pri_professional_monthly', 'pro_professional'));
$license = License::firstOrFail();
$this->assertSame($id, $license->id);
$this->assertSame('Professional', $license->edition);
$this->assertSame(3, $license->activation_limit);
}
public function test_scheduled_cancellation_does_not_revoke_before_effective_date(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal',
['data' => ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']]]
));
$license = License::firstOrFail();
$this->assertNull($license->revoked_at);
$this->assertNotNull($license->cancel_scheduled_at);
}
public function test_canceled_subscription_revokes_license(): void
{
$this->product('pri_personal_monthly', 'Personal', 1);
$service = app(PurchaseProvisioner::class);
$service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'));
$service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['data' => ['current_billing_period' => null]]));
$license = License::firstOrFail();
$this->assertNotNull($license->revoked_at);
$this->assertFalse($license->is_usable);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\User;
use App\Services\Payments\PaddleClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
use Tests\TestCase;
class PortalSubscriptionManagementTest extends TestCase
{
use RefreshDatabase;
private function product(string $code, string $edition, string $price, int $limit): LicenseProduct
{
return LicenseProduct::create([
'code' => $code,
'name' => 'Rsync Studio '.$edition.' '.(str_contains($code, 'annual') ? 'Annual' : 'Monthly'),
'edition' => $edition,
'activation_limit' => $limit,
'max_major_version' => 1,
'payment_provider' => 'paddle',
'external_product_id' => 'pro_'.strtolower($edition),
'external_price_id' => $price,
'active' => true,
]);
}
public function test_personal_subscription_is_upgraded_in_place(): void
{
config()->set('rsync-studio.paddle.personal_monthly_price_id', 'pri_personal_monthly');
config()->set('rsync-studio.paddle.professional_monthly_price_id', 'pri_professional_monthly');
$personal = $this->product('personal-monthly', 'Personal', 'pri_personal_monthly', 1);
$professional = $this->product('professional-monthly', 'Professional', 'pri_professional_monthly', 3);
$user = User::factory()->create(['email' => 'mark@example.com', 'email_verified_at' => now()]);
$license = License::create([
'license_product_id' => $personal->id,
'license_key_hash' => str_repeat('a', 64),
'license_key_last4' => 'TEST',
'customer_email' => $user->email,
'edition' => 'Personal',
'activation_limit' => 1,
'max_major_version' => 1,
'payment_provider' => 'paddle',
'external_subscription_id' => 'sub_test',
'external_price_id' => 'pri_personal_monthly',
'subscription_status' => 'active',
]);
$this->mock(PaddleClient::class, function (MockInterface $mock) {
$mock->shouldReceive('updateSubscriptionPrice')->once()->with('sub_test', 'pri_professional_monthly')->andReturn(['status' => 'active']);
});
$this->actingAs($user)->post(route('portal.upgrade', $license))->assertRedirect(route('portal.show', $license));
$license->refresh();
$this->assertSame($professional->id, $license->license_product_id);
$this->assertSame('Professional', $license->edition);
$this->assertSame(3, $license->activation_limit);
$this->assertSame('sub_test', $license->external_subscription_id);
}
}