diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 0d3ff49..7803b78 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -77,7 +77,7 @@ jobs: id: test run: | set +e - OUTPUT=$(vendor/bin/phpunit --log-junit test-results.xml 2>&1) + OUTPUT=$(vendor/bin/pest --log-junit test-results.xml 2>&1) EXIT_CODE=$? echo "$OUTPUT" diff --git a/composer.json b/composer.json index 3b4ae4e..20cb0e8 100644 --- a/composer.json +++ b/composer.json @@ -5,9 +5,15 @@ "license": "EUPL-1.2", "require": { "php": "^8.2", - "host-uk/core": "@dev", + "host-uk/core": "dev-dev", "symfony/yaml": "^7.0" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/host-uk/core-php.git" + } + ], "autoload": { "psr-4": { "Core\\Api\\": "src/Api/", @@ -19,6 +25,21 @@ "providers": [] } }, + "require-dev": { + "laravel/pint": "^1.18", + "orchestra/testbench": "^10.0", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "phpstan/phpstan": "^2.0", + "vimeo/psalm": "^6.0" + }, "minimum-stability": "stable", - "prefer-stable": true + "prefer-stable": true, + "config": { + "github-protocols": ["https"], + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + } } diff --git a/docs/authentication.md b/docs/authentication.md index 3fe97ce..8425690 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -7,7 +7,7 @@ The API package provides secure authentication with bcrypt-hashed API keys, scop ### Creating Keys ```php -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; $apiKey = ApiKey::create([ 'name' => 'Mobile App Production', @@ -262,7 +262,7 @@ Route::middleware('auth:api')->group(function () { ### Scope Enforcement ```php -use Mod\Api\Middleware\EnforceApiScope; +use Core\Api\Middleware\EnforceApiScope; Route::middleware([EnforceApiScope::class.':posts:write']) ->post('/posts', [PostController::class, 'store']); @@ -271,7 +271,7 @@ Route::middleware([EnforceApiScope::class.':posts:write']) ### Rate Limiting ```php -use Mod\Api\Middleware\RateLimitApi; +use Core\Api\Middleware\RateLimitApi; Route::middleware(RateLimitApi::class)->group(function () { // Rate-limited routes @@ -342,7 +342,7 @@ if ($usage > $threshold) { namespace Tests\Feature\Api; use Tests\TestCase; -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; class ApiKeyAuthTest extends TestCase { diff --git a/docs/building-rest-apis.md b/docs/building-rest-apis.md index 8eb52ea..4958e47 100644 --- a/docs/building-rest-apis.md +++ b/docs/building-rest-apis.md @@ -91,8 +91,8 @@ Build controllers that use the `HasApiResponses` trait for consistent error hand namespace Mod\Blog\Api; use App\Http\Controllers\Controller; -use Core\Mod\Api\Concerns\HasApiResponses; -use Core\Mod\Api\Resources\PaginatedCollection; +use Core\Core\Api\Concerns\HasApiResponses; +use Core\Core\Api\Resources\PaginatedCollection; use Illuminate\Http\Request; use Mod\Blog\Models\Post; use Mod\Blog\Resources\PostResource; @@ -162,7 +162,7 @@ class PostController extends Controller The `PaginatedCollection` class provides standardized pagination metadata: ```php -use Core\Mod\Api\Resources\PaginatedCollection; +use Core\Core\Api\Resources\PaginatedCollection; public function index(Request $request) { @@ -618,10 +618,10 @@ new_post = response.json() Use attributes to auto-generate OpenAPI documentation: ```php -use Core\Mod\Api\Documentation\Attributes\ApiTag; -use Core\Mod\Api\Documentation\Attributes\ApiParameter; -use Core\Mod\Api\Documentation\Attributes\ApiResponse; -use Core\Mod\Api\Documentation\Attributes\ApiSecurity; +use Core\Core\Api\Documentation\Attributes\ApiTag; +use Core\Core\Api\Documentation\Attributes\ApiParameter; +use Core\Core\Api\Documentation\Attributes\ApiResponse; +use Core\Core\Api\Documentation\Attributes\ApiSecurity; #[ApiTag('Posts', 'Blog post management')] #[ApiSecurity('api_key')] @@ -661,7 +661,7 @@ class PostController extends Controller Use the `HasApiResponses` trait for consistent errors: ```php -use Core\Mod\Api\Concerns\HasApiResponses; +use Core\Core\Api\Concerns\HasApiResponses; class PostController extends Controller { @@ -812,7 +812,7 @@ public function index(Request $request) namespace Tests\Feature\Api; use Tests\TestCase; -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; use Mod\Blog\Models\Post; class PostApiTest extends TestCase diff --git a/docs/documentation.md b/docs/documentation.md index 61bec4c..68465ef 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -44,7 +44,7 @@ return [ ### Hiding Endpoints ```php -use Mod\Api\Documentation\Attributes\ApiHidden; +use Core\Api\Documentation\Attributes\ApiHidden; #[ApiHidden] class InternalController @@ -65,7 +65,7 @@ class PostController ### Tagging Endpoints ```php -use Mod\Api\Documentation\Attributes\ApiTag; +use Core\Api\Documentation\Attributes\ApiTag; #[ApiTag('Blog Posts')] class PostController @@ -77,7 +77,7 @@ class PostController ### Documenting Parameters ```php -use Mod\Api\Documentation\Attributes\ApiParameter; +use Core\Api\Documentation\Attributes\ApiParameter; class PostController { @@ -104,7 +104,7 @@ class PostController ### Documenting Responses ```php -use Mod\Api\Documentation\Attributes\ApiResponse; +use Core\Api\Documentation\Attributes\ApiResponse; class PostController { @@ -138,7 +138,7 @@ class PostController ### Security Requirements ```php -use Mod\Api\Documentation\Attributes\ApiSecurity; +use Core\Api\Documentation\Attributes\ApiSecurity; #[ApiSecurity(['apiKey' => []])] class PostController @@ -210,7 +210,7 @@ return [ namespace Mod\Blog\Api\Documentation; -use Mod\Api\Documentation\Extension; +use Core\Api\Documentation\Extension; class BlogExtension extends Extension { @@ -248,7 +248,7 @@ public function onApiRoutes(ApiRoutesRegistering $event): void **Rate Limit Extension:** ```php -use Mod\Api\Documentation\Extensions\RateLimitExtension; +use Core\Api\Documentation\Extensions\RateLimitExtension; // Automatically documents rate limits in responses // Adds X-RateLimit-* headers to all endpoints @@ -257,7 +257,7 @@ use Mod\Api\Documentation\Extensions\RateLimitExtension; **Workspace Header Extension:** ```php -use Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension; +use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension; // Documents X-Workspace-ID header requirement // Adds to all workspace-scoped endpoints @@ -268,7 +268,7 @@ use Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension; ### Pagination ```php -use Mod\Api\Documentation\Examples\CommonExamples; +use Core\Api\Documentation\Examples\CommonExamples; #[ApiResponse( status: 200, diff --git a/docs/index.md b/docs/index.md index 03ff405..51f0db3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,7 +66,7 @@ class Boot ### Creating API Keys ```php -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; $apiKey = ApiKey::create([ 'name' => 'Mobile App', @@ -117,7 +117,7 @@ X-RateLimit-Reset: 1640995200 ### Creating Webhooks ```php -use Mod\Api\Models\WebhookEndpoint; +use Core\Api\Models\WebhookEndpoint; $webhook = WebhookEndpoint::create([ 'url' => 'https://your-app.com/webhooks', @@ -130,7 +130,7 @@ $webhook = WebhookEndpoint::create([ ### Dispatching Events ```php -use Mod\Api\Services\WebhookService; +use Core\Api\Services\WebhookService; $service = app(WebhookService::class); @@ -144,7 +144,7 @@ $service->dispatch('post.created', [ ### Verifying Signatures ```php -use Mod\Api\Services\WebhookSignature; +use Core\Api\Services\WebhookSignature; $signature = WebhookSignature::verify( payload: $request->getContent(), @@ -164,9 +164,9 @@ if (!$signature) { Auto-generate OpenAPI documentation with attributes: ```php -use Mod\Api\Documentation\Attributes\ApiTag; -use Mod\Api\Documentation\Attributes\ApiParameter; -use Mod\Api\Documentation\Attributes\ApiResponse; +use Core\Api\Documentation\Attributes\ApiTag; +use Core\Api\Documentation\Attributes\ApiParameter; +use Core\Api\Documentation\Attributes\ApiResponse; #[ApiTag('Posts')] class PostController extends Controller @@ -305,7 +305,7 @@ Route::middleware('api.rate-limit') namespace Tests\Feature\Api; use Tests\TestCase; -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; class PostApiTest extends TestCase { diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 4e28438..c7cd1d0 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -87,7 +87,7 @@ Route::middleware('throttle:api')->group(function () { ### Based on API Key Tier ```php -use Mod\Api\Services\RateLimitService; +use Core\Api\Services\RateLimitService; $rateLimitService = app(RateLimitService::class); @@ -146,7 +146,7 @@ X-RateLimit-Reset: 1640995200 ### Check Current Usage ```php -use Mod\Api\Services\RateLimitService; +use Core\Api\Services\RateLimitService; $service = app(RateLimitService::class); diff --git a/docs/scopes.md b/docs/scopes.md index c9a272c..1d5819f 100644 --- a/docs/scopes.md +++ b/docs/scopes.md @@ -91,7 +91,7 @@ Scopes follow the format: `resource:action` ### API Key Creation ```php -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; $apiKey = ApiKey::create([ 'name' => 'Mobile App', @@ -121,7 +121,7 @@ $token = $user->createToken('mobile-app', [ ### Route Protection ```php -use Mod\Api\Middleware\EnforceApiScope; +use Core\Api\Middleware\EnforceApiScope; // Single scope Route::middleware(['auth:sanctum', 'scope:posts:write']) @@ -236,7 +236,7 @@ Define custom scopes for your modules: namespace Mod\Shop\Api; -use Mod\Api\Contracts\ScopeProvider; +use Core\Api\Contracts\ScopeProvider; class ShopScopeProvider implements ScopeProvider { @@ -334,7 +334,7 @@ Route::middleware('scope-any:posts:write,pages:write')->post('/content', ...); ### API Key Scopes ```php -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; $apiKey = ApiKey::findByKey($providedKey); diff --git a/docs/webhooks.md b/docs/webhooks.md index 03852f6..aac89fe 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -15,7 +15,7 @@ Webhooks allow your application to: ### Basic Webhook ```php -use Mod\Api\Models\WebhookEndpoint; +use Core\Api\Models\WebhookEndpoint; $webhook = WebhookEndpoint::create([ 'url' => 'https://your-app.com/webhooks', @@ -43,7 +43,7 @@ $webhook = WebhookEndpoint::create([ ### Manual Dispatch ```php -use Mod\Api\Services\WebhookService; +use Core\Api\Services\WebhookService; $webhookService = app(WebhookService::class); @@ -58,7 +58,7 @@ $webhookService->dispatch('post.created', [ ### From Model Events ```php -use Mod\Api\Services\WebhookService; +use Core\Api\Services\WebhookService; class Post extends Model { @@ -85,7 +85,7 @@ class Post extends Model ```php use Mod\Blog\Actions\CreatePost; -use Mod\Api\Services\WebhookService; +use Core\Api\Services\WebhookService; class CreatePost { @@ -157,7 +157,7 @@ X-Webhook-ID: evt_abc123 ### Verifying Signatures ```php -use Mod\Api\Services\WebhookSignature; +use Core\Api\Services\WebhookSignature; public function handle(Request $request) { @@ -227,7 +227,7 @@ foreach ($deliveries as $delivery) { ### Manual Retry ```php -use Mod\Api\Models\WebhookDelivery; +use Core\Api\Models\WebhookDelivery; $delivery = WebhookDelivery::find($id); @@ -266,7 +266,7 @@ if ($delivery->isFailed()) { ### Test Endpoint ```php -use Mod\Api\Models\WebhookEndpoint; +use Core\Api\Models\WebhookEndpoint; $webhook = WebhookEndpoint::find($id); @@ -290,7 +290,7 @@ if ($result['success']) { namespace Tests\Feature; use Tests\TestCase; -use Mod\Api\Facades\Webhooks; +use Core\Api\Facades\Webhooks; class PostCreationTest extends TestCase { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..a777907 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 5 + paths: + - src + - app diff --git a/phpunit.xml b/phpunit.xml index 61c031c..4059b65 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,6 +10,7 @@ tests/Feature + src/Api/Tests/Feature diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..144ef3e --- /dev/null +++ b/psalm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/Api/Console/Commands/CleanupExpiredGracePeriods.php b/src/Api/Console/Commands/CleanupExpiredGracePeriods.php index 2cf5f26..d2d80fd 100644 --- a/src/Api/Console/Commands/CleanupExpiredGracePeriods.php +++ b/src/Api/Console/Commands/CleanupExpiredGracePeriods.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Mod\Api\Console\Commands; +namespace Core\Api\Console\Commands; use Illuminate\Console\Command; -use Mod\Api\Services\ApiKeyService; +use Core\Api\Services\ApiKeyService; /** * Clean up API keys with expired grace periods. @@ -39,7 +39,7 @@ public function handle(ApiKeyService $service): int $this->newLine(); // Count keys that would be cleaned up - $count = \Mod\Api\Models\ApiKey::gracePeriodExpired() + $count = \Core\Api\Models\ApiKey::gracePeriodExpired() ->whereNull('deleted_at') ->count(); diff --git a/src/Api/Database/Factories/ApiKeyFactory.php b/src/Api/Database/Factories/ApiKeyFactory.php index f992d8c..fc3f658 100644 --- a/src/Api/Database/Factories/ApiKeyFactory.php +++ b/src/Api/Database/Factories/ApiKeyFactory.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Mod\Api\Database\Factories; +namespace Core\Api\Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; -use Mod\Api\Models\ApiKey; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Models\ApiKey; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; /** * Factory for generating ApiKey test instances. diff --git a/src/Api/Jobs/RecordApiUsageJob.php b/src/Api/Jobs/RecordApiUsageJob.php new file mode 100644 index 0000000..6548940 --- /dev/null +++ b/src/Api/Jobs/RecordApiUsageJob.php @@ -0,0 +1,57 @@ +queue = config('api.queues.usage', 'default'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + // Record individual usage + $usage = ApiUsage::create([ + 'api_key_id' => $this->data['api_key_id'], + 'workspace_id' => $this->data['workspace_id'], + 'endpoint' => $this->data['endpoint'], + 'method' => strtoupper($this->data['method']), + 'status_code' => $this->data['status_code'], + 'response_time_ms' => $this->data['response_time_ms'], + 'request_size' => $this->data['request_size'], + 'response_size' => $this->data['response_size'], + 'ip_address' => $this->data['ip_address'], + 'user_agent' => $this->data['user_agent'] ? substr($this->data['user_agent'], 0, 500) : null, + 'created_at' => $this->data['created_at'] ?? now(), + ]); + + // Update daily aggregation + ApiUsageDaily::recordFromUsage($usage); + } +} diff --git a/src/Api/Jobs/UpdateApiKeyLastUsedJob.php b/src/Api/Jobs/UpdateApiKeyLastUsedJob.php new file mode 100644 index 0000000..8d7cff1 --- /dev/null +++ b/src/Api/Jobs/UpdateApiKeyLastUsedJob.php @@ -0,0 +1,45 @@ +queue = config('api.queues.usage', 'default'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $apiKey = ApiKey::find($this->apiKeyId); + + if ($apiKey) { + $apiKey->update(['last_used_at' => now()]); + } + } +} diff --git a/src/Api/Models/ApiKey.php b/src/Api/Models/ApiKey.php index 3c229ee..599d27c 100644 --- a/src/Api/Models/ApiKey.php +++ b/src/Api/Models/ApiKey.php @@ -10,8 +10,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Core\Api\Jobs\UpdateApiKeyLastUsedJob; /** * API Key - authenticates SDK and REST API requests. @@ -258,10 +260,19 @@ public function endGracePeriod(): void /** * Record API key usage. + * + * Uses cache debouncing to reduce database writes. The actual database + * update is queued to a background job and only dispatched at most + * once every 60 seconds per key. */ public function recordUsage(): void { - $this->update(['last_used_at' => now()]); + $cacheKey = "api_key_last_used:{$this->id}"; + + // Only update database at most once per minute + if (Cache::add($cacheKey, true, now()->addMinute())) { + UpdateApiKeyLastUsedJob::dispatch($this->id); + } } /** diff --git a/src/Api/Routes/admin.php b/src/Api/Routes/admin.php index 1e6899f..6351ede 100644 --- a/src/Api/Routes/admin.php +++ b/src/Api/Routes/admin.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; -use Mod\Api\View\Modal\Admin\WebhookTemplateManager; +use Core\Api\View\Modal\Admin\WebhookTemplateManager; /* |-------------------------------------------------------------------------- diff --git a/src/Api/Services/ApiKeyService.php b/src/Api/Services/ApiKeyService.php index 2175826..9214330 100644 --- a/src/Api/Services/ApiKeyService.php +++ b/src/Api/Services/ApiKeyService.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Mod\Api\Services; +namespace Core\Api\Services; use Illuminate\Support\Facades\Log; -use Mod\Api\Models\ApiKey; +use Core\Api\Models\ApiKey; /** * API Key Service - manages API key lifecycle. diff --git a/src/Api/Services/ApiUsageService.php b/src/Api/Services/ApiUsageService.php index 204f444..11027c1 100644 --- a/src/Api/Services/ApiUsageService.php +++ b/src/Api/Services/ApiUsageService.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Mod\Api\Services; +namespace Core\Api\Services; use Carbon\Carbon; -use Mod\Api\Models\ApiUsage; -use Mod\Api\Models\ApiUsageDaily; +use Core\Api\Models\ApiUsage; +use Core\Api\Models\ApiUsageDaily; +use Core\Api\Jobs\RecordApiUsageJob; /** * API Usage Service - tracks and reports API usage metrics. @@ -17,6 +18,9 @@ class ApiUsageService { /** * Record an API request. + * + * This method dispatches a background job to record the usage metrics, + * removing database writes from the critical path of the API request. */ public function record( int $apiKeyId, @@ -29,28 +33,23 @@ public function record( ?int $responseSize = null, ?string $ipAddress = null, ?string $userAgent = null - ): ApiUsage { + ): void { // Normalise endpoint (remove query strings, IDs) $normalisedEndpoint = $this->normaliseEndpoint($endpoint); - // Record individual usage - $usage = ApiUsage::record( - $apiKeyId, - $workspaceId, - $normalisedEndpoint, - $method, - $statusCode, - $responseTimeMs, - $requestSize, - $responseSize, - $ipAddress, - $userAgent - ); - - // Update daily aggregation - ApiUsageDaily::recordFromUsage($usage); - - return $usage; + RecordApiUsageJob::dispatch([ + 'api_key_id' => $apiKeyId, + 'workspace_id' => $workspaceId, + 'endpoint' => $normalisedEndpoint, + 'method' => $method, + 'status_code' => $statusCode, + 'response_time_ms' => $responseTimeMs, + 'request_size' => $requestSize, + 'response_size' => $responseSize, + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent, + 'created_at' => now(), + ]); } /** @@ -282,7 +281,7 @@ public function getKeyComparison( // Fetch API keys separately to avoid broken eager loading with aggregation $apiKeyIds = $aggregated->pluck('api_key_id')->filter()->unique()->all(); - $apiKeys = \Mod\Api\Models\ApiKey::whereIn('id', $apiKeyIds) + $apiKeys = \Core\Api\Models\ApiKey::whereIn('id', $apiKeyIds) ->select('id', 'name', 'prefix') ->get() ->keyBy('id'); diff --git a/src/Api/Tests/Feature/ApiKeyRotationTest.php b/src/Api/Tests/Feature/ApiKeyRotationTest.php index 86c2f5c..f90e7c0 100644 --- a/src/Api/Tests/Feature/ApiKeyRotationTest.php +++ b/src/Api/Tests/Feature/ApiKeyRotationTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Mod\Api\Models\ApiKey; -use Mod\Api\Services\ApiKeyService; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Models\ApiKey; +use Core\Api\Services\ApiKeyService; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); diff --git a/src/Api/Tests/Feature/ApiKeySecurityTest.php b/src/Api/Tests/Feature/ApiKeySecurityTest.php index d9f0545..38a2ef2 100644 --- a/src/Api/Tests/Feature/ApiKeySecurityTest.php +++ b/src/Api/Tests/Feature/ApiKeySecurityTest.php @@ -5,10 +5,10 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; -use Mod\Api\Database\Factories\ApiKeyFactory; -use Mod\Api\Models\ApiKey; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Database\Factories\ApiKeyFactory; +use Core\Api\Models\ApiKey; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); diff --git a/src/Api/Tests/Feature/ApiKeyTest.php b/src/Api/Tests/Feature/ApiKeyTest.php index 109811c..07a717b 100644 --- a/src/Api/Tests/Feature/ApiKeyTest.php +++ b/src/Api/Tests/Feature/ApiKeyTest.php @@ -3,10 +3,10 @@ declare(strict_types=1); use Illuminate\Support\Facades\Cache; -use Mod\Api\Database\Factories\ApiKeyFactory; -use Mod\Api\Models\ApiKey; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Database\Factories\ApiKeyFactory; +use Core\Api\Models\ApiKey; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); diff --git a/src/Api/Tests/Feature/ApiScopeEnforcementTest.php b/src/Api/Tests/Feature/ApiScopeEnforcementTest.php index 6da35e8..fbbb852 100644 --- a/src/Api/Tests/Feature/ApiScopeEnforcementTest.php +++ b/src/Api/Tests/Feature/ApiScopeEnforcementTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use Mod\Api\Models\ApiKey; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Models\ApiKey; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Route; diff --git a/src/Api/Tests/Feature/ApiUsageOptimizationTest.php b/src/Api/Tests/Feature/ApiUsageOptimizationTest.php new file mode 100644 index 0000000..8ed37c4 --- /dev/null +++ b/src/Api/Tests/Feature/ApiUsageOptimizationTest.php @@ -0,0 +1,84 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); +}); + +it('dispatches UpdateApiKeyLastUsedJob when recording usage', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Test Key' + ); + $apiKey = $result['api_key']; + + $apiKey->recordUsage(); + + Bus::assertDispatched(UpdateApiKeyLastUsedJob::class, function ($job) use ($apiKey) { + return $job->apiKeyId === $apiKey->id; + }); +}); + +it('debounces UpdateApiKeyLastUsedJob using cache', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Debounce Test Key' + ); + $apiKey = $result['api_key']; + + // First call should dispatch + $apiKey->recordUsage(); + Bus::assertDispatched(UpdateApiKeyLastUsedJob::class, 1); + + // Second call immediately after should not dispatch + $apiKey->recordUsage(); + Bus::assertDispatched(UpdateApiKeyLastUsedJob::class, 1); + + // Clear cache and it should dispatch again + Cache::flush(); + $apiKey->recordUsage(); + Bus::assertDispatched(UpdateApiKeyLastUsedJob::class, 2); +}); + +it('dispatches RecordApiUsageJob when recording detailed usage', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Usage Test Key' + ); + $apiKey = $result['api_key']; + + $service = new ApiUsageService(); + $service->record( + apiKeyId: $apiKey->id, + workspaceId: $apiKey->workspace_id, + endpoint: '/api/test', + method: 'GET', + statusCode: 200, + responseTimeMs: 150 + ); + + Bus::assertDispatched(RecordApiUsageJob::class, function ($job) use ($apiKey) { + return $job->data['api_key_id'] === $apiKey->id && + $job->data['endpoint'] === '/api/test' && + $job->data['status_code'] === 200; + }); +}); diff --git a/src/Api/Tests/Feature/ApiUsageTest.php b/src/Api/Tests/Feature/ApiUsageTest.php index 20c3f0d..74ae92b 100644 --- a/src/Api/Tests/Feature/ApiUsageTest.php +++ b/src/Api/Tests/Feature/ApiUsageTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -use Mod\Api\Models\ApiKey; -use Mod\Api\Models\ApiUsage; -use Mod\Api\Models\ApiUsageDaily; -use Mod\Api\Services\ApiUsageService; -use Mod\Tenant\Models\User; -use Mod\Tenant\Models\Workspace; +use Core\Api\Models\ApiKey; +use Core\Api\Models\ApiUsage; +use Core\Api\Models\ApiUsageDaily; +use Core\Api\Services\ApiUsageService; +use Core\Tenant\Models\User; +use Core\Tenant\Models\Workspace; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..1cc8464 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in('Feature', 'Unit', '../src/Api/Tests/Feature');