diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5f0bfc3..5623764 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,13 @@ "permissions": { "allow": [ "Bash(php artisan make:model:*)", - "Bash(gh issue create:*)" + "Bash(gh issue create:*)", + "Bash(find:*)", + "Bash(git checkout:*)", + "Bash(php artisan make:policy:*)", + "Bash(/usr/local/bin/php:*)", + "Bash(/opt/homebrew/bin/php:*)", + "Bash(git add:*)" ] }, "enabledMcpjsonServers": [ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60d738b..a60c1ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,9 @@ on: - develop - master +env: + MIN_COVERAGE: 70 + jobs: ci: runs-on: ubuntu-latest @@ -46,5 +49,20 @@ jobs: - name: Build Assets run: npm run build - - name: Tests - run: php artisan test + - name: Run Tests with Coverage + run: php artisan test --coverage --min=${{ env.MIN_COVERAGE }} + + - name: Generate Coverage Report + if: always() + run: | + php artisan test --coverage-clover=coverage.xml || true + + - name: Upload Coverage to Codecov + if: always() + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/app/Http/Controllers/AgentController.php b/app/Http/Controllers/AgentController.php deleted file mode 100644 index 641623a..0000000 --- a/app/Http/Controllers/AgentController.php +++ /dev/null @@ -1,8 +0,0 @@ -user_id === $request->user()->id, 403); + $this->authorize('view', $chat); $artifacts = Artifact::query() ->whereIn('message_id', $chat->messages()->pluck('id')) @@ -36,7 +39,7 @@ public function show(Request $request, Artifact $artifact): JsonResponse { $chat = $artifact->message?->chat; abort_if($chat === null, 404); - abort_unless($chat->user_id === $request->user()?->id, 403); + $this->authorize('view', $chat); return response()->json($artifact); } @@ -48,7 +51,7 @@ public function render(Request $request, Artifact $artifact): Response { $chat = $artifact->message?->chat; abort_if($chat === null, 404); - abort_unless($chat->user_id === $request->user()?->id, 403); + $this->authorize('view', $chat); $view = $this->getRendererView($artifact); $html = $view->render(); diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index a92ff8e..e79d6e9 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -7,6 +7,7 @@ use App\Http\Requests\StoreChatRequest; use App\Http\Requests\UpdateChatRequest; use App\Models\Chat; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -14,6 +15,8 @@ class ChatController extends Controller { + use AuthorizesRequests; + public function index(Request $request): Response { $chats = $request->user()->chats()->with('aiModel')->orderByDesc('updated_at')->get(); @@ -36,7 +39,7 @@ public function store(StoreChatRequest $request): RedirectResponse public function show(Request $request, Chat $chat): Response { - abort_unless($chat->user_id === $request->user()->id, 403); + $this->authorize('view', $chat); $chats = $request->user()->chats()->with('aiModel')->orderByDesc('updated_at')->get(); @@ -56,7 +59,7 @@ public function update(UpdateChatRequest $request, Chat $chat): RedirectResponse public function destroy(Request $request, Chat $chat): RedirectResponse { - abort_unless($chat->user_id === $request->user()->id, 403); + $this->authorize('delete', $chat); $chat->delete(); diff --git a/app/Http/Controllers/ChatStreamController.php b/app/Http/Controllers/ChatStreamController.php index a3d14b9..dfdeca3 100644 --- a/app/Http/Controllers/ChatStreamController.php +++ b/app/Http/Controllers/ChatStreamController.php @@ -8,18 +8,21 @@ use App\Models\AiModel; use App\Models\Chat; use App\Services\ChatStreamService; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Response; use Symfony\Component\HttpFoundation\StreamedResponse; class ChatStreamController extends Controller { + use AuthorizesRequests; + public function __construct( private readonly ChatStreamService $streamService ) {} public function __invoke(ChatStreamRequest $request, Chat $chat): StreamedResponse { - abort_unless($chat->user_id === $request->user()->id, 403); + $this->authorize('stream', $chat); $userMessage = $request->string('message')->trim()->value(); diff --git a/app/Http/Requests/ChatStreamRequest.php b/app/Http/Requests/ChatStreamRequest.php index 8e9152a..4ece065 100644 --- a/app/Http/Requests/ChatStreamRequest.php +++ b/app/Http/Requests/ChatStreamRequest.php @@ -14,6 +14,11 @@ public function authorize(): bool } /** + * Get the validation rules that apply to the request. + * + * Note: ai_model_id is nullable here because existing chats have a default model. + * The controller falls back to the chat's ai_model_id when not provided. + * * @return array */ public function rules(): array diff --git a/app/Http/Requests/StoreAgentRequest.php b/app/Http/Requests/StoreAgentRequest.php index 35bf88e..cb77aa8 100644 --- a/app/Http/Requests/StoreAgentRequest.php +++ b/app/Http/Requests/StoreAgentRequest.php @@ -8,7 +8,7 @@ class StoreAgentRequest extends FormRequest { public function authorize(): bool { - return true; + return auth()->check(); } /** diff --git a/app/Jobs/GenerateChatTitle.php b/app/Jobs/GenerateChatTitle.php index 91f8904..98573a6 100644 --- a/app/Jobs/GenerateChatTitle.php +++ b/app/Jobs/GenerateChatTitle.php @@ -21,6 +21,8 @@ public function __construct( public function handle(): void { + $this->chat->loadMissing('aiModel'); + $messages = $this->chat->messages() ->orderByDesc('created_at') ->limit(6) diff --git a/app/Models/Agent.php b/app/Models/Agent.php index 8ffda8d..79bf68f 100644 --- a/app/Models/Agent.php +++ b/app/Models/Agent.php @@ -6,6 +6,9 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Agent extends Model { @@ -26,4 +29,48 @@ class Agent extends Model 'capabilities', 'is_active', ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'tools' => 'array', + 'capabilities' => 'array', + 'is_active' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function defaultModel(): BelongsTo + { + return $this->belongsTo(AiModel::class, 'default_model_id'); + } + + /** + * @return BelongsToMany + */ + public function models(): BelongsToMany + { + return $this->belongsToMany(AiModel::class, 'agent_model'); + } + + /** + * @return HasMany + */ + public function chats(): HasMany + { + return $this->hasMany(Chat::class); + } } diff --git a/app/Models/AiModel.php b/app/Models/AiModel.php index af208bf..67c7423 100644 --- a/app/Models/AiModel.php +++ b/app/Models/AiModel.php @@ -63,6 +63,17 @@ public function scopeEnabled(Builder $query): Builder return $query->where('enabled', true); } + /** + * Scope to get models that are both enabled and available. + * + * @param Builder $query + * @return Builder + */ + public function scopeAvailable(Builder $query): Builder + { + return $query->where('enabled', true)->where('is_available', true); + } + /** * @param Builder $query * @return Builder diff --git a/app/Models/Chat.php b/app/Models/Chat.php index a7ec042..1008b03 100644 --- a/app/Models/Chat.php +++ b/app/Models/Chat.php @@ -24,6 +24,7 @@ class Chat extends Model 'user_id', 'title', 'ai_model_id', + 'agent_id', ]; /** @@ -49,4 +50,12 @@ public function aiModel(): BelongsTo { return $this->belongsTo(AiModel::class); } + + /** + * @return BelongsTo + */ + public function agent(): BelongsTo + { + return $this->belongsTo(Agent::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index ec9eed7..e427e1a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -21,6 +21,14 @@ public function chats(): \Illuminate\Database\Eloquent\Relations\HasMany return $this->hasMany(Chat::class); } + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function agents(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Agent::class); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ diff --git a/app/Policies/AgentPolicy.php b/app/Policies/AgentPolicy.php new file mode 100644 index 0000000..b92a652 --- /dev/null +++ b/app/Policies/AgentPolicy.php @@ -0,0 +1,51 @@ +id === $agent->user_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Agent $agent): bool + { + return $user->id === $agent->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Agent $agent): bool + { + return $user->id === $agent->user_id; + } +} diff --git a/app/Policies/ChatPolicy.php b/app/Policies/ChatPolicy.php new file mode 100644 index 0000000..121e1db --- /dev/null +++ b/app/Policies/ChatPolicy.php @@ -0,0 +1,59 @@ +id === $chat->user_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Chat $chat): bool + { + return $user->id === $chat->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Chat $chat): bool + { + return $user->id === $chat->user_id; + } + + /** + * Determine whether the user can stream to the chat. + */ + public function stream(User $user, Chat $chat): bool + { + return $user->id === $chat->user_id; + } +} diff --git a/app/Services/ChatStreamService.php b/app/Services/ChatStreamService.php index df379fc..725405c 100644 --- a/app/Services/ChatStreamService.php +++ b/app/Services/ChatStreamService.php @@ -23,6 +23,21 @@ class ChatStreamService { + /** + * Number of messages after which to generate the first title. + */ + private const INITIAL_TITLE_GENERATION_THRESHOLD = 2; + + /** + * Generate a new title every N messages after the initial generation. + */ + private const TITLE_REGENERATION_INTERVAL = 10; + + /** + * Maximum number of tool execution steps. + */ + private const MAX_TOOL_STEPS = 2; + /** * @var array */ @@ -82,7 +97,7 @@ public function stream(Chat $chat, string $userMessage, AiModel $model): Generat ->withMessages($messages); if (count($tools) > 0) { - $prismBuilder = $prismBuilder->withTools($tools)->withMaxSteps(2); + $prismBuilder = $prismBuilder->withTools($tools)->withMaxSteps(self::MAX_TOOL_STEPS); } yield from $this->processStream($prismBuilder->asStream()); @@ -267,7 +282,10 @@ private function finalizeMessage(Chat $chat): void $chat->touch(); $messageCount = $chat->messages()->count(); - if ($messageCount === 2 || $messageCount % 10 === 0) { + $shouldGenerateTitle = $messageCount === self::INITIAL_TITLE_GENERATION_THRESHOLD + || $messageCount % self::TITLE_REGENERATION_INTERVAL === 0; + + if ($shouldGenerateTitle) { GenerateChatTitle::dispatchSync($chat); } } else { diff --git a/app/Services/OllamaService.php b/app/Services/OllamaService.php index 406a3c6..41e7cef 100644 --- a/app/Services/OllamaService.php +++ b/app/Services/OllamaService.php @@ -9,9 +9,15 @@ class OllamaService { - public function __construct( - private readonly string $baseUrl = 'http://localhost:11434' - ) {} + private readonly string $baseUrl; + + private readonly int $timeout; + + public function __construct(?string $baseUrl = null, ?int $timeout = null) + { + $this->baseUrl = $baseUrl ?? config('services.ollama.base_url', 'http://localhost:11434'); + $this->timeout = $timeout ?? (int) config('services.ollama.timeout', 5); + } /** * Get list of available models from Ollama. @@ -21,7 +27,7 @@ public function __construct( public function getAvailableModels(): array { try { - $response = Http::timeout(5)->get("{$this->baseUrl}/api/tags"); + $response = Http::timeout($this->timeout)->get("{$this->baseUrl}/api/tags"); if ($response->successful()) { return $response->json('models', []); diff --git a/config/services.php b/config/services.php index 1a3ee1a..b8ec7e2 100644 --- a/config/services.php +++ b/config/services.php @@ -39,4 +39,9 @@ 'api_key' => env('TAVILY_API_KEY'), ], + 'ollama' => [ + 'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), + 'timeout' => env('OLLAMA_TIMEOUT', 5), + ], + ]; diff --git a/database/migrations/2025_12_05_120000_add_performance_indexes.php b/database/migrations/2025_12_05_120000_add_performance_indexes.php new file mode 100644 index 0000000..2a680d8 --- /dev/null +++ b/database/migrations/2025_12_05_120000_add_performance_indexes.php @@ -0,0 +1,46 @@ +index('updated_at'); + }); + + Schema::table('messages', function (Blueprint $table): void { + $table->index('created_at'); + }); + + Schema::table('ai_models', function (Blueprint $table): void { + $table->index('enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('chats', function (Blueprint $table): void { + $table->dropIndex(['updated_at']); + }); + + Schema::table('messages', function (Blueprint $table): void { + $table->dropIndex(['created_at']); + }); + + Schema::table('ai_models', function (Blueprint $table): void { + $table->dropIndex(['enabled']); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 630b48c..a789762 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,7 +30,9 @@ Route::get('chats/{chat}', [ChatController::class, 'show'])->name('chats.show'); Route::patch('chats/{chat}', [ChatController::class, 'update'])->name('chats.update'); Route::delete('chats/{chat}', [ChatController::class, 'destroy'])->name('chats.destroy'); - Route::post('chats/{chat}/stream', ChatStreamController::class)->name('chats.stream'); + Route::post('chats/{chat}/stream', ChatStreamController::class) + ->middleware('throttle:30,1') + ->name('chats.stream'); Route::get('chats/{chat}/artifacts', [ArtifactController::class, 'index'])->name('artifacts.index'); Route::get('artifacts/{artifact}', [ArtifactController::class, 'show'])->name('artifacts.show'); diff --git a/tests/Feature/Http/Requests/StoreAgentRequestTest.php b/tests/Feature/Http/Requests/StoreAgentRequestTest.php index 5ea1f3d..6a75abb 100644 --- a/tests/Feature/Http/Requests/StoreAgentRequestTest.php +++ b/tests/Feature/Http/Requests/StoreAgentRequestTest.php @@ -7,14 +7,21 @@ use Illuminate\Support\Facades\Validator; describe('authorization', function () { - it('authorizes all authenticated users', function () { + it('authorizes authenticated users', function () { $user = User::factory()->create(); + $this->actingAs($user); + $request = new StoreAgentRequest; - $request->setUserResolver(fn () => $user); expect($request->authorize())->toBeTrue(); }); + + it('denies unauthenticated users', function () { + $request = new StoreAgentRequest; + + expect($request->authorize())->toBeFalse(); + }); }); describe('required field validation', function () { diff --git a/tests/Feature/Models/AgentTest.php b/tests/Feature/Models/AgentTest.php index 0e78836..3c7d454 100644 --- a/tests/Feature/Models/AgentTest.php +++ b/tests/Feature/Models/AgentTest.php @@ -1,10 +1,59 @@ create(); expect($agent)->toBeInstanceOf(Agent::class); }); + + it('belongs to a user', function () { + $user = User::factory()->create(); + $agent = Agent::factory()->for($user)->create(); + + expect($agent->user)->toBeInstanceOf(User::class) + ->and($agent->user->id)->toBe($user->id); + }); + + it('belongs to a default model', function () { + $aiModel = AiModel::factory()->create(); + $agent = Agent::factory()->create(['default_model_id' => $aiModel->id]); + + expect($agent->defaultModel)->toBeInstanceOf(AiModel::class) + ->and($agent->defaultModel->id)->toBe($aiModel->id); + }); + + it('has many chats', function () { + $user = User::factory()->create(); + $agent = Agent::factory()->for($user)->create(); + $chat = Chat::factory()->for($user)->create(['agent_id' => $agent->id]); + + expect($agent->chats)->toHaveCount(1) + ->and($agent->chats->first()->id)->toBe($chat->id); + }); + + it('casts tools to array', function () { + $agent = Agent::factory()->create(['tools' => ['search', 'create']]); + + expect($agent->tools)->toBeArray() + ->and($agent->tools)->toBe(['search', 'create']); + }); + + it('casts capabilities to array', function () { + $agent = Agent::factory()->create(['capabilities' => ['code', 'analysis']]); + + expect($agent->capabilities)->toBeArray() + ->and($agent->capabilities)->toBe(['code', 'analysis']); + }); + + it('casts is_active to boolean', function () { + $agent = Agent::factory()->create(['is_active' => 1]); + + expect($agent->is_active)->toBeBool() + ->and($agent->is_active)->toBeTrue(); + }); }); diff --git a/tests/Feature/Models/ChatTest.php b/tests/Feature/Models/ChatTest.php index 7d6bf9e..7933a8b 100644 --- a/tests/Feature/Models/ChatTest.php +++ b/tests/Feature/Models/ChatTest.php @@ -1,5 +1,6 @@ aiModel->id)->toBe($aiModel->id); }); + it('belongs to an agent', function () { + $user = User::factory()->create(); + $agent = Agent::factory()->for($user)->create(); + $chat = Chat::factory()->for($user)->create(['agent_id' => $agent->id]); + + expect($chat->agent)->toBeInstanceOf(Agent::class); + expect($chat->agent->id)->toBe($agent->id); + }); + + it('allows null agent', function () { + $chat = Chat::factory()->create(['agent_id' => null]); + + expect($chat->agent)->toBeNull(); + }); + it('has many messages', function () { $chat = Chat::factory()->create(); Message::factory()->for($chat)->count(3)->create(); diff --git a/tests/Feature/Policies/AgentPolicyTest.php b/tests/Feature/Policies/AgentPolicyTest.php new file mode 100644 index 0000000..7d4e644 --- /dev/null +++ b/tests/Feature/Policies/AgentPolicyTest.php @@ -0,0 +1,69 @@ +policy = new AgentPolicy; + $this->user = User::factory()->create(); + $this->otherUser = User::factory()->create(); + }); + + describe('viewAny', function () { + it('allows any authenticated user to view any agents', function () { + expect($this->policy->viewAny($this->user))->toBeTrue(); + }); + }); + + describe('view', function () { + it('allows owner to view their agent', function () { + $agent = Agent::factory()->for($this->user)->create(); + + expect($this->policy->view($this->user, $agent))->toBeTrue(); + }); + + it('denies other users from viewing the agent', function () { + $agent = Agent::factory()->for($this->user)->create(); + + expect($this->policy->view($this->otherUser, $agent))->toBeFalse(); + }); + }); + + describe('create', function () { + it('allows any authenticated user to create agents', function () { + expect($this->policy->create($this->user))->toBeTrue(); + }); + }); + + describe('update', function () { + it('allows owner to update their agent', function () { + $agent = Agent::factory()->for($this->user)->create(); + + expect($this->policy->update($this->user, $agent))->toBeTrue(); + }); + + it('denies other users from updating the agent', function () { + $agent = Agent::factory()->for($this->user)->create(); + + expect($this->policy->update($this->otherUser, $agent))->toBeFalse(); + }); + }); + + describe('delete', function () { + it('allows owner to delete their agent', function () { + $agent = Agent::factory()->for($this->user)->create(); + + expect($this->policy->delete($this->user, $agent))->toBeTrue(); + }); + + it('denies other users from deleting the agent', function () { + $agent = Agent::factory()->for($this->user)->create(); + + expect($this->policy->delete($this->otherUser, $agent))->toBeFalse(); + }); + }); +}); diff --git a/tests/Feature/Policies/ChatPolicyTest.php b/tests/Feature/Policies/ChatPolicyTest.php new file mode 100644 index 0000000..4aa90dd --- /dev/null +++ b/tests/Feature/Policies/ChatPolicyTest.php @@ -0,0 +1,83 @@ +policy = new ChatPolicy; + $this->user = User::factory()->create(); + $this->otherUser = User::factory()->create(); + }); + + describe('viewAny', function () { + it('allows any authenticated user to view any chats', function () { + expect($this->policy->viewAny($this->user))->toBeTrue(); + }); + }); + + describe('view', function () { + it('allows owner to view their chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->view($this->user, $chat))->toBeTrue(); + }); + + it('denies other users from viewing the chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->view($this->otherUser, $chat))->toBeFalse(); + }); + }); + + describe('create', function () { + it('allows any authenticated user to create chats', function () { + expect($this->policy->create($this->user))->toBeTrue(); + }); + }); + + describe('update', function () { + it('allows owner to update their chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->update($this->user, $chat))->toBeTrue(); + }); + + it('denies other users from updating the chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->update($this->otherUser, $chat))->toBeFalse(); + }); + }); + + describe('delete', function () { + it('allows owner to delete their chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->delete($this->user, $chat))->toBeTrue(); + }); + + it('denies other users from deleting the chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->delete($this->otherUser, $chat))->toBeFalse(); + }); + }); + + describe('stream', function () { + it('allows owner to stream to their chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->stream($this->user, $chat))->toBeTrue(); + }); + + it('denies other users from streaming to the chat', function () { + $chat = Chat::factory()->for($this->user)->create(); + + expect($this->policy->stream($this->otherUser, $chat))->toBeFalse(); + }); + }); +}); diff --git a/tests/Feature/Services/OllamaServiceTest.php b/tests/Feature/Services/OllamaServiceTest.php index 250b8c9..d5a033f 100644 --- a/tests/Feature/Services/OllamaServiceTest.php +++ b/tests/Feature/Services/OllamaServiceTest.php @@ -7,6 +7,33 @@ Http::preventStrayRequests(); }); +describe('configuration', function () { + it('uses config values by default', function () { + config(['services.ollama.base_url' => 'http://custom-ollama:8080']); + config(['services.ollama.timeout' => 10]); + + Http::fake([ + 'custom-ollama:8080/api/tags' => Http::response(['models' => []]), + ]); + + $service = new OllamaService; + $service->getAvailableModels(); + + Http::assertSent(fn ($request) => $request->url() === 'http://custom-ollama:8080/api/tags'); + }); + + it('allows override via constructor', function () { + Http::fake([ + 'override-host:9999/api/tags' => Http::response(['models' => []]), + ]); + + $service = new OllamaService('http://override-host:9999', 15); + $service->getAvailableModels(); + + Http::assertSent(fn ($request) => $request->url() === 'http://override-host:9999/api/tags'); + }); +}); + describe('models', function () { it('returns available models from ollama api', function () { Http::fake([