diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php new file mode 100644 index 0000000..8af9159 --- /dev/null +++ b/app/Filament/Pages/Dashboard.php @@ -0,0 +1,27 @@ +> */ + public function getWidgets(): array + { + return [ + DashboardStats::class, + CriticalStock::class, + RecentOrders::class, + ]; + } +} diff --git a/app/Filament/Pages/Reports/SalesReport.php b/app/Filament/Pages/Reports/SalesReport.php new file mode 100644 index 0000000..165c1ba --- /dev/null +++ b/app/Filament/Pages/Reports/SalesReport.php @@ -0,0 +1,81 @@ +records(function (?array $filters) use ($service) { + $from = Arr::get($filters, 'period.date_from') ?? now()->startOfMonth()->toDateString(); + $to = Arr::get($filters, 'period.date_to') ?? now()->toDateString(); + + return new Collection($service->salesPerProduct($from, $to)); + }) + ->columns([ + TextColumn::make('code')->label('Kode Produk')->badge(), + TextColumn::make('name')->label('Nama Produk'), + TextColumn::make('qty')->label('Qty Terjual')->numeric(), + TextColumn::make('revenue')->label('Omzet')->numeric()->prefix('Rp '), + ]) + ->filters([ + Filter::make('period') + ->schema([ + DatePicker::make('date_from')->label('Dari')->default(now()->startOfMonth()->toDateString()), + DatePicker::make('date_to')->label('Sampai')->default(now()->toDateString()), + ]), + ]) + ->paginated(false); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Filament/Pages/Reports/StockReport.php b/app/Filament/Pages/Reports/StockReport.php new file mode 100644 index 0000000..42fa558 --- /dev/null +++ b/app/Filament/Pages/Reports/StockReport.php @@ -0,0 +1,76 @@ +records(fn () => new Collection($service->paginate(perPage: 100)->items())) + ->columns([ + TextColumn::make('products.code')->label('Kode')->badge(), + TextColumn::make('products.name')->label('Produk'), + TextColumn::make('quantity')->label('Saldo')->numeric(), + TextColumn::make('products.stock_minimum')->label('Min.'), + TextColumn::make('status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => $state === 'kritis' ? 'danger' : 'success') + ->state(fn (array $record): string => (int) $record['quantity'] <= (int) data_get($record, 'products.stock_minimum', 0) ? 'kritis' : 'aman'), + TextColumn::make('nilai') + ->label('Nilai Stok') + ->numeric()->prefix('Rp ') + ->state(fn (array $record): int => (int) $record['quantity'] * (int) ($record['products']['cost_price'] ?? 0)), + ]) + ->paginated(false); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Filament/Pages/Settings/ActivityLogs.php b/app/Filament/Pages/Settings/ActivityLogs.php new file mode 100644 index 0000000..21d6245 --- /dev/null +++ b/app/Filament/Pages/Settings/ActivityLogs.php @@ -0,0 +1,75 @@ +records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate( + search: $search, + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + )) + ->columns([ + TextColumn::make('created_at')->label('Waktu')->dateTime('d M Y H:i:s', 'Asia/Jakarta')->sortable(), + TextColumn::make('user_name')->label('User')->state(fn (array $record): string => data_get($record, 'users.name', substr((string) ($record['user_id'] ?? ''), 0, 8) ?: 'sistem')), + TextColumn::make('action')->label('Aksi')->badge()->color('info'), + TextColumn::make('entity_type')->label('Entitas')->badge(), + TextColumn::make('description')->label('Deskripsi')->weight(FontWeight::Medium), + ]) + ->paginated([10, 25, 50]); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Filament/Pages/Settings/Users.php b/app/Filament/Pages/Settings/Users.php new file mode 100644 index 0000000..2df8a18 --- /dev/null +++ b/app/Filament/Pages/Settings/Users.php @@ -0,0 +1,117 @@ +records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate( + search: $search, + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + )) + ->columns([ + TextColumn::make('name')->label('Nama')->weight(FontWeight::SemiBold), + TextColumn::make('email')->label('Email'), + TextColumn::make('role') + ->label('Role') + ->badge() + ->color(fn (string $state): string => $state === 'admin' ? 'danger' : 'info'), + IconColumn::make('is_active')->label('Aktif')->boolean(), + ]) + ->recordActions([ + Action::make('role') + ->label('Ubah Role') + ->schema(fn (Schema $schema): Schema => $schema->components([ + Select::make('role')->label('Role')->options(['admin' => 'Admin', 'operator' => 'Operator'])->required(), + ])) + ->fillForm(fn (array $record): array => ['role' => $record['role']]) + ->action(function (array $data, array $record): void { + app(UserAdminService::class)->setRole($record['id'], $data['role'], auth()->user()); + Notification::make()->title('Role diperbarui.')->success()->send(); + }), + Action::make('toggle_aktif') + ->label(fn (array $record): string => ($record['is_active'] ?? false) ? 'Nonaktifkan' : 'Aktifkan') + ->color(fn (array $record): bool|string => ($record['is_active'] ?? false) ? 'danger' : 'success') + ->requiresConfirmation() + ->action(function (array $record): void { + app(UserAdminService::class)->setActive($record['id'], ! ($record['is_active'] ?? false), auth()->user()); + Notification::make()->title('Status user diperbarui.')->success()->send(); + }), + ]) + ->headerActions([ + CreateAction::make() + ->label('Tambah User') + ->schema(fn (Schema $schema): Schema => $schema->components([ + TextInput::make('name')->label('Nama')->required(), + TextInput::make('email')->label('Email')->email()->required(), + TextInput::make('password')->label('Password')->password()->minLength(8)->required(), + Select::make('role')->label('Role')->options(['operator' => 'Operator', 'admin' => 'Admin'])->default('operator')->required(), + ])) + ->using(function (array $data): void { + app(UserAdminService::class)->createUser($data, auth()->user()); + Notification::make()->title('User dibuat & terverifikasi.')->success()->send(); + }), + ]) + ->paginated([10, 25]); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Filament/Widgets/CriticalStock.php b/app/Filament/Widgets/CriticalStock.php new file mode 100644 index 0000000..6f67d5c --- /dev/null +++ b/app/Filament/Widgets/CriticalStock.php @@ -0,0 +1,39 @@ +paginate(perPage: 100)->items()) + ->filter(fn (array $row): bool => (int) $row['quantity'] <= (int) data_get($row, 'products.stock_minimum', 0)) + ->values(); + + return $table + ->heading('Stok Kritis') + ->records(fn () => $rows) + ->columns([ + TextColumn::make('products.code')->label('Kode')->badge(), + TextColumn::make('products.name')->label('Produk'), + TextColumn::make('quantity')->label('Saldo')->numeric()->color('danger'), + TextColumn::make('products.stock_minimum')->label('Min.'), + TextColumn::make('status') + ->badge() + ->color('danger') + ->state(fn (array $record): string => (int) $record['quantity'] <= (int) data_get($record, 'products.stock_minimum', 0) ? 'kritis' : 'aman'), + ]) + ->paginated(false); + } +} diff --git a/app/Filament/Widgets/DashboardStats.php b/app/Filament/Widgets/DashboardStats.php new file mode 100644 index 0000000..0740391 --- /dev/null +++ b/app/Filament/Widgets/DashboardStats.php @@ -0,0 +1,41 @@ +countByStatus(); + + $openComplaints = app(InsForgeClient::class)->countRecords('complaints', [ + 'status' => ComplaintService::STATUS_OPEN, + ]); + + $critical = collect(app(StockService::class)->paginate(perPage: 100)->items()) + ->filter(fn (array $row): bool => (int) $row['quantity'] <= (int) data_get($row, 'products.stock_minimum', 0)) + ->count(); + + return [ + Stat::make('Pending', (string) ($counts[OrderService::STATUS_PENDING] ?? 0))->color('gray'), + Stat::make('Diproses', (string) ($counts[OrderService::STATUS_PROCESSING] ?? 0))->color('info'), + Stat::make('Dikirim', (string) ($counts[OrderService::STATUS_DELIVERING] ?? 0))->color('warning'), + Stat::make('Selesai', (string) ($counts[OrderService::STATUS_COMPLETED] ?? 0))->color('success'), + Stat::make('Komplain Terbuka', (string) $openComplaints)->color($openComplaints > 0 ? 'danger' : 'success'), + Stat::make('Stok Kritis', (string) $critical)->color($critical > 0 ? 'danger' : 'success'), + ]; + } +} diff --git a/app/Filament/Widgets/RecentOrders.php b/app/Filament/Widgets/RecentOrders.php new file mode 100644 index 0000000..33a35ce --- /dev/null +++ b/app/Filament/Widgets/RecentOrders.php @@ -0,0 +1,48 @@ +heading('Pesanan Terbaru') + ->records(fn () => new Collection($service->recent(8))) + ->columns([ + TextColumn::make('order_number')->label('No. Order')->badge(), + TextColumn::make('customers.name')->label('Pelanggan'), + TextColumn::make('order_date')->label('Tgl')->date('d M Y'), + TextColumn::make('order_status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'pending' => 'gray', + 'processing' => 'info', + 'delivering' => 'warning', + 'completed' => 'success', + default => 'danger', + }) + ->formatStateUsing(fn (string $state): string => match ($state) { + 'pending' => 'Pending', + 'processing' => 'Diproses', + 'delivering' => 'Dikirim', + 'completed' => 'Selesai', + default => 'Batal', + }), + ]) + ->paginated(false); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index de84578..c416b2a 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,12 +2,12 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Dashboard; use App\Http\Middleware\RefreshInsForgeToken; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; -use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; diff --git a/app/Services/ActivityLogService.php b/app/Services/ActivityLogService.php new file mode 100644 index 0000000..d4330c7 --- /dev/null +++ b/app/Services/ActivityLogService.php @@ -0,0 +1,28 @@ +buildFilters($filters); - $response = $this->request('HEAD', "/api/database/records/{$table}", query: array_merge($query, ['limit' => 1])); + $response = $this->send( + 'GET', + "/api/database/records/{$table}", + query: array_merge($this->buildFilters($filters), ['limit' => 0]), + headers: ['Prefer' => 'count=exact'], + ); - return (int) ($response->header('Content-Range') ? explode('/', explode('-', $response->header('Content-Range'))[1])[0] : 0); + return $this->parseTotalFromRange($response->header('Content-Range')); + } + + /** + * Jalankan SQL read-only/administratif via management API (admin key). + * + * @return list> + */ + public function rawSql(string $query): array + { + $response = Http::baseUrl(rtrim($this->baseUrl, '/')) + ->timeout($this->timeout) + ->withToken($this->apiKey) + ->post('/api/database/advance/rawsql', ['query' => $query]); + + throw_unless($response->successful(), fn () => InsForgeException::fromResponse( + $response->status(), + $response->json() ?? $response->body(), + )); + + return $response->json('rows') ?? []; + } + + private function parseTotalFromRange(?string $contentRange): int + { + if (! filled($contentRange)) { + return 0; + } + + $total = explode('/', $contentRange)[1] ?? ''; + + return ctype_digit($total) ? (int) $total : 0; } public function rpc(string $function, array $args = [], bool $asUser = true): mixed diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index ea83172..87824ab 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -272,6 +272,72 @@ class OrderService extends InsForgeTableService return $actor !== null && ($actor->isAdmin() || $actor->id === ($record['created_by'] ?? null)); } + /** @return array status => jumlah order aktif */ + public function countByStatus(): array + { + [$client] = $this->scoped(); + + $counts = []; + + foreach (array_keys(self::TRANSITIONS) as $status) { + $counts[$status] = $client->countRecords($this->table(), [ + 'order_status' => $status, + 'deleted_at' => 'null', + ]); + } + + return $counts; + } + + public function recent(int $limit = 8): array + { + [$client] = $this->scoped(); + + return $client->listRecords($this->table(), [ + 'select' => 'id,order_number,order_date,order_status,customers(name)', + 'deleted_at' => 'eq.null', + 'order' => 'created_at.desc', + 'limit' => $limit, + ]); + } + + /** + * Agregasi penjualan per produk dari order completed dalam periode (UTC). + * + * @return list + */ + public function salesPerProduct(string $fromDate, string $toDate): array + { + [$client, $asUser] = $this->scoped(); + + $orders = $client->listRecords($this->table(), [ + 'select' => 'order_items(quantity,subtotal,products(code,name))', + 'and' => sprintf( + '(order_status.eq.completed,completed_at.gte.%sT00:00:00+00:00,completed_at.lte.%sT23:59:59+00:00)', + $fromDate, + $toDate, + ), + 'limit' => 1000, + ], asUser: $asUser); + + $aggregated = []; + + foreach ($orders as $order) { + foreach ($order['order_items'] ?? [] as $item) { + $code = data_get($item, 'products.code', '?'); + $key = $code; + + $aggregated[$key] ??= ['code' => $code, 'name' => data_get($item, 'products.name', ''), 'qty' => 0, 'revenue' => 0]; + $aggregated[$key]['qty'] += (int) ($item['quantity'] ?? 0); + $aggregated[$key]['revenue'] += (int) ($item['subtotal'] ?? 0); + } + } + + usort($aggregated, fn (array $a, array $b): int => $b['revenue'] <=> $a['revenue']); + + return array_values($aggregated); + } + public function deliveryNoteData(string $orderId): array { [$client, $asUser] = $this->scoped(); diff --git a/app/Services/StockService.php b/app/Services/StockService.php index c2c26e4..d128864 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -23,7 +23,7 @@ class StockService extends InsForgeTableService protected function select(): string { - return '*,products(code,name,stock_minimum,is_active)'; + return '*,products(code,name,stock_minimum,is_active,cost_price)'; } protected function searchableColumns(): array diff --git a/app/Services/UserAdminService.php b/app/Services/UserAdminService.php new file mode 100644 index 0000000..ace5493 --- /dev/null +++ b/app/Services/UserAdminService.php @@ -0,0 +1,102 @@ + 'Password minimal 8 karakter.']); + } + + try { + $this->client->createUserAsAdmin([ + 'email' => $email, + 'password' => $password, + 'name' => $attributes['name'] ?? $email, + ]); + } catch (InsForgeException $exception) { + if ($exception->getCode() === 409 || str_contains($exception->getMessage(), 'exist')) { + throw ValidationException::withMessages(['email' => 'Email sudah terdaftar.']); + } + + throw $exception; + } + + [$authId] = $this->client->rawSql(sprintf( + "SELECT id::text FROM auth.users WHERE email = '%s' LIMIT 1", + str_replace("'", "''", $email), + )) ?: [null]; + + $authId = $authId['id'] ?? null; + + if ($authId === null) { + throw new \RuntimeException('Gagal menemukan user auth yang baru dibuat.'); + } + + $this->client->rawSql(sprintf( + "UPDATE auth.users SET email_verified = true WHERE id = '%s'", + $authId, + )); + + $this->client->insertRecord('users', [[ + 'id' => $authId, + 'name' => $attributes['name'] ?? $email, + 'email' => $email, + 'password' => Hash::make($password), + 'role' => $role, + 'is_active' => true, + ]], asUser: false); + + $this->activity->log('user_created', 'users', $authId, "User {$email} dibuat dengan role {$role}.", $actor); + + return ['id' => $authId, 'email' => $email]; + } + + public function setActive(string $userId, bool $active, ?User $actor = null): void + { + $this->client->updateRecords('users', ['id' => $userId], [ + 'is_active' => $active, + ], asUser: false); + + $this->activity->log( + $active ? 'user_activated' : 'user_deactivated', + 'users', + $userId, + sprintf('User %s.', $active ? 'diaktifkan' : 'dinonaktifkan'), + $actor, + ); + } + + public function setRole(string $userId, string $role, ?User $actor = null): void + { + in_array($role, ['admin', 'operator'], true) || throw ValidationException::withMessages(['role' => 'Role tidak valid.']); + + $this->client->updateRecords('users', ['id' => $userId], [ + 'role' => $role, + ], asUser: false); + + $this->activity->log('user_role_changed', 'users', $userId, "Role diubah menjadi {$role}.", $actor); + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php new file mode 100644 index 0000000..154c72a --- /dev/null +++ b/app/Services/UserService.php @@ -0,0 +1,23 @@ + 'p-1', + 'code' => 'BERAS-001', + 'name' => 'Beras Premium', + 'cost_price' => 13000, + 'stock_minimum' => 50, + 'is_active' => true, + ]); + } + + private function actingAsRole(string $role): User + { + $user = new User; + + $user->forceFill([ + 'id' => "eeee9999-0000-0000-0000-{$role}00001", + 'name' => ucfirst($role), + 'email' => "{$role}@beraspro.test", + 'role' => $role, + 'is_active' => true, + ]); + + $this->actingAs($user); + + return $user; + } + + public function test_dashboard_renders_for_operator(): void + { + $this->actingAsRole('operator'); + + InsForgeFake::seed('stocks', [ + 'id' => 's1', + 'product_id' => 'p-1', + 'quantity' => 5, + 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium', 'stock_minimum' => 50, 'cost_price' => 13000], + ]); + + Livewire::test(Dashboard::class)->assertSuccessful(); + $this->assertCount(3, (new Dashboard)->getWidgets()); + + Livewire::test(DashboardStats::class) + ->assertSuccessful() + ->assertSee('Pending') + ->assertSee('Komplain Terbuka'); + + Livewire::test(CriticalStock::class) + ->assertSuccessful() + ->assertSee('Beras Premium') + ->assertSee('kritis'); + } + + public function test_sales_report_aggregates_completed_orders(): void + { + $this->actingAsRole('admin'); + + InsForgeFake::seed('orders', [ + 'id' => 'o1', + 'order_status' => OrderService::STATUS_COMPLETED, + 'deleted_at' => null, + 'order_items' => [ + ['quantity' => 3, 'subtotal' => 45000, 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium']], + ['quantity' => 2, 'subtotal' => 30000, 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium']], + ], + ]); + + $rows = app(OrderService::class)->salesPerProduct('2026-08-01', '2026-08-31'); + + $this->assertCount(1, $rows); + $this->assertSame(5, $rows[0]['qty']); + $this->assertSame(75000, $rows[0]['revenue']); + } + + public function test_sales_report_page_denied_for_operator(): void + { + $this->actingAsRole('operator'); + + $this->get('/admin/reports/sales')->assertForbidden(); + } + + public function test_stock_report_renders_for_admin(): void + { + $this->actingAsRole('admin'); + + InsForgeFake::seed('stocks', [ + 'id' => 's2', + 'product_id' => 'p-1', + 'quantity' => 20, + 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium', 'stock_minimum' => 50, 'cost_price' => 13000], + ]); + + Livewire::test(StockReport::class) + ->assertSuccessful() + ->assertSee('Beras Premium') + ->assertSee('kritis'); + } + + public function test_users_page_denied_for_operator(): void + { + $this->actingAsRole('operator'); + + $this->get('/admin/settings/users')->assertForbidden(); + } + + public function test_create_user_via_admin_api_creates_verified_mirror(): void + { + $this->actingAsRole('admin'); + + $result = app(UserAdminService::class)->createUser([ + 'name' => 'Operator Baru', + 'email' => 'baru@beraspro.test', + 'password' => 'Rahasia#123', + 'role' => 'operator', + ], auth()->user()); + + $this->assertArrayHasKey('id', $result); + + // Mirror row dibuat dengan role benar. + $mirror = array_values(array_filter( + InsForgeFake::$store['users'] ?? [], + fn (array $row): bool => $row['email'] === 'baru@beraspro.test', + )); + $this->assertCount(1, $mirror); + $this->assertSame('operator', reset($mirror)['role']); + + // email terverifikasi via rawSql UPDATE. + $verified = array_values(array_filter(InsForgeFake::$rawSqlQueries, fn (string $q): bool => str_contains($q, 'email_verified = true'))); + $this->assertNotEmpty($verified); + + // Activity log tertulis. + $log = end(InsForgeFake::$store['activity_logs']); + $this->assertSame('user_created', $log['action']); + } + + public function test_activity_logs_page_renders_for_admin(): void + { + $this->actingAsRole('admin'); + + InsForgeFake::seed('activity_logs', [ + 'id' => 'l1', + 'action' => 'order_created', + 'entity_type' => 'orders', + 'description' => 'Order ORD-X dibuat.', + ]); + + Livewire::test(ActivityLogs::class) + ->assertSuccessful() + ->assertSee('order_created'); + } +} diff --git a/tests/Feature/Phase6/WidProbe.php b/tests/Feature/Phase6/WidProbe.php new file mode 100644 index 0000000..3325787 --- /dev/null +++ b/tests/Feature/Phase6/WidProbe.php @@ -0,0 +1,41 @@ + 'p-1', 'code' => 'BERAS-001', 'name' => 'Beras Premium', + 'cost_price' => 13000, 'stock_minimum' => 50, 'is_active' => true, + ]); + + $user = new User; + $user->forceFill(['id' => 'u1', 'role' => 'operator', 'is_active' => true]); + $this->actingAs($user); + + InsForgeFake::seed('stocks', [ + 'id' => 's1', + 'product_id' => 'p-1', + 'quantity' => 5, + 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium', 'stock_minimum' => 50, 'cost_price' => 13000], + ]); + + $html = Livewire::test(CriticalStock::class)->html(); + echo 'len=', strlen($html), "\n"; + echo 'Beras: ', str_contains($html, 'Beras Premium') ? 'Y' : 'N', "\n"; + echo 'kritis: ', str_contains($html, 'kritis') ? 'Y' : 'N', "\n"; + echo 'no-data: ', str_contains($html, 'tidak ada data') || str_contains($html, 'empty') ? 'Y' : 'N', "\n"; + $this->assertTrue(true); + } +} diff --git a/tests/Support/InsForgeFake.php b/tests/Support/InsForgeFake.php index cc0f0f2..1a5d706 100644 --- a/tests/Support/InsForgeFake.php +++ b/tests/Support/InsForgeFake.php @@ -13,12 +13,20 @@ class InsForgeFake /** @var list}> */ public static array $rpcCalls = []; + /** @var array email => auth uuid */ + public static array $authUsersByEmail = []; + + /** @var list */ + public static array $rawSqlQueries = []; + public static string $accessToken = ''; public static function reset(): void { self::$store = []; self::$rpcCalls = []; + self::$authUsersByEmail = []; + self::$rawSqlQueries = []; } public static function seed(string $table, array $row): void @@ -71,6 +79,30 @@ class InsForgeFake { Http::fake([ '*/api/auth/refresh' => Http::response(['accessToken' => self::$accessToken], 200), + '*/api/auth/users' => function (Request $request) { + if (strtoupper($request->method()) === 'POST') { + $payload = $request->data(); + self::$authUsersByEmail[$payload['email'] ?? ''] = 'auth-'.md5((string) ($payload['email'] ?? '')); + + return Http::response(['accessToken' => null, 'requireEmailVerification' => true], 200); + } + + return Http::response([], 200); + }, + '*/api/database/advance/rawsql*' => function (Request $request) { + $query = (string) ($request->data()['query'] ?? ''); + + self::$rawSqlQueries[] = $query; + + if (str_contains($query, 'FROM auth.users') && str_contains(strtoupper($query), 'SELECT')) { + preg_match("/email = '([^']+)'/", $query, $m); + $id = self::$authUsersByEmail[$m[1] ?? ''] ?? null; + + return Http::response(['rows' => $id ? [['id' => $id]] : []], 200); + } + + return Http::response(['rows' => []], 200); + }, '*/api/database/rpc/*' => function (Request $request) { $function = str_replace('/api/database/rpc/', '', (string) parse_url($request->url(), PHP_URL_PATH)); $args = $request->data();