diff --git a/TODO.md b/TODO.md index edb2bd2..5c6b7a9 100644 --- a/TODO.md +++ b/TODO.md @@ -167,7 +167,7 @@ The test file covers workspace-level entitlements but not namespace-level (`canF --- ### TEST-002: Add integration tests for EntitlementApiController -**Status:** Open +**Status:** Fixed (2026-01-29) **File:** `tests/Feature/EntitlementApiTest.php` Need HTTP-level integration tests for the API endpoints, including authentication, validation, and error cases. @@ -178,6 +178,23 @@ Need HTTP-level integration tests for the API endpoints, including authenticatio - Test authentication failures - Test rate limiting (once implemented) +**Resolution:** +- Added comprehensive integration tests for EntitlementApiController +- Cross-App Entitlement API tests: + - `GET /api/v1/entitlements/check` - authentication, validation (email, feature, quantity), 404 responses, entitlement checks + - `POST /api/v1/entitlements/usage` - authentication, validation, recording usage with metadata + - `GET /api/v1/entitlements/summary` - authentication, workspace summary with packages, features, boosts +- Blesta Provisioning API tests: + - `POST /api/provisioning/entitlements` (store) - authentication, validation (email, name, product_code, dates), user creation, workspace creation, package provisioning + - `GET /api/provisioning/entitlements/{id}` (show) - authentication, 404 handling, entitlement details with ISO8601 dates + - `POST /api/provisioning/entitlements/{id}/suspend` - authentication, 404 handling, suspension with reason, log entry creation + - `POST /api/provisioning/entitlements/{id}/unsuspend` - authentication, 404 handling, reactivation, access restoration + - `POST /api/provisioning/entitlements/{id}/cancel` - authentication, 404 handling, cancellation with reason, access denial + - `POST /api/provisioning/entitlements/{id}/renew` - authentication, validation, date updates, log entry creation +- Error response format tests for consistency across endpoints +- Rate limiting tests verifying the `#[RateLimit]` attribute configuration (60 requests/minute) +- All tests use Pest syntax with `declare(strict_types=1)` + --- ### PERF-001: Optimise EntitlementService cache invalidation diff --git a/tests/Feature/EntitlementApiTest.php b/tests/Feature/EntitlementApiTest.php index 6b1873a..c274028 100644 --- a/tests/Feature/EntitlementApiTest.php +++ b/tests/Feature/EntitlementApiTest.php @@ -1,11 +1,17 @@ service = app(EntitlementService::class); }); -describe('Entitlement API', function () { +// ============================================================================= +// Cross-App Entitlement API Tests (check, usage, summary) +// ============================================================================= + +describe('Cross-App Entitlement API', function () { describe('GET /api/v1/entitlements/check', function () { it('requires authentication', function () { $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); @@ -65,6 +75,39 @@ describe('Entitlement API', function () { $response->assertStatus(401); }); + it('validates required parameters', function () { + $this->actingAs($this->user); + + // Missing email + $response = $this->getJson('/api/v1/entitlements/check?feature=social.accounts'); + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + // Missing feature + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email); + $response->assertStatus(422) + ->assertJsonValidationErrors(['feature']); + + // Invalid email format + $response = $this->getJson('/api/v1/entitlements/check?email=invalid-email&feature=social.accounts'); + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + }); + + it('validates quantity parameter', function () { + $this->actingAs($this->user); + + // Invalid quantity (must be positive) + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts&quantity=0'); + $response->assertStatus(422) + ->assertJsonValidationErrors(['quantity']); + + // Invalid quantity (negative) + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts&quantity=-1'); + $response->assertStatus(422) + ->assertJsonValidationErrors(['quantity']); + }); + it('returns 404 for non-existent user', function () { $this->actingAs($this->user); @@ -137,6 +180,21 @@ describe('Entitlement API', function () { 'remaining' => 1, ]); }); + + it('returns usage percentage', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + // Use 2 of 5 allowed (40%) + $this->service->recordUsage($this->workspace, 'social.accounts', quantity: 2); + Cache::flush(); + + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); + + $response->assertStatus(200); + $percentage = $response->json('usage_percentage'); + expect($percentage)->toBe(40.0); + }); }); describe('POST /api/v1/entitlements/usage', function () { @@ -149,6 +207,81 @@ describe('Entitlement API', function () { $response->assertStatus(401); }); + it('validates required parameters', function () { + $this->actingAs($this->user); + + // Missing email + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'feature' => 'social.posts.scheduled', + ]); + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + // Missing feature + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + ]); + $response->assertStatus(422) + ->assertJsonValidationErrors(['feature']); + }); + + it('validates quantity parameter', function () { + $this->actingAs($this->user); + + // Invalid quantity (must be positive) + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + 'feature' => 'social.posts.scheduled', + 'quantity' => 0, + ]); + $response->assertStatus(422) + ->assertJsonValidationErrors(['quantity']); + }); + + it('validates metadata parameter', function () { + $this->actingAs($this->user); + + // Invalid metadata (must be array) + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + 'feature' => 'social.posts.scheduled', + 'metadata' => 'not-an-array', + ]); + $response->assertStatus(422) + ->assertJsonValidationErrors(['metadata']); + }); + + it('returns 404 for non-existent user', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => 'nonexistent@example.com', + 'feature' => 'social.posts.scheduled', + ]); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => 'User not found', + ]); + }); + + it('returns 404 when user has no workspace', function () { + $this->actingAs($this->user); + $this->workspace->users()->detach($this->user->id); + + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + 'feature' => 'social.posts.scheduled', + ]); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => 'No workspace found for user', + ]); + }); + it('records usage successfully', function () { $this->actingAs($this->user); $this->service->provisionPackage($this->workspace, 'social-creator'); @@ -172,6 +305,22 @@ describe('Entitlement API', function () { expect($result->used)->toBe(3); }); + it('records usage with default quantity of 1', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + 'feature' => 'social.posts.scheduled', + ]); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'quantity' => 1, + ]); + }); + it('records usage with metadata', function () { $this->actingAs($this->user); $this->service->provisionPackage($this->workspace, 'social-creator'); @@ -196,6 +345,18 @@ describe('Entitlement API', function () { $response->assertStatus(401); }); + it('returns 404 when user has no workspace', function () { + $this->actingAs($this->user); + $this->workspace->users()->detach($this->user->id); + + $response = $this->getJson('/api/v1/entitlements/summary'); + + $response->assertStatus(404) + ->assertJson([ + 'error' => 'No workspace found', + ]); + }); + it('returns summary for authenticated user', function () { $this->actingAs($this->user); $this->service->provisionPackage($this->workspace, 'social-creator'); @@ -249,3 +410,680 @@ describe('Entitlement API', function () { }); }); }); + +// ============================================================================= +// Blesta Provisioning API Tests (store, show, suspend, unsuspend, cancel, renew) +// ============================================================================= + +describe('Blesta Provisioning API', function () { + describe('POST /api/provisioning/entitlements (store)', function () { + it('requires authentication', function () { + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'test@example.com', + 'name' => 'Test User', + 'product_code' => 'social-creator', + ]); + + $response->assertStatus(401); + }); + + it('validates required parameters', function () { + $this->actingAs($this->user); + + // Missing all required fields + $response = $this->postJson('/api/provisioning/entitlements', []); + $response->assertStatus(422) + ->assertJsonValidationErrors(['email', 'name', 'product_code']); + }); + + it('validates email format', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'invalid-email', + 'name' => 'Test User', + 'product_code' => 'social-creator', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + }); + + it('validates name max length', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'test@example.com', + 'name' => str_repeat('a', 256), + 'product_code' => 'social-creator', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + }); + + it('validates date format for billing_cycle_anchor', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'test@example.com', + 'name' => 'Test User', + 'product_code' => 'social-creator', + 'billing_cycle_anchor' => 'not-a-date', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['billing_cycle_anchor']); + }); + + it('validates date format for expires_at', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'test@example.com', + 'name' => 'Test User', + 'product_code' => 'social-creator', + 'expires_at' => 'invalid-date', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['expires_at']); + }); + + it('returns 404 for non-existent package', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'test@example.com', + 'name' => 'Test User', + 'product_code' => 'nonexistent-package', + ]); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => "Package 'nonexistent-package' not found", + ]); + }); + + it('creates new user when user does not exist', function () { + Event::fake([Registered::class]); + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'newuser@example.com', + 'name' => 'New User', + 'product_code' => 'social-creator', + ]); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'package' => 'social-creator', + 'status' => WorkspacePackage::STATUS_ACTIVE, + ]); + + // Verify user was created + $newUser = User::where('email', 'newuser@example.com')->first(); + expect($newUser)->not->toBeNull(); + expect($newUser->name)->toBe('New User'); + + // Verify Registered event was fired + Event::assertDispatched(Registered::class, function ($event) use ($newUser) { + return $event->user->id === $newUser->id; + }); + }); + + it('creates workspace for new user', function () { + Event::fake([Registered::class]); + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => 'newuser@example.com', + 'name' => 'New User', + 'product_code' => 'social-creator', + ]); + + $response->assertStatus(201); + + $newUser = User::where('email', 'newuser@example.com')->first(); + $workspace = $newUser->ownedWorkspaces()->first(); + + expect($workspace)->not->toBeNull(); + expect($workspace->name)->toContain('New User'); + }); + + it('uses existing user when user already exists', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => $this->user->email, + 'name' => 'Different Name', // Should be ignored for existing user + 'product_code' => 'social-creator', + ]); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'workspace_id' => $this->workspace->id, + ]); + }); + + it('provisions package with optional parameters', function () { + $this->actingAs($this->user); + $billingAnchor = now()->subDays(10)->toIso8601String(); + $expiresAt = now()->addMonth()->toIso8601String(); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => $this->user->email, + 'name' => $this->user->name, + 'product_code' => 'social-creator', + 'billing_cycle_anchor' => $billingAnchor, + 'expires_at' => $expiresAt, + 'blesta_service_id' => 'blesta_12345', + ]); + + $response->assertStatus(201); + + $entitlementId = $response->json('entitlement_id'); + $workspacePackage = WorkspacePackage::find($entitlementId); + + expect($workspacePackage->blesta_service_id)->toBe('blesta_12345'); + expect($workspacePackage->billing_cycle_anchor)->not->toBeNull(); + expect($workspacePackage->expires_at)->not->toBeNull(); + }); + + it('creates entitlement log entry', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', [ + 'email' => $this->user->email, + 'name' => $this->user->name, + 'product_code' => 'social-creator', + ]); + + $response->assertStatus(201); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_PROVISIONED) + ->where('source', EntitlementLog::SOURCE_BLESTA) + ->first(); + + expect($log)->not->toBeNull(); + }); + }); + + describe('GET /api/provisioning/entitlements/{id} (show)', function () { + it('requires authentication', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->getJson('/api/provisioning/entitlements/'.$workspacePackage->id); + + $response->assertStatus(401); + }); + + it('returns 404 for non-existent entitlement', function () { + $this->actingAs($this->user); + + $response = $this->getJson('/api/provisioning/entitlements/99999'); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => 'Entitlement not found', + ]); + }); + + it('returns entitlement details', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator', [ + 'blesta_service_id' => 'blesta_service_123', + ]); + + $response = $this->getJson('/api/provisioning/entitlements/'.$workspacePackage->id); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'entitlement' => [ + 'id' => $workspacePackage->id, + 'workspace_id' => $this->workspace->id, + 'workspace_slug' => $this->workspace->slug, + 'package_code' => 'social-creator', + 'package_name' => 'SocialHost Creator', + 'status' => WorkspacePackage::STATUS_ACTIVE, + 'blesta_service_id' => 'blesta_service_123', + ], + ]); + }); + + it('includes ISO8601 formatted dates', function () { + $this->actingAs($this->user); + $expiresAt = now()->addMonth(); + $billingAnchor = now()->subDays(5); + + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator', [ + 'expires_at' => $expiresAt, + 'billing_cycle_anchor' => $billingAnchor, + ]); + + $response = $this->getJson('/api/provisioning/entitlements/'.$workspacePackage->id); + + $response->assertStatus(200); + + $entitlement = $response->json('entitlement'); + expect($entitlement['expires_at'])->not->toBeNull(); + expect($entitlement['billing_cycle_anchor'])->not->toBeNull(); + }); + }); + + describe('POST /api/provisioning/entitlements/{id}/suspend', function () { + it('requires authentication', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/suspend'); + + $response->assertStatus(401); + }); + + it('returns 404 for non-existent entitlement', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements/99999/suspend'); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => 'Entitlement not found', + ]); + }); + + it('suspends an active entitlement', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/suspend'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => WorkspacePackage::STATUS_SUSPENDED, + ]); + }); + + it('accepts optional reason parameter', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/suspend', [ + 'reason' => 'Payment failed', + ]); + + $response->assertStatus(200); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_SUSPENDED) + ->first(); + + expect($log->metadata['reason'])->toBe('Payment failed'); + }); + + it('creates entitlement log entry', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/suspend'); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_SUSPENDED) + ->where('source', EntitlementLog::SOURCE_BLESTA) + ->first(); + + expect($log)->not->toBeNull(); + }); + + it('denies access after suspension', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + // Can access before suspension + $result = $this->service->can($this->workspace, 'social.accounts'); + expect($result->isAllowed())->toBeTrue(); + + $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/suspend'); + Cache::flush(); + + // Cannot access after suspension + $result = $this->service->can($this->workspace, 'social.accounts'); + expect($result->isDenied())->toBeTrue(); + }); + }); + + describe('POST /api/provisioning/entitlements/{id}/unsuspend', function () { + it('requires authentication', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + $workspacePackage->suspend(); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/unsuspend'); + + $response->assertStatus(401); + }); + + it('returns 404 for non-existent entitlement', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements/99999/unsuspend'); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => 'Entitlement not found', + ]); + }); + + it('reactivates a suspended entitlement', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + $workspacePackage->suspend(); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/unsuspend'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => WorkspacePackage::STATUS_ACTIVE, + ]); + }); + + it('creates entitlement log entry', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + $workspacePackage->suspend(); + + $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/unsuspend'); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_REACTIVATED) + ->where('source', EntitlementLog::SOURCE_BLESTA) + ->first(); + + expect($log)->not->toBeNull(); + }); + + it('restores access after unsuspension', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + $workspacePackage->suspend(); + Cache::flush(); + + // Cannot access while suspended + $result = $this->service->can($this->workspace, 'social.accounts'); + expect($result->isDenied())->toBeTrue(); + + $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/unsuspend'); + Cache::flush(); + + // Can access after unsuspension + $result = $this->service->can($this->workspace, 'social.accounts'); + expect($result->isAllowed())->toBeTrue(); + }); + }); + + describe('POST /api/provisioning/entitlements/{id}/cancel', function () { + it('requires authentication', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/cancel'); + + $response->assertStatus(401); + }); + + it('returns 404 for non-existent entitlement', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements/99999/cancel'); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => 'Entitlement not found', + ]); + }); + + it('cancels an active entitlement', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/cancel'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => WorkspacePackage::STATUS_CANCELLED, + ]); + }); + + it('accepts optional reason parameter', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/cancel', [ + 'reason' => 'Customer requested cancellation', + ]); + + $response->assertStatus(200); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) + ->first(); + + expect($log->metadata['reason'])->toBe('Customer requested cancellation'); + }); + + it('creates entitlement log entry', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/cancel'); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) + ->where('source', EntitlementLog::SOURCE_BLESTA) + ->first(); + + expect($log)->not->toBeNull(); + }); + + it('denies access after cancellation', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + // Can access before cancellation + $result = $this->service->can($this->workspace, 'social.accounts'); + expect($result->isAllowed())->toBeTrue(); + + $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/cancel'); + Cache::flush(); + + // Cannot access after cancellation + $result = $this->service->can($this->workspace, 'social.accounts'); + expect($result->isDenied())->toBeTrue(); + }); + }); + + describe('POST /api/provisioning/entitlements/{id}/renew', function () { + it('requires authentication', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew'); + + $response->assertStatus(401); + }); + + it('returns 404 for non-existent entitlement', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements/99999/renew'); + + $response->assertStatus(404) + ->assertJson([ + 'success' => false, + 'error' => 'Entitlement not found', + ]); + }); + + it('validates date format for expires_at', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew', [ + 'expires_at' => 'invalid-date', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['expires_at']); + }); + + it('validates date format for billing_cycle_anchor', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew', [ + 'billing_cycle_anchor' => 'not-a-date', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['billing_cycle_anchor']); + }); + + it('renews an entitlement without parameters', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => WorkspacePackage::STATUS_ACTIVE, + ]); + }); + + it('updates expires_at when provided', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + $newExpiry = now()->addMonth(); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew', [ + 'expires_at' => $newExpiry->toIso8601String(), + ]); + + $response->assertStatus(200); + + $workspacePackage->refresh(); + expect($workspacePackage->expires_at->toDateString())->toBe($newExpiry->toDateString()); + }); + + it('updates billing_cycle_anchor when provided', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + $newAnchor = now(); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew', [ + 'billing_cycle_anchor' => $newAnchor->toIso8601String(), + ]); + + $response->assertStatus(200); + + $workspacePackage->refresh(); + expect($workspacePackage->billing_cycle_anchor->toDateString())->toBe($newAnchor->toDateString()); + }); + + it('creates entitlement log entry', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + + $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew', [ + 'expires_at' => now()->addMonth()->toIso8601String(), + ]); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_RENEWED) + ->where('source', EntitlementLog::SOURCE_BLESTA) + ->first(); + + expect($log)->not->toBeNull(); + }); + + it('returns ISO8601 formatted expires_at in response', function () { + $this->actingAs($this->user); + $workspacePackage = $this->service->provisionPackage($this->workspace, 'social-creator'); + $newExpiry = now()->addMonth(); + + $response = $this->postJson('/api/provisioning/entitlements/'.$workspacePackage->id.'/renew', [ + 'expires_at' => $newExpiry->toIso8601String(), + ]); + + $response->assertStatus(200); + expect($response->json('expires_at'))->not->toBeNull(); + }); + }); +}); + +// ============================================================================= +// Error Response Format Tests +// ============================================================================= + +describe('Error Response Format', function () { + it('returns consistent error format for validation failures', function () { + $this->actingAs($this->user); + + $response = $this->postJson('/api/provisioning/entitlements', []); + + $response->assertStatus(422) + ->assertJsonStructure([ + 'message', + 'errors' => ['email', 'name', 'product_code'], + ]); + }); + + it('returns consistent error format for not found', function () { + $this->actingAs($this->user); + + $response = $this->getJson('/api/provisioning/entitlements/99999'); + + $response->assertStatus(404) + ->assertJsonStructure([ + 'success', + 'error', + ]); + }); + + it('returns consistent error format for unauthenticated', function () { + $response = $this->getJson('/api/provisioning/entitlements/1'); + + $response->assertStatus(401); + }); +}); + +// ============================================================================= +// Rate Limiting Tests +// ============================================================================= + +describe('Rate Limiting', function () { + it('controller has rate limit attribute', function () { + $reflection = new \ReflectionClass(\Core\Tenant\Controllers\EntitlementApiController::class); + $attributes = $reflection->getAttributes(\Core\Api\RateLimit\RateLimit::class); + + expect($attributes)->toHaveCount(1); + + $rateLimit = $attributes[0]->newInstance(); + expect($rateLimit->limit)->toBe(60); + expect($rateLimit->window)->toBe(60); + expect($rateLimit->key)->toBe('entitlement-api'); + }); +});