diff --git a/Modules/Countries/Routes/api.php b/Modules/Countries/Routes/api.php deleted file mode 100644 index 4b93664a..00000000 --- a/Modules/Countries/Routes/api.php +++ /dev/null @@ -1,25 +0,0 @@ -name('api.countries.')->group(function (): void { - Route::get('/', [CountriesApiController::class, 'index'])->name('index'); - Route::get('/active', [CountriesApiController::class, 'active'])->name('active'); - Route::get('/resolve/{code}', [CountriesApiController::class, 'resolve']) - ->where('code', '[A-Za-z0-9]{2,3}') - ->name('resolve'); - Route::get('/{country:alpha2}', [CountriesApiController::class, 'show'])->name('show'); - Route::get('/{alpha2}/subdivisions', [CountriesApiController::class, 'subdivisions']) - ->where('alpha2', '[A-Za-z]{2}') - ->name('subdivisions'); -}); - - -Route::prefix('api/v1')->name('api.reference.')->group(function (): void { - Route::get('/currencies', [ReferenceDataApiController::class, 'currencies'])->name('currencies.index'); - Route::get('/languages', [ReferenceDataApiController::class, 'languages'])->name('languages.index'); - Route::get('/scripts', [ReferenceDataApiController::class, 'scripts'])->name('scripts.index'); - Route::get('/locodes', [ReferenceDataApiController::class, 'locodes'])->name('locodes.index'); -}); diff --git a/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php b/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php index 608049c8..2d21cbd2 100644 --- a/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php +++ b/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php @@ -14,6 +14,17 @@ class DocumentationModuleTest extends TestCase { use RefreshDatabase; + /** + * Add a CSRF token for projects that keep web middleware enabled during tests. + * + * @param array $payload + * @return array + */ + private function csrfPayload(array $payload): array + { + return array_merge(['_token' => 'test-token'], $payload); + } + /** * The documentation dashboard renders for authenticated users. */ @@ -40,7 +51,7 @@ class DocumentationModuleTest extends TestCase 'is_active' => true, ]); - $response = $this->actingAs($user)->post(route('documentation.documents.store'), [ + $response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([ 'documentation_category_id' => $category->id, 'title' => 'Goods In Process', 'slug' => 'goods-in-process', @@ -50,7 +61,7 @@ class DocumentationModuleTest extends TestCase 'version' => '1.0.0', 'reference' => 'SOP-001', 'is_featured' => '1', - ]); + ])); $response->assertRedirect(); $this->assertDatabaseHas('documentation_documents', [ @@ -72,7 +83,7 @@ class DocumentationModuleTest extends TestCase $user = \App\Models\User::factory()->create(); $file = UploadedFile::fake()->createWithContent('goods-in.md', "# Goods In\n\n- Scan delivery note\n- Confirm quantities"); - $response = $this->actingAs($user)->post(route('documentation.documents.store'), [ + $response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([ 'title' => 'Goods In Markdown', 'slug' => 'goods-in-markdown', 'summary' => 'Markdown controlled document.', @@ -80,7 +91,7 @@ class DocumentationModuleTest extends TestCase 'status' => DocumentationDocument::STATUS_PUBLISHED, 'version' => '1.0.0', 'attachment' => $file, - ]); + ])); $response->assertRedirect(); @@ -102,7 +113,7 @@ class DocumentationModuleTest extends TestCase $user = \App\Models\User::factory()->create(); - $response = $this->actingAs($user)->post(route('documentation.documents.store'), [ + $response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([ 'title' => 'Attachment Pack', 'slug' => 'attachment-pack', 'summary' => 'Document with supporting files.', @@ -113,7 +124,7 @@ class DocumentationModuleTest extends TestCase UploadedFile::fake()->createWithContent('setup.md', "```php\necho 'ok';\n```"), UploadedFile::fake()->create('process.pdf', 64, 'application/pdf'), ], - ]); + ])); $response->assertRedirect(); @@ -129,7 +140,8 @@ class DocumentationModuleTest extends TestCase ->assertOk(); $this->actingAs($user) - ->delete(route('documentation.documents.attachments.destroy', [$document, $attachment])) + ->withSession(['_token' => 'test-token']) + ->delete(route('documentation.documents.attachments.destroy', [$document, $attachment]), ['_token' => 'test-token']) ->assertRedirect(route('documentation.documents.edit', $document)); $this->assertDatabaseMissing('documentation_attachments', ['id' => $attachment->id]); @@ -158,14 +170,14 @@ class DocumentationModuleTest extends TestCase { $user = \App\Models\User::factory()->create(); - $this->actingAs($user)->post(route('documentation.categories.store'), [ + $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.categories.store'), $this->csrfPayload([ 'name' => 'Policies', 'slug' => 'policies', 'colour' => 'emerald', 'description' => 'Controlled business policies.', 'sort_order' => 1, 'is_active' => '1', - ])->assertRedirect(route('documentation.categories.index')); + ]))->assertRedirect(route('documentation.categories.index')); $this->assertDatabaseHas('documentation_categories', [ 'name' => 'Policies', diff --git a/Modules/Helpdesk/Countries/Routes/api.php b/Modules/Helpdesk/Countries/Routes/api.php deleted file mode 100644 index a18b2e84..00000000 --- a/Modules/Helpdesk/Countries/Routes/api.php +++ /dev/null @@ -1,9 +0,0 @@ -name('api.countries.index'); - Route::get('/countries/{country:alpha2}', [CountryLookupController::class, 'show'])->name('api.countries.show'); -} diff --git a/Modules/Helpdesk/Livewire/TicketIndex.php b/Modules/Helpdesk/Livewire/TicketIndex.php index d2fddbab..66439984 100644 --- a/Modules/Helpdesk/Livewire/TicketIndex.php +++ b/Modules/Helpdesk/Livewire/TicketIndex.php @@ -59,6 +59,7 @@ final class TicketIndex extends Component private function tickets(): LengthAwarePaginator { $user = Auth::user(); + $search = trim($this->search); return Ticket::query() ->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) @@ -67,11 +68,11 @@ final class TicketIndex extends Component ->when($this->scope === 'closed', static fn (Builder $query): Builder => $query->whereNotNull('completed_at')) ->when($this->status !== '', fn (Builder $query): Builder => $query->where('status_id', (int) $this->status)) ->when($this->priority !== '', fn (Builder $query): Builder => $query->where('priority_id', (int) $this->priority)) - ->when($this->search !== '', function (Builder $query): Builder { - return $query->where(static function (Builder $subQuery): void { - $subQuery->where('reference', 'like', '%'.$this->search.'%') - ->orWhere('subject', 'like', '%'.$this->search.'%') - ->orWhere('body', 'like', '%'.$this->search.'%'); + ->when($search !== '', static function (Builder $query) use ($search): Builder { + return $query->where(static function (Builder $subQuery) use ($search): void { + $subQuery->where('reference', 'like', '%'.$search.'%') + ->orWhere('subject', 'like', '%'.$search.'%') + ->orWhere('body', 'like', '%'.$search.'%'); }); }) ->latest('last_activity_at') diff --git a/Modules/Helpdesk/Policies/TicketPolicy.php b/Modules/Helpdesk/Policies/TicketPolicy.php index ad140d06..6d45f1f7 100644 --- a/Modules/Helpdesk/Policies/TicketPolicy.php +++ b/Modules/Helpdesk/Policies/TicketPolicy.php @@ -29,8 +29,7 @@ final class TicketPolicy public function update(User $user, Ticket $ticket): bool { return $this->isPrivileged($user) - || (int) $ticket->agent_id === (int) $user->id - || (int) $ticket->user_id === (int) $user->id; + || ((bool) ($user->helpdesk_agent ?? false) && (int) $ticket->agent_id === (int) $user->id); } public function comment(User $user, Ticket $ticket): bool diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php index e2f8ce46..53dbd53e 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -25,7 +25,7 @@ @include('helpdesk::partials.page-header', [ 'eyebrow' => 'Helpdesk', - 'title' => 'Create Ticket', + 'title' => 'Create ticket', 'subtitle' => 'Raise a new support request with priority, project, category, team and full details.', ]) diff --git a/Modules/Helpdesk/Services/AgentAssignmentService.php b/Modules/Helpdesk/Services/AgentAssignmentService.php index decc65d5..ae6eca15 100644 --- a/Modules/Helpdesk/Services/AgentAssignmentService.php +++ b/Modules/Helpdesk/Services/AgentAssignmentService.php @@ -17,6 +17,7 @@ final class AgentAssignmentService public function selectAgentId(?int $categoryId): ?int { $query = User::query() + ->select('users.*') ->where(static function (Builder $query): void { $query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true); }) diff --git a/Modules/Platform/resources/views/dashboard/index.blade.php b/Modules/Platform/resources/views/dashboard/index.blade.php index 587ddd23..20425a45 100644 --- a/Modules/Platform/resources/views/dashboard/index.blade.php +++ b/Modules/Platform/resources/views/dashboard/index.blade.php @@ -11,6 +11,15 @@ ]" /> +
+ @foreach ([['Companies', $companiesCount], ['Active', $activeCompaniesCount], ['Warehouses', $warehousesCount], ['Prices', $pricesCount], ['Licences', $licensesCount]] as [$label, $value]) +
+

{{ $label }}

+

{{ $value }}

+
+ @endforeach +
+
@@ -79,6 +88,81 @@
+
+
+
+

Pricing

+ +

+ Pricing plans +

+ +

+ Current platform pricing tiers and administration shortcuts. +

+
+ + +
+ +
+ @foreach ($prices as $price) +
+
+
+

+ {{ $price->name }} +

+ +

+ {{ $price->strapline }} +

+
+ + @if ($price->badge_label) + + {{ $price->badge_label }} + + @endif +
+ +

+ £{{ number_format($price->monthly_amount / 100, 0) }} + / month +

+ + @if (! empty($price->features)) +
    + @foreach (array_slice($price->features, 0, 3) as $feature) +
  • + + {{ $feature }} +
  • + @endforeach +
+ @endif +
+ @endforeach +
+
diff --git a/phpunit.dusk.xml b/phpunit.dusk.xml new file mode 100644 index 00000000..24fbe459 --- /dev/null +++ b/phpunit.dusk.xml @@ -0,0 +1,15 @@ + + + + + ./tests/Browser + + + diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php index 020699db..a27a4a66 100644 --- a/tests/DuskTestCase.php +++ b/tests/DuskTestCase.php @@ -5,12 +5,14 @@ namespace Tests; use Facebook\WebDriver\Chrome\ChromeOptions; use Facebook\WebDriver\Remote\DesiredCapabilities; use Facebook\WebDriver\Remote\RemoteWebDriver; -use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Artisan; use Laravel\Dusk\TestCase as BaseTestCase; use PHPUnit\Framework\Attributes\BeforeClass; abstract class DuskTestCase extends BaseTestCase { + use CreatesApplication; + /** * Prepare for Dusk test execution. */ @@ -18,6 +20,7 @@ abstract class DuskTestCase extends BaseTestCase public static function prepare(): void { if (! static::runningInSail()) { + static::ensureChromeDriverBinaryAlias(); static::startChromeDriver(['--port=9515']); } } @@ -27,22 +30,63 @@ abstract class DuskTestCase extends BaseTestCase */ protected function driver(): RemoteWebDriver { - $options = (new ChromeOptions)->addArguments(collect([ - $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', + $options = (new ChromeOptions())->addArguments([ + '--disable-gpu', + '--headless=new', + '--window-size=1920,1080', + '--no-sandbox', + '--disable-dev-shm-usage', '--disable-search-engine-choice-screen', - '--disable-smooth-scrolling', - ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { - return $items->merge([ - '--disable-gpu', - '--headless=new', - ]); - })->all()); + ]); return RemoteWebDriver::create( - $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515', - DesiredCapabilities::chrome()->setCapability( - ChromeOptions::CAPABILITY, $options - ) + $_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515', + DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $options) ); } + + /** + * Dusk may install a differently named macOS Apple Silicon binary. + * Laravel Dusk still asks for chromedriver-mac-arm, so create that alias safely. + */ + private static function ensureChromeDriverBinaryAlias(): void + { + $binDirectory = base_path('vendor/laravel/dusk/bin'); + $expected = $binDirectory.'/chromedriver-mac-arm'; + + if (is_file($expected)) { + @chmod($expected, 0755); + return; + } + + if (! is_dir($binDirectory)) { + @mkdir($binDirectory, 0755, true); + } + + try { + Artisan::call('dusk:chrome-driver'); + } catch (\Throwable) { + // Continue and inspect whatever files are available locally. + } + + $candidates = array_values(array_filter(glob($binDirectory.'/chromedriver*') ?: [], static function (string $path) use ($expected): bool { + return is_file($path) + && $path !== $expected + && ! str_ends_with($path, '.zip') + && ! str_ends_with($path, '.txt'); + })); + + usort($candidates, static function (string $left, string $right): int { + $score = static fn (string $path): int => str_contains($path, 'mac-arm64') ? 0 + : (str_contains($path, 'mac-arm') ? 1 + : (str_contains($path, 'mac') ? 2 : 3)); + + return $score($left) <=> $score($right); + }); + + if ($candidates !== []) { + @copy($candidates[0], $expected); + @chmod($expected, 0755); + } + } }