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');