v1.0.0
This commit is contained in:
131
src/tests/Feature/AdminBrandingTest.php
Normal file
131
src/tests/Feature/AdminBrandingTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can change the site-wide accent color', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->call('setAccentColor', '#123abc')
|
||||
->assertSet('accentColor', '#123abc');
|
||||
|
||||
expect(Settings::accentColor())->toBe('#123abc');
|
||||
});
|
||||
|
||||
test('an invalid accent color is rejected and the previous value is kept', function () {
|
||||
$admin = adminUser();
|
||||
Settings::set('accent_color', '#123abc');
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->call('setAccentColor', 'not-a-color; }</style><script>alert(1)</script>')
|
||||
->assertSet('accentColor', '#123abc');
|
||||
|
||||
expect(Settings::accentColor())->toBe('#123abc');
|
||||
});
|
||||
|
||||
test('resetting the accent color restores the default purple', function () {
|
||||
$admin = adminUser();
|
||||
Settings::set('accent_color', '#123abc');
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->call('resetAccentColor')
|
||||
->assertSet('accentColor', '#7c6fd6');
|
||||
|
||||
expect(Settings::accentColor())->toBe('#7c6fd6');
|
||||
});
|
||||
|
||||
test('a malformed stored accent color falls back to the default instead of breaking every page', function () {
|
||||
Settings::set('accent_color', 'javascript:alert(1)');
|
||||
|
||||
expect(Settings::accentColor())->toBe('#7c6fd6');
|
||||
});
|
||||
|
||||
test('the branding tab lists the logo section below company name and colors, not beside them', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->assertSeeInOrder(['Nazwa firmy', 'Kolor akcentu', 'Logo']);
|
||||
});
|
||||
|
||||
test('without any custom upload, the logo and favicon both resolve to the bundled default mark', function () {
|
||||
expect(Settings::logoUrl())->toContain('branding/default-mark.svg')
|
||||
->and(Settings::faviconUrl())->toContain('branding/default-mark.svg');
|
||||
});
|
||||
|
||||
test('admin can upload a custom svg favicon next to the logo config', function () {
|
||||
Storage::fake('public');
|
||||
$admin = adminUser();
|
||||
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>';
|
||||
$file = UploadedFile::fake()->createWithContent('favicon.svg', $svg);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->set('faviconUpload', $file)
|
||||
->assertSet('faviconError', null);
|
||||
|
||||
$path = Settings::get('favicon_path');
|
||||
expect($path)->not->toBeNull();
|
||||
Storage::disk('public')->assertExists($path);
|
||||
expect(Storage::disk('public')->get($path))->toContain('<svg');
|
||||
});
|
||||
|
||||
test('a non-svg favicon upload is rejected with an error and no setting is saved', function () {
|
||||
Storage::fake('public');
|
||||
$admin = adminUser();
|
||||
$file = UploadedFile::fake()->create('favicon.png', 10, 'image/png');
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->set('faviconUpload', $file)
|
||||
->assertSet('faviconError', 'Favicon musi być plikiem w formacie SVG.');
|
||||
|
||||
expect(Settings::get('favicon_path'))->toBeNull();
|
||||
});
|
||||
|
||||
test('uploading a favicon strips embedded scripts and inline event handlers before storing it', function () {
|
||||
Storage::fake('public');
|
||||
$admin = adminUser();
|
||||
$malicious = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><rect width="5" height="5" onclick="alert(3)"/></svg>';
|
||||
$file = UploadedFile::fake()->createWithContent('favicon.svg', $malicious);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->set('faviconUpload', $file);
|
||||
|
||||
$stored = Storage::disk('public')->get(Settings::get('favicon_path'));
|
||||
|
||||
expect($stored)->not->toContain('<script')
|
||||
->and($stored)->not->toContain('onload=')
|
||||
->and($stored)->not->toContain('onclick=');
|
||||
});
|
||||
|
||||
test('removing a custom favicon falls back to the default mark again', function () {
|
||||
Storage::fake('public');
|
||||
$admin = adminUser();
|
||||
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>';
|
||||
$file = UploadedFile::fake()->createWithContent('favicon.svg', $svg);
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'branding')
|
||||
->set('faviconUpload', $file);
|
||||
|
||||
$path = Settings::get('favicon_path');
|
||||
|
||||
$component->call('removeFavicon');
|
||||
|
||||
Storage::disk('public')->assertMissing($path);
|
||||
expect(Settings::get('favicon_path'))->toBeNull()
|
||||
->and(Settings::faviconUrl())->toContain('branding/default-mark.svg');
|
||||
});
|
||||
|
||||
test('the login page shows the configured logo', function () {
|
||||
$this->get('/login')->assertOk()->assertSee('branding/default-mark.svg', false);
|
||||
});
|
||||
47
src/tests/Feature/AdminCategoryManagementTest.php
Normal file
47
src/tests/Feature/AdminCategoryManagementTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Category;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can add a description to a category via the edit form', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'categories')
|
||||
->call('startEditCategory', $category->id, $category->name, $category->description)
|
||||
->set('editingCategoryDescription', 'Wsparcie techniczne dla sprzętu i oprogramowania.')
|
||||
->call('saveEditCategory')
|
||||
->assertOk();
|
||||
|
||||
expect($category->fresh()->description)->toBe('Wsparcie techniczne dla sprzętu i oprogramowania.');
|
||||
});
|
||||
|
||||
test('clearing a category description saves it as null, not an empty string', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc', 'description' => 'Stary opis']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'categories')
|
||||
->call('startEditCategory', $category->id, $category->name, $category->description)
|
||||
->set('editingCategoryDescription', ' ')
|
||||
->call('saveEditCategory')
|
||||
->assertOk();
|
||||
|
||||
expect($category->fresh()->description)->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can add a description to a subcategory via the edit dialog', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openSubcategoryEditForm', $sub->id)
|
||||
->set('subcategoryEditForm.description', 'Problemy z połączeniem VPN.')
|
||||
->call('submitSubcategoryEdit')
|
||||
->assertOk();
|
||||
|
||||
expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.');
|
||||
});
|
||||
58
src/tests/Feature/AdminStatusPriorityReorderTest.php
Normal file
58
src/tests/Feature/AdminStatusPriorityReorderTest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can move a status up and down, swapping sort_order with its neighbor', function () {
|
||||
$admin = adminUser();
|
||||
seedStatusesAndPriorities();
|
||||
$extra = Status::query()->create(['key' => 'in_progress', 'label' => 'W trakcie', 'color' => '#000', 'stage' => 'open', 'sort_order' => 4]);
|
||||
// seedStatusesAndPriorities() creates new(1)/open(2)/closed(3), so "in_progress" starts last.
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'statuses')
|
||||
->call('moveStatusUp', 'in_progress')
|
||||
->assertOk();
|
||||
|
||||
expect($extra->fresh()->sort_order)->toBe(3)
|
||||
->and(Status::query()->find('closed')->sort_order)->toBe(4);
|
||||
});
|
||||
|
||||
test('moving the first status up is a no-op', function () {
|
||||
$admin = adminUser();
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('moveStatusUp', 'new')
|
||||
->assertOk();
|
||||
|
||||
expect(Status::query()->find('new')->sort_order)->toBe(1);
|
||||
});
|
||||
|
||||
test('admin can move a priority down, swapping sort_order with its neighbor', function () {
|
||||
$admin = adminUser();
|
||||
seedStatusesAndPriorities();
|
||||
$low = Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#000', 'sort_order' => 2]);
|
||||
// seedStatusesAndPriorities() creates "high" at sort_order 1.
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'priorities')
|
||||
->call('movePriorityDown', 'high')
|
||||
->assertOk();
|
||||
|
||||
expect(Priority::query()->find('high')->sort_order)->toBe(2)
|
||||
->and($low->fresh()->sort_order)->toBe(1);
|
||||
});
|
||||
|
||||
test('moving the last priority down is a no-op', function () {
|
||||
$admin = adminUser();
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('movePriorityDown', 'high')
|
||||
->assertOk();
|
||||
|
||||
expect(Priority::query()->find('high')->sort_order)->toBe(1);
|
||||
});
|
||||
113
src/tests/Feature/AdminTeamManagementTest.php
Normal file
113
src/tests/Feature/AdminTeamManagementTest.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function adminUser(): User
|
||||
{
|
||||
return User::query()->create(['name' => 'Admin', 'email' => 'admin@example.com', 'roles' => ['admin']]);
|
||||
}
|
||||
|
||||
function operatorUser(string $email = 'op1@example.com'): User
|
||||
{
|
||||
return User::query()->create(['name' => 'Operator '.$email, 'email' => $email, 'roles' => ['operator']]);
|
||||
}
|
||||
|
||||
function subcategoryFixture(): Subcategory
|
||||
{
|
||||
$category = Category::query()->create(['name' => 'IT']);
|
||||
|
||||
return $category->subcategories()->create(['name' => 'VPN']);
|
||||
}
|
||||
|
||||
test('admin can create a team with members and subcategories via the multiselect', function () {
|
||||
$admin = adminUser();
|
||||
$op1 = operatorUser('op1@example.com');
|
||||
$op2 = operatorUser('op2@example.com');
|
||||
$sub = subcategoryFixture();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'teams')
|
||||
->call('openTeamForm')
|
||||
->set('teamForm.name', 'Infrastruktura')
|
||||
->call('toggleTeamMember', $op1->id)
|
||||
->call('toggleTeamMember', $op2->id)
|
||||
->call('toggleTeamSubcategory', $sub->id)
|
||||
->call('submitTeam')
|
||||
->assertOk();
|
||||
|
||||
$team = Team::query()->where('name', 'Infrastruktura')->firstOrFail();
|
||||
|
||||
expect($team->members->pluck('id')->sort()->values()->all())->toBe([$op1->id, $op2->id])
|
||||
->and($team->subcategories()->pluck('subcategories.id')->all())->toBe([$sub->id]);
|
||||
});
|
||||
|
||||
test('admin can edit a team, swap out a member and change its subcategories', function () {
|
||||
$admin = adminUser();
|
||||
$op1 = operatorUser('op1@example.com');
|
||||
$op2 = operatorUser('op2@example.com');
|
||||
$subA = subcategoryFixture();
|
||||
$subB = Category::query()->create(['name' => 'Admin'])->subcategories()->create(['name' => 'Wnioski']);
|
||||
|
||||
$team = Team::query()->create(['name' => 'Zespół A']);
|
||||
$team->subcategories()->attach($subA->id);
|
||||
$op1->teams()->attach($team->id);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editTeam', $team->id)
|
||||
->assertSet('teamForm.name', 'Zespół A')
|
||||
->assertSet('teamForm.memberIds', [$op1->id])
|
||||
->set('teamForm.name', 'Zespół A zmieniony')
|
||||
->call('toggleTeamMember', $op1->id) // remove op1
|
||||
->call('toggleTeamMember', $op2->id) // add op2
|
||||
->call('toggleTeamSubcategory', $subA->id) // remove subA
|
||||
->call('toggleTeamSubcategory', $subB->id) // add subB
|
||||
->call('submitTeam')
|
||||
->assertOk();
|
||||
|
||||
$team->refresh();
|
||||
|
||||
expect($team->name)->toBe('Zespół A zmieniony')
|
||||
->and($op1->fresh()->teams->pluck('id')->all())->toBe([])
|
||||
->and($op2->fresh()->teams->pluck('id')->all())->toBe([$team->id])
|
||||
->and($team->subcategories()->pluck('subcategories.id')->all())->toBe([$subB->id]);
|
||||
});
|
||||
|
||||
test('admin can delete a team, which unassigns its members without touching their roles', function () {
|
||||
$admin = adminUser();
|
||||
$op1 = operatorUser('op1@example.com');
|
||||
|
||||
$team = Team::query()->create(['name' => 'Do usunięcia']);
|
||||
$op1->teams()->attach($team->id);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeTeam', $team->id)
|
||||
->assertSet('pendingDeleteType', 'team')
|
||||
->call('confirmPendingDelete')
|
||||
->assertOk();
|
||||
|
||||
expect(Team::query()->find($team->id))->toBeNull()
|
||||
->and($op1->fresh()->teams)->toBeEmpty()
|
||||
->and($op1->fresh()->roles)->toBe(['operator']);
|
||||
});
|
||||
|
||||
test('a user can belong to more than one team at once, and the users list shows all of them', function () {
|
||||
$admin = adminUser();
|
||||
$op1 = operatorUser('op1@example.com');
|
||||
|
||||
$teamA = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$teamB = Team::query()->create(['name' => 'Aplikacje']);
|
||||
|
||||
$op1->teams()->attach([$teamA->id, $teamB->id]);
|
||||
|
||||
expect($op1->fresh()->teams->pluck('name')->sort()->values()->all())->toBe(['Aplikacje', 'Infrastruktura']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'users')
|
||||
->assertSee('Infrastruktura')
|
||||
->assertSee('Aplikacje');
|
||||
});
|
||||
60
src/tests/Feature/AdminUserManagementTest.php
Normal file
60
src/tests/Feature/AdminUserManagementTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can edit a users name, email and roles', function () {
|
||||
$admin = adminUser();
|
||||
$target = User::query()->create(['name' => 'Old Name', 'email' => 'old@example.com', 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editUser', $target->id)
|
||||
->assertSet('userForm.name', 'Old Name')
|
||||
->assertSet('userForm.email', 'old@example.com')
|
||||
->set('userForm.name', 'New Name')
|
||||
->set('userForm.email', 'new@example.com')
|
||||
->call('toggleUserFormRole', 'operator')
|
||||
->call('submitUser')
|
||||
->assertOk();
|
||||
|
||||
$target->refresh();
|
||||
|
||||
expect($target->name)->toBe('New Name')
|
||||
->and($target->email)->toBe('new@example.com')
|
||||
->and($target->roles)->toContain('client', 'operator');
|
||||
});
|
||||
|
||||
test('editing a user does not trip the unique email rule against itself', function () {
|
||||
$admin = adminUser();
|
||||
$target = User::query()->create(['name' => 'Someone', 'email' => 'someone@example.com', 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editUser', $target->id)
|
||||
->set('userForm.name', 'Someone Else')
|
||||
->call('submitUser')
|
||||
->assertHasNoErrors();
|
||||
});
|
||||
|
||||
test('admin can delete another user', function () {
|
||||
$admin = adminUser();
|
||||
$target = User::query()->create(['name' => 'Delete Me', 'email' => 'deleteme@example.com', 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeUser', $target->id)
|
||||
->assertSet('pendingDeleteType', 'user')
|
||||
->call('confirmPendingDelete')
|
||||
->assertOk();
|
||||
|
||||
expect(User::query()->find($target->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('an admin cannot delete their own account', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeUser', $admin->id)
|
||||
->assertSet('pendingDeleteType', null);
|
||||
|
||||
expect(User::query()->find($admin->id))->not->toBeNull();
|
||||
});
|
||||
43
src/tests/Feature/ApiAbilityEnforcementTest.php
Normal file
43
src/tests/Feature/ApiAbilityEnforcementTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
dataset('protected_endpoints', function () {
|
||||
return [
|
||||
'GET tickets' => ['GET', '/api/v1/tickets', 'tickets:read'],
|
||||
'GET categories' => ['GET', '/api/v1/categories', 'dictionaries:read'],
|
||||
'GET users' => ['GET', '/api/v1/users', 'users:read'],
|
||||
];
|
||||
});
|
||||
|
||||
test('a request with no token is unauthenticated', function (string $method, string $uri) {
|
||||
$this->json($method, $uri)->assertUnauthorized();
|
||||
})->with('protected_endpoints');
|
||||
|
||||
test('a token missing the required ability is forbidden', function (string $method, string $uri, string $requiredAbility) {
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['some:other-ability']);
|
||||
|
||||
$this->json($method, $uri)->assertForbidden();
|
||||
})->with('protected_endpoints');
|
||||
|
||||
test('a token with the required ability is allowed', function (string $method, string $uri, string $requiredAbility) {
|
||||
seedStatusesAndPriorities();
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, [$requiredAbility]);
|
||||
|
||||
$this->json($method, $uri)->assertOk();
|
||||
})->with('protected_endpoints');
|
||||
|
||||
test('a token whose underlying access token has been deleted is rejected', function () {
|
||||
$client = ApiClient::factory()->create();
|
||||
$token = $client->createToken('test', ['tickets:read']);
|
||||
$plaintext = $token->plainTextToken;
|
||||
|
||||
$client->tokens()->delete();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$plaintext)
|
||||
->getJson('/api/v1/tickets')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
79
src/tests/Feature/ApiKeyManagementTest.php
Normal file
79
src/tests/Feature/ApiKeyManagementTest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\ApiKeys;
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('a non-admin cannot reach the admin panel where API keys are managed', function () {
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
|
||||
$this->actingAs($operator)->get('/admin')->assertForbidden();
|
||||
});
|
||||
|
||||
test('admin creates an api key and sees the plaintext token exactly once', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(ApiKeys::class)
|
||||
->call('openForm')
|
||||
->set('form.name', 'Monitoring integracja')
|
||||
->set('form.description', 'Tworzy zgłoszenia z systemu monitoringu')
|
||||
->set('form.abilities', ['tickets:read', 'tickets:write'])
|
||||
->call('submit')
|
||||
->assertOk();
|
||||
|
||||
$client = ApiClient::query()->where('name', 'Monitoring integracja')->firstOrFail();
|
||||
|
||||
expect($client->created_by)->toBe($admin->id);
|
||||
expect($client->tokens()->count())->toBe(1);
|
||||
expect($client->tokens()->first()->abilities)->toBe(['tickets:read', 'tickets:write']);
|
||||
|
||||
$component->assertSet('newTokenPlaintext', fn ($value) => is_string($value) && str_contains($value, '|'));
|
||||
|
||||
// Re-rendering (e.g. a page refresh) must not resurrect the plaintext secret.
|
||||
$fresh = Livewire::actingAs($admin)->test(ApiKeys::class);
|
||||
$fresh->assertSet('newTokenPlaintext', null);
|
||||
});
|
||||
|
||||
test('creating a key requires a name and at least one ability', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(ApiKeys::class)
|
||||
->call('openForm')
|
||||
->set('form.name', '')
|
||||
->set('form.abilities', [])
|
||||
->call('submit')
|
||||
->assertHasErrors(['form.name', 'form.abilities']);
|
||||
});
|
||||
|
||||
test('admin revokes a key and its token stops working', function () {
|
||||
$admin = adminUser();
|
||||
$apiClient = ApiClient::factory()->create();
|
||||
$token = $apiClient->createToken('test', ['tickets:read']);
|
||||
$plaintext = $token->plainTextToken;
|
||||
|
||||
Livewire::actingAs($admin)->test(ApiKeys::class)
|
||||
->call('revoke', $apiClient->id)
|
||||
->assertOk();
|
||||
|
||||
$apiClient->refresh();
|
||||
expect($apiClient->isRevoked())->toBeTrue();
|
||||
expect($apiClient->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('admin regenerates a key, keeping its abilities but issuing a new token', function () {
|
||||
$admin = adminUser();
|
||||
$apiClient = ApiClient::factory()->create();
|
||||
$oldToken = $apiClient->createToken('test', ['tickets:read', 'users:read']);
|
||||
$oldTokenId = $oldToken->accessToken->id;
|
||||
|
||||
Livewire::actingAs($admin)->test(ApiKeys::class)
|
||||
->call('regenerate', $apiClient->id)
|
||||
->assertOk();
|
||||
|
||||
$apiClient->refresh();
|
||||
expect($apiClient->isRevoked())->toBeFalse();
|
||||
expect($apiClient->tokens()->count())->toBe(1);
|
||||
expect($apiClient->tokens()->first()->abilities)->toBe(['tickets:read', 'users:read']);
|
||||
expect($apiClient->tokens()->first()->id)->not->toBe($oldTokenId);
|
||||
});
|
||||
123
src/tests/Feature/AttachmentLimitsTest.php
Normal file
123
src/tests/Feature/AttachmentLimitsTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\NewTicket as ClientNewTicket;
|
||||
use App\Livewire\Client\TicketShow as ClientTicketShow;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('Settings::validateAttachment rejects a file over the configured size limit', function () {
|
||||
Settings::set('attachment_max_size_kb', '100');
|
||||
|
||||
$file = UploadedFile::fake()->create('big.txt', 200); // 200 KB > 100 KB limit
|
||||
|
||||
expect(Settings::validateAttachment($file))->toContain('za duży');
|
||||
});
|
||||
|
||||
test('Settings::validateAttachment rejects a disallowed file extension', function () {
|
||||
Settings::set('attachment_max_size_kb', '10240');
|
||||
Settings::set('attachment_allowed_types', 'jpg,png,pdf');
|
||||
|
||||
$file = UploadedFile::fake()->create('script.exe', 10);
|
||||
|
||||
expect(Settings::validateAttachment($file))->toContain('Niedozwolony typ');
|
||||
});
|
||||
|
||||
test('Settings::validateAttachment passes a file within size and an allowed extension', function () {
|
||||
Settings::set('attachment_max_size_kb', '10240');
|
||||
Settings::set('attachment_allowed_types', 'jpg,png,pdf');
|
||||
|
||||
$file = UploadedFile::fake()->create('photo.jpg', 500);
|
||||
|
||||
expect(Settings::validateAttachment($file))->toBeNull();
|
||||
});
|
||||
|
||||
test('an empty allowed-types setting means no extension restriction, only size', function () {
|
||||
Settings::set('attachment_max_size_kb', '10240');
|
||||
Settings::set('attachment_allowed_types', '');
|
||||
|
||||
$file = UploadedFile::fake()->create('whatever.xyz', 10);
|
||||
|
||||
expect(Settings::validateAttachment($file))->toBeNull();
|
||||
});
|
||||
|
||||
test('an oversized attachment on a new client ticket is rejected client-side, resets the picker, and the ticket still submits without it', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('attachment_max_size_kb', '50');
|
||||
|
||||
$client = User::query()->create(['name' => 'Ola', 'email' => 'ola-attach@example.com', 'roles' => ['client']]);
|
||||
$category = Category::query()->create(['name' => 'IT']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$bigFile = UploadedFile::fake()->create('too-big.pdf', 500);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientNewTicket::class)
|
||||
->call('selectCategory', $category->id)
|
||||
->call('selectSubcategory', $sub->id)
|
||||
->set('subject', 'Temat')
|
||||
->set('body', 'Opis')
|
||||
->set('attachments', [$bigFile])
|
||||
->assertHasErrors(['attachments'])
|
||||
->assertSet('attachments', []);
|
||||
});
|
||||
|
||||
test('an attachment of a disallowed type on a client reply is rejected and never reaches the ticket', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('attachment_allowed_types', 'jpg,png,pdf');
|
||||
|
||||
$client = User::query()->create(['name' => 'Ewa', 'email' => 'ewa-attach2@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
$badFile = UploadedFile::fake()->create('malware.exe', 10);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Oto plik.')
|
||||
->set('attachments', [$badFile])
|
||||
->assertHasErrors(['attachments'])
|
||||
->assertSet('attachments', [])
|
||||
->call('sendReply');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'Oto plik.')->firstOrFail();
|
||||
expect($message->attachments)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Settings::validateAttachments rejects a batch over the configured max file count', function () {
|
||||
Settings::set('attachment_max_count', '2');
|
||||
|
||||
$files = [
|
||||
UploadedFile::fake()->create('one.pdf', 10),
|
||||
UploadedFile::fake()->create('two.pdf', 10),
|
||||
UploadedFile::fake()->create('three.pdf', 10),
|
||||
];
|
||||
|
||||
expect(Settings::validateAttachments($files))->toContain('maksymalnie 2');
|
||||
});
|
||||
|
||||
test('Settings::validateAttachments rejects a batch over the configured max combined size', function () {
|
||||
Settings::set('attachment_max_count', '10');
|
||||
Settings::set('attachment_max_total_size_kb', '100');
|
||||
|
||||
$files = [
|
||||
UploadedFile::fake()->create('one.pdf', 60),
|
||||
UploadedFile::fake()->create('two.pdf', 60),
|
||||
];
|
||||
|
||||
expect(Settings::validateAttachments($files))->toContain('Łączny rozmiar');
|
||||
});
|
||||
|
||||
test('Settings::validateAttachments passes a batch within count and size limits', function () {
|
||||
Settings::set('attachment_max_count', '5');
|
||||
Settings::set('attachment_max_total_size_kb', '10240');
|
||||
Settings::set('attachment_max_size_kb', '10240');
|
||||
Settings::set('attachment_allowed_types', '');
|
||||
|
||||
$files = [
|
||||
UploadedFile::fake()->create('one.pdf', 10),
|
||||
UploadedFile::fake()->create('two.pdf', 10),
|
||||
];
|
||||
|
||||
expect(Settings::validateAttachments($files))->toBeNull();
|
||||
});
|
||||
61
src/tests/Feature/DictionariesApiTest.php
Normal file
61
src/tests/Feature/DictionariesApiTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
test('categories are returned with subcategories and custom fields', function () {
|
||||
$category = Category::query()->create(['name' => 'IT']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['dictionaries:read']);
|
||||
|
||||
$this->getJson('/api/v1/categories')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.name', 'IT')
|
||||
->assertJsonPath('data.0.subcategories.0.name', 'VPN');
|
||||
});
|
||||
|
||||
test('statuses are returned ordered by sort_order', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['dictionaries:read']);
|
||||
|
||||
$this->getJson('/api/v1/statuses')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.key', 'new');
|
||||
});
|
||||
|
||||
test('priorities are returned ordered by sort_order', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['dictionaries:read']);
|
||||
|
||||
$this->getJson('/api/v1/priorities')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.key', 'high');
|
||||
});
|
||||
|
||||
test('teams are returned', function () {
|
||||
Team::query()->create(['name' => 'Infra']);
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['dictionaries:read']);
|
||||
|
||||
$this->getJson('/api/v1/teams')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.name', 'Infra');
|
||||
});
|
||||
|
||||
test('dictionaries require the dictionaries:read ability', function () {
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:read']);
|
||||
|
||||
$this->getJson('/api/v1/categories')->assertForbidden();
|
||||
});
|
||||
94
src/tests/Feature/EmailLayoutTest.php
Normal file
94
src/tests/Feature/EmailLayoutTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('Settings::renderEmailLayout always wraps content in the fixed box layout, ignoring any stored override', function () {
|
||||
// The box is no longer admin-editable — a leftover/stale stored value
|
||||
// (e.g. from before this UI was removed) must not be used.
|
||||
Settings::set('email_layout_html', '<div class="should-be-ignored">{tresc}</div>');
|
||||
Settings::set('email_footer', 'Pozdrawiamy, Wsparcie');
|
||||
Settings::set('company_name', 'Acme');
|
||||
|
||||
$html = Settings::renderEmailLayout('<p>Treść zgłoszenia</p>');
|
||||
|
||||
expect($html)->not->toContain('should-be-ignored')
|
||||
->and($html)->toBe(strtr(Settings::default('email_layout_html'), [
|
||||
'{tresc}' => '<p>Treść zgłoszenia</p>',
|
||||
'{stopka}' => 'Pozdrawiamy, Wsparcie',
|
||||
'{firma}' => 'Acme',
|
||||
]));
|
||||
});
|
||||
|
||||
test('the default footer is not empty and its own {firma} placeholder resolves through the layout', function () {
|
||||
Settings::set('company_name', 'Acme');
|
||||
|
||||
$html = Settings::renderEmailLayout('<p>Treść</p>');
|
||||
|
||||
expect(Settings::default('email_footer'))->not->toBeEmpty()
|
||||
->and($html)->toContain('Acme')
|
||||
->and($html)->not->toContain('{firma}');
|
||||
});
|
||||
|
||||
test('admin can reset the email footer back to its default, remounting the editor with a fresh key', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('saveEmailFooter', 'Coś innego')
|
||||
->assertSet('emailFooterVersion', 0);
|
||||
|
||||
$component->call('resetEmailFooter')
|
||||
->assertOk()
|
||||
->assertSet('emailFooterHtml', Settings::default('email_footer'))
|
||||
->assertSet('emailFooterVersion', 1)
|
||||
->assertSee('email-footer-1', escape: false);
|
||||
|
||||
expect(Settings::get('email_footer'))->toBe(Settings::default('email_footer'));
|
||||
});
|
||||
|
||||
test('admin can save the email footer from the Szablony e-mail tab (moved out of Konfiguracja)', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('saveEmailFooter', '<p>Pozdrawiamy, Zespół Wsparcia</p>')
|
||||
->assertOk()
|
||||
->assertSet('emailFooterHtml', '<p>Pozdrawiamy, Zespół Wsparcia</p>');
|
||||
|
||||
expect(Settings::get('email_footer'))->toBe('<p>Pozdrawiamy, Zespół Wsparcia</p>');
|
||||
});
|
||||
|
||||
test('the live example preview reflects the currently saved footer', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('saveEmailFooter', 'Stopka na żywo');
|
||||
|
||||
expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo')
|
||||
->and($component->instance()->emailPreviewHtml)->toContain('#1234');
|
||||
});
|
||||
|
||||
test('there is no more editor for the box layout itself, only for its footer', function () {
|
||||
expect(method_exists(Panel::class, 'saveEmailLayout'))->toBeFalse()
|
||||
->and(method_exists(Panel::class, 'resetEmailLayout'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a rendered ticket notification embeds its template body inside the fixed box layout', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-layout-test', 'name' => 'x', 'trigger_label' => 'x',
|
||||
'subject' => 'S', 'body' => '<p>Zgłoszenie #{numer} zostało utworzone.</p>',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$mail = (new TicketNotification($ticket, $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
|
||||
expect($mail->viewData['html'])->toBe(Settings::renderEmailLayout('<p>Zgłoszenie #'.$ticket->number.' zostało utworzone.</p>'))
|
||||
->and($mail->view)->toBe('emails.ticket-notification');
|
||||
});
|
||||
80
src/tests/Feature/EmailNotificationsAdminTest.php
Normal file
80
src/tests/Feature/EmailNotificationsAdminTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
|
||||
$admin = adminUser();
|
||||
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('toggleNotificationEnabled', $statusChanged->id)
|
||||
->assertOk();
|
||||
|
||||
expect($statusChanged->fresh()->enabled)->toBeFalse();
|
||||
|
||||
expect(method_exists(Panel::class, 'openEmailTemplateForm'))->toBeFalse()
|
||||
->and(method_exists(Panel::class, 'submitEmailTemplateForm'))->toBeFalse()
|
||||
->and(method_exists(Panel::class, 'removeEmailTemplate'))->toBeFalse()
|
||||
->and(method_exists(Panel::class, 'setNotificationTemplate'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin can edit the subject and body of a trigger\'s fixed template', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
// A bare migrated (unseeded) database has no email_templates rows yet, so
|
||||
// ticket_created's binding is still null at this point — give it one.
|
||||
$template = EmailTemplate::query()->create(['key' => 'tpl-created-test', 'name' => 'Nowe', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B']);
|
||||
$ticketCreated = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail();
|
||||
$ticketCreated->update(['email_template_id' => $template->id]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('editEmailTemplate', $ticketCreated->email_template_id)
|
||||
->call('setTemplateSubject', 'Nowy temat #{numer}')
|
||||
->call('setTemplateBody', 'Nowa treść {imie}')
|
||||
->call('closeEmailTemplateEditor')
|
||||
->assertSet('editingTemplateId', null)
|
||||
->assertOk();
|
||||
|
||||
$template = EmailTemplate::query()->find($ticketCreated->email_template_id);
|
||||
expect($template->subject)->toBe('Nowy temat #{numer}')
|
||||
->and($template->body)->toBe('Nowa treść {imie}');
|
||||
});
|
||||
|
||||
test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',
|
||||
'subject' => 'Zgloszenie #{numer}', 'body' => 'Podglad: {link}',
|
||||
]);
|
||||
NotificationSetting::query()->where('trigger_key', 'ticket_created')->update(['email_template_id' => $template->id]);
|
||||
NotificationSetting::query()->where('trigger_key', 'status_changed')->update(['enabled' => false]);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'client-notify@example.com',
|
||||
'subject' => 'Problem testowy',
|
||||
'body' => 'Opis problemu.',
|
||||
], null);
|
||||
|
||||
Notification::assertSentOnDemand(
|
||||
TicketNotification::class,
|
||||
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'client-notify@example.com'
|
||||
);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'in_progress');
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
|
||||
$mail = (new TicketNotification($ticket->fresh(), $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
expect($mail->subject)->toBe('Zgloszenie #'.$ticket->number)
|
||||
->and($mail->viewData['html'])->toContain(route('client.ticket', $ticket));
|
||||
});
|
||||
7
src/tests/Feature/ExampleTest.php
Normal file
7
src/tests/Feature/ExampleTest.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
test('the application returns a successful response', function () {
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
123
src/tests/Feature/ExtendedNotificationTriggersTest.php
Normal file
123
src/tests/Feature/ExtendedNotificationTriggersTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('the 6 extended triggers exist and are disabled by default, alongside the 2 enabled originals', function () {
|
||||
$settings = NotificationSetting::query()->get()->keyBy('trigger_key');
|
||||
|
||||
expect($settings->keys()->sort()->values()->all())->toBe([
|
||||
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
|
||||
'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created',
|
||||
]);
|
||||
|
||||
foreach (['ticket_created', 'status_changed'] as $key) {
|
||||
expect($settings[$key]->enabled)->toBeTrue();
|
||||
}
|
||||
|
||||
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'ticket_closed', 'operator_replied', 'sla_breached'] as $key) {
|
||||
expect($settings[$key]->enabled)->toBeFalse()
|
||||
->and($settings[$key]->email_template_id)->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('a disabled-by-default trigger sends nothing until an admin turns it on', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
Notification::assertNothingSent();
|
||||
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
$operator = User::query()->create(['name' => 'Jan Kowalski', 'email' => 'jan@example.com', 'roles' => ['operator']]);
|
||||
|
||||
app(TicketService::class)->setAssignee($ticket, $operator);
|
||||
|
||||
$templateId = NotificationSetting::query()->where('trigger_key', 'assignee_changed')->value('email_template_id');
|
||||
$mail = (new TicketNotification($ticket->fresh(), $templateId))->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
expect($mail->viewData['html'])->toContain('Jan Kowalski');
|
||||
});
|
||||
|
||||
test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
$team = Team::query()->create(['name' => 'IT']);
|
||||
|
||||
app(TicketService::class)->setTeam($ticket, $team);
|
||||
|
||||
$templateId = NotificationSetting::query()->where('trigger_key', 'team_changed')->value('email_template_id');
|
||||
$mail = (new TicketNotification($ticket->fresh(), $templateId))->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
expect($mail->viewData['html'])->toContain('IT');
|
||||
});
|
||||
|
||||
test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->updateDetails($ticket, [
|
||||
'subject' => $ticket->subject, 'body' => $ticket->body, 'subcategory_id' => $sub->id, 'custom_values' => [],
|
||||
]);
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
|
||||
app(TicketService::class)->updateDetails($ticket->fresh(), [
|
||||
'subject' => 'Nowy temat', 'body' => $ticket->body, 'subcategory_id' => $sub->id, 'custom_values' => [],
|
||||
]);
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('closing a ticket fires both status_changed and ticket_closed', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
|
||||
|
||||
// status_changed ships enabled by default, but in a bare migrated (unseeded)
|
||||
// database it has no template assigned yet — give it one so both triggers
|
||||
// actually have something to send, isolating this test from seeding order.
|
||||
$statusTemplate = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
NotificationSetting::query()->where('trigger_key', 'status_changed')->update(['email_template_id' => $statusTemplate->id]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setStatus($ticket, 'closed');
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 2);
|
||||
});
|
||||
|
||||
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
|
||||
$ticket = makeTicket();
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]);
|
||||
|
||||
app(TicketService::class)->operatorReply($ticket, $operator, 'Sprawdzam temat.');
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
});
|
||||
106
src/tests/Feature/LdapAccountCreationTogglesTest.php
Normal file
106
src/tests/Feature/LdapAccountCreationTogglesTest.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
use Livewire\Livewire;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
});
|
||||
|
||||
test('disabling ldap_auto_provision_guests keeps a guest ticket anonymous even when the e-mail matches an LDAP entry', function () {
|
||||
DirectoryEmulator::setup();
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('ldap_auto_provision_guests', '0');
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'znany.klient',
|
||||
'cn' => 'Znany Klient',
|
||||
'mail' => 'znany.klient@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'znany.klient@firma.pl',
|
||||
'subject' => 'Zgłoszenie',
|
||||
'body' => 'Opis.',
|
||||
], null);
|
||||
|
||||
expect($ticket->customer_id)->toBeNull()
|
||||
->and(User::query()->where('email', 'znany.klient@firma.pl')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('leaving ldap_auto_provision_guests enabled (the default) still auto-provisions as before', function () {
|
||||
DirectoryEmulator::setup();
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'znany.klient2',
|
||||
'cn' => 'Znany Klient Dwa',
|
||||
'mail' => 'znany.klient2@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'znany.klient2@firma.pl',
|
||||
'subject' => 'Zgłoszenie',
|
||||
'body' => 'Opis.',
|
||||
], null);
|
||||
|
||||
expect($ticket->customer_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('restricting manual user creation to LDAP rejects an invite for an unknown e-mail', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_user_creation_to_ldap', '1');
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openUserForm')
|
||||
->set('userForm.name', 'Ktoś Spoza')
|
||||
->set('userForm.email', 'nieznany@firma.pl')
|
||||
->call('submitUser')
|
||||
->assertHasErrors(['userForm.email']);
|
||||
|
||||
expect(User::query()->where('email', 'nieznany@firma.pl')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('restricting manual user creation to LDAP still allows inviting an e-mail that exists in LDAP', function () {
|
||||
DirectoryEmulator::setup();
|
||||
Settings::set('restrict_user_creation_to_ldap', '1');
|
||||
$admin = adminUser();
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'znany.pracownik',
|
||||
'cn' => 'Znany Pracownik',
|
||||
'mail' => 'znany.pracownik@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openUserForm')
|
||||
->set('userForm.name', 'Znany Pracownik')
|
||||
->set('userForm.email', 'znany.pracownik@firma.pl')
|
||||
->call('submitUser')
|
||||
->assertOk();
|
||||
|
||||
expect(User::query()->where('email', 'znany.pracownik@firma.pl')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('leaving the LDAP restriction off (the default) allows inviting any e-mail', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openUserForm')
|
||||
->set('userForm.name', 'Ktokolwiek')
|
||||
->set('userForm.email', 'ktokolwiek@example.com')
|
||||
->call('submitUser')
|
||||
->assertOk();
|
||||
|
||||
expect(User::query()->where('email', 'ktokolwiek@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
65
src/tests/Feature/LdapLoginTest.php
Normal file
65
src/tests/Feature/LdapLoginTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
});
|
||||
|
||||
test('an ldap login binds to an admin-invited placeholder user by email instead of duplicating it', function () {
|
||||
$fake = DirectoryEmulator::setup();
|
||||
|
||||
$placeholder = User::query()->create([
|
||||
'name' => 'Placeholder Name',
|
||||
'email' => 'anna.kowalska@firma.pl',
|
||||
'roles' => ['operator', 'admin'],
|
||||
]);
|
||||
|
||||
$ldapUser = LldapUser::create([
|
||||
'uid' => 'anna.kowalska',
|
||||
'cn' => 'Anna Kowalska',
|
||||
'mail' => 'anna.kowalska@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$fake->actingAs($ldapUser);
|
||||
|
||||
expect(Auth::attempt(['uid' => 'anna.kowalska', 'password' => 'whatever']))->toBeTrue();
|
||||
|
||||
expect(User::count())->toBe(1);
|
||||
|
||||
$placeholder->refresh();
|
||||
expect($placeholder->name)->toBe('Anna Kowalska')
|
||||
->and($placeholder->roles)->toEqualCanonicalizing(['operator', 'admin']) // roles assigned by admin are preserved, not overwritten by sync
|
||||
->and($placeholder->guid)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a brand new ldap identity is provisioned locally with the client role by default', function () {
|
||||
$fake = DirectoryEmulator::setup();
|
||||
|
||||
$ldapUser = LldapUser::create([
|
||||
'uid' => 'new.person',
|
||||
'cn' => 'New Person',
|
||||
'mail' => 'new.person@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$fake->actingAs($ldapUser);
|
||||
|
||||
expect(Auth::attempt(['uid' => 'new.person', 'password' => 'whatever']))->toBeTrue();
|
||||
|
||||
$user = User::query()->where('email', 'new.person@firma.pl')->first();
|
||||
|
||||
expect($user)->not->toBeNull()
|
||||
->and($user->roles)->toBe(['client']);
|
||||
});
|
||||
|
||||
test('an unknown username does not authenticate', function () {
|
||||
DirectoryEmulator::setup();
|
||||
|
||||
expect(Auth::attempt(['uid' => 'someone.else', 'password' => 'whatever']))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Landing;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
use Livewire\Livewire;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
});
|
||||
|
||||
test('restricting guest tickets to LDAP rejects a submission from an unknown e-mail with a not-found message', function () {
|
||||
DirectoryEmulator::setup();
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
Livewire::test(Landing::class)
|
||||
->set('email', 'nieznany@firma.pl')
|
||||
->set('subject', 'Temat')
|
||||
->set('body', 'Treść')
|
||||
->call('submit')
|
||||
->assertHasErrors(['email'])
|
||||
->assertSee('Nie znaleziono użytkownika o podanym adresie e-mail.');
|
||||
|
||||
expect(Ticket::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('restricting guest tickets to LDAP still allows a submission from an e-mail that exists in LDAP', function () {
|
||||
DirectoryEmulator::setup();
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'znany.gosc',
|
||||
'cn' => 'Znany Gość',
|
||||
'mail' => 'znany.gosc@firma.pl',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
Livewire::test(Landing::class)
|
||||
->set('email', 'znany.gosc@firma.pl')
|
||||
->set('subject', 'Temat')
|
||||
->set('body', 'Treść')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Ticket::query()->where('email', 'znany.gosc@firma.pl')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('restricting guest tickets to LDAP still allows an already-known local account even if LDAP lookup fails', function () {
|
||||
DirectoryEmulator::setup();
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('restrict_tickets_to_ldap', '1');
|
||||
|
||||
User::query()->create(['name' => 'Istniejący Klient', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]);
|
||||
|
||||
Livewire::test(Landing::class)
|
||||
->set('email', 'istniejacy@firma.pl')
|
||||
->set('subject', 'Temat')
|
||||
->set('body', 'Treść')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Ticket::query()->where('email', 'istniejacy@firma.pl')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('leaving the restriction off (the default) allows any guest e-mail to file a ticket', function () {
|
||||
DirectoryEmulator::setup();
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
Livewire::test(Landing::class)
|
||||
->set('email', 'ktokolwiek@example.com')
|
||||
->set('subject', 'Temat')
|
||||
->set('body', 'Treść')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Ticket::query()->where('email', 'ktokolwiek@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('admin can create a local user with a password who can then log in without an LDAP entry', function () {
|
||||
DirectoryEmulator::setup();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openUserForm')
|
||||
->set('userForm.name', 'Konto Lokalne')
|
||||
->set('userForm.email', 'lokalny@firma.pl')
|
||||
->set('userForm.password', 'sekretne-haslo')
|
||||
->set('userForm.password_confirmation', 'sekretne-haslo')
|
||||
->call('submitUser')
|
||||
->assertOk();
|
||||
|
||||
$user = User::query()->where('email', 'lokalny@firma.pl')->firstOrFail();
|
||||
expect($user->password)->not->toBeNull();
|
||||
|
||||
$ok = Auth::attempt([
|
||||
'uid' => 'lokalny@firma.pl',
|
||||
'password' => 'sekretne-haslo',
|
||||
'fallback' => ['email' => 'lokalny@firma.pl'],
|
||||
]);
|
||||
|
||||
expect($ok)->toBeTrue()
|
||||
->and(Auth::id())->toBe($user->id);
|
||||
});
|
||||
|
||||
test('a mismatched password confirmation is rejected when creating a local user', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openUserForm')
|
||||
->set('userForm.name', 'Konto Lokalne')
|
||||
->set('userForm.email', 'zle-haslo@firma.pl')
|
||||
->set('userForm.password', 'sekretne-haslo')
|
||||
->set('userForm.password_confirmation', 'inne-haslo')
|
||||
->call('submitUser')
|
||||
->assertHasErrors(['userForm.password']);
|
||||
|
||||
expect(User::query()->where('email', 'zle-haslo@firma.pl')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('leaving the password blank keeps a user LDAP-only, as before', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openUserForm')
|
||||
->set('userForm.name', 'Tylko LDAP')
|
||||
->set('userForm.email', 'tylko-ldap@firma.pl')
|
||||
->call('submitUser')
|
||||
->assertOk();
|
||||
|
||||
$user = User::query()->where('email', 'tylko-ldap@firma.pl')->firstOrFail();
|
||||
expect($user->password)->toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Exercises Login::safeRedirectTarget() directly via reflection rather than
|
||||
* through a full ->call('submit') — Livewire's test harness disables the
|
||||
* middleware stack (see RequestBroker::temporarilyDisableExceptionHandlingAndMiddleware),
|
||||
* so no session is ever bound to the request during a component test, and
|
||||
* submit() unconditionally touches the session to regenerate it. The
|
||||
* open-redirect guard itself has no such dependency, so it's tested in isolation.
|
||||
*/
|
||||
function safeRedirectTargetFor(?string $redirect): ?string
|
||||
{
|
||||
$login = new Login;
|
||||
$ref = new ReflectionClass($login);
|
||||
$prop = $ref->getProperty('redirect');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($login, $redirect);
|
||||
|
||||
$method = $ref->getMethod('safeRedirectTarget');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($login);
|
||||
}
|
||||
|
||||
test('a same-app ?redirect= path is honored', function () {
|
||||
expect(safeRedirectTargetFor('/client/tickets/42'))->toBe('/client/tickets/42');
|
||||
});
|
||||
|
||||
test('an external ?redirect= target is rejected to prevent an open redirect', function () {
|
||||
expect(safeRedirectTargetFor('https://evil.example.com/'))->toBeNull()
|
||||
->and(safeRedirectTargetFor('//evil.example.com/'))->toBeNull()
|
||||
->and(safeRedirectTargetFor(null))->toBeNull();
|
||||
});
|
||||
111
src/tests/Feature/MailSmtpConfigTest.php
Normal file
111
src/tests/Feature/MailSmtpConfigTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.fromName', 'Zespół Wsparcia')
|
||||
->set('mailConfig.smtpEnabled', true)
|
||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||
->set('mailConfig.smtpPort', '587')
|
||||
->set('mailConfig.smtpUsername', 'no-reply')
|
||||
->set('mailConfig.smtpPassword', 'sekret123')
|
||||
->set('mailConfig.smtpEncryption', 'tls')
|
||||
->call('saveMailConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('mail_from_address'))->toBe('wsparcie@firma.pl')
|
||||
->and(Settings::get('mail_from_name'))->toBe('Zespół Wsparcia')
|
||||
->and(Settings::bool('mail_smtp_enabled'))->toBeTrue()
|
||||
->and(Settings::get('mail_smtp_host'))->toBe('smtp.firma.pl')
|
||||
->and(Settings::get('mail_smtp_password'))->toBe('sekret123');
|
||||
|
||||
// Saving again with a blank password field must not wipe the stored one.
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||
->set('mailConfig.smtpPassword', '')
|
||||
->call('saveMailConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('mail_smtp_password'))->toBe('sekret123');
|
||||
});
|
||||
|
||||
test('the SMTP test button reports an error without a host/from address, and success once mail is faked and both are set', function () {
|
||||
Mail::fake();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('testMailConnection')
|
||||
->assertSet('mailTestResult', 'error');
|
||||
|
||||
// Mail::fake()'s raw() is a no-op that never throws, so a valid config
|
||||
// reports success — this exercises the same config-override/restore path
|
||||
// real sends use, without needing a reachable SMTP server in tests.
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||
->call('testMailConnection')
|
||||
->assertSet('mailTestResult', 'ok');
|
||||
});
|
||||
|
||||
test('the configured footer is appended to every rendered notification', function () {
|
||||
Settings::set('email_footer', 'Stopka testowa.');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-footer-test', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'Treść wiadomości.',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$mail = (new TicketNotification($ticket, $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
|
||||
expect($mail->viewData['html'])->toContain('Treść wiadomości.')
|
||||
->and($mail->viewData['html'])->toContain('Stopka testowa.');
|
||||
});
|
||||
|
||||
test('an empty footer setting adds nothing extra to the notification', function () {
|
||||
Settings::set('email_footer', '');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-footer-test-2', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'Treść wiadomości.',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$mail = (new TicketNotification($ticket, $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]);
|
||||
|
||||
expect($mail->viewData['html'])->toContain('Treść wiadomości.');
|
||||
});
|
||||
|
||||
test('AppServiceProvider overrides the mail config from settings only when SMTP is enabled', function () {
|
||||
Settings::set('mail_smtp_enabled', '0');
|
||||
Settings::set('mail_smtp_host', 'smtp.disabled.example');
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
expect(config('mail.default'))->not->toBe('smtp');
|
||||
|
||||
Settings::set('mail_smtp_enabled', '1');
|
||||
Settings::set('mail_smtp_host', 'smtp.enabled.example');
|
||||
Settings::set('mail_smtp_port', '2525');
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
|
||||
expect(config('mail.default'))->toBe('smtp')
|
||||
->and(config('mail.mailers.smtp.host'))->toBe('smtp.enabled.example')
|
||||
->and(config('mail.mailers.smtp.port'))->toBe(2525);
|
||||
});
|
||||
|
||||
test('AppServiceProvider always applies the from-address override regardless of SMTP toggle', function () {
|
||||
Settings::set('mail_smtp_enabled', '0');
|
||||
Settings::set('mail_from_address', 'wsparcie@firma.pl');
|
||||
Settings::set('mail_from_name', 'Wsparcie');
|
||||
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
|
||||
expect(config('mail.from.address'))->toBe('wsparcie@firma.pl')
|
||||
->and(config('mail.from.name'))->toBe('Wsparcie');
|
||||
});
|
||||
145
src/tests/Feature/MessageAttachmentsTest.php
Normal file
145
src/tests/Feature/MessageAttachmentsTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\TicketShow as ClientTicketShow;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('a client can attach a file to a reply, and it shows up on that message', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
$file = UploadedFile::fake()->create('zrzut-ekranu.png', 200, 'image/png');
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Oto zrzut ekranu problemu.')
|
||||
->set('attachments', [$file])
|
||||
->call('sendReply')
|
||||
->assertSee('zrzut-ekranu.png');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'Oto zrzut ekranu problemu.')->firstOrFail();
|
||||
expect($message->attachments)->toHaveCount(1);
|
||||
Storage::disk('public')->assertExists($message->attachments->first()->path);
|
||||
});
|
||||
|
||||
test('a client can attach multiple files to a single reply', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna-multi@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
$files = [
|
||||
UploadedFile::fake()->create('zrzut-1.png', 100, 'image/png'),
|
||||
UploadedFile::fake()->create('zrzut-2.png', 100, 'image/png'),
|
||||
];
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Dwa zrzuty ekranu.')
|
||||
->set('attachments', $files)
|
||||
->call('sendReply')
|
||||
->assertSee('zrzut-1.png')
|
||||
->assertSee('zrzut-2.png');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'Dwa zrzuty ekranu.')->firstOrFail();
|
||||
expect($message->attachments)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('an operator can attach a file to a public reply', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('op-attach@example.com');
|
||||
$ticket = makeTicket();
|
||||
$file = UploadedFile::fake()->create('instrukcja.pdf', 100, 'application/pdf');
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'W załączniku instrukcja.')
|
||||
->set('replyAttachments', [$file])
|
||||
->call('sendReply')
|
||||
->assertSee('instrukcja.pdf');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'W załączniku instrukcja.')->firstOrFail();
|
||||
expect($message->attachments)->toHaveCount(1)
|
||||
->and($message->attachments->first()->original_name)->toBe('instrukcja.pdf');
|
||||
});
|
||||
|
||||
test('an operator can attach multiple files to an internal note', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('op-note-attach@example.com');
|
||||
$ticket = makeTicket();
|
||||
$files = [
|
||||
UploadedFile::fake()->create('log.txt', 10, 'text/plain'),
|
||||
UploadedFile::fake()->create('log2.txt', 10, 'text/plain'),
|
||||
];
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('startAddNote')
|
||||
->set('noteDraft', 'Logi z serwera w załączniku.')
|
||||
->set('noteAttachments', $files)
|
||||
->call('addNote')
|
||||
->assertSee('log.txt')
|
||||
->assertSee('log2.txt');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'Logi z serwera w załączniku.')->firstOrFail();
|
||||
expect($message->internal)->toBeTrue()
|
||||
->and($message->attachments)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('attachments are silently skipped when the admin has disabled them', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
Settings::set('allow_attachments', '0');
|
||||
|
||||
$operator = operatorUser('op-attach-disabled@example.com');
|
||||
$ticket = makeTicket();
|
||||
$file = UploadedFile::fake()->create('plik.txt', 10);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Odpowiedź bez załącznika.')
|
||||
->set('replyAttachments', [$file])
|
||||
->call('sendReply');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'Odpowiedź bez załącznika.')->firstOrFail();
|
||||
expect($message->attachments)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a client can remove a picked attachment before sending, via the same method the "x" button uses', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$client = User::query()->create(['name' => 'Ewa Zielińska', 'email' => 'ewa@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
$file = UploadedFile::fake()->create('niechciany.txt', 5);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Jednak bez pliku.')
|
||||
->set('attachments', [$file])
|
||||
->call('removeAttachment', 0) // mirrors the "x" button's wire:click="removeAttachment(0)"
|
||||
->assertSet('attachments', [])
|
||||
->call('sendReply');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'Jednak bez pliku.')->firstOrFail();
|
||||
expect($message->attachments)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sending a reply without picking a file does not create an attachment row', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$client = User::query()->create(['name' => 'Piotr Nowak', 'email' => 'piotr@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Bez załącznika.')
|
||||
->call('sendReply');
|
||||
|
||||
$message = $ticket->messages()->where('body', 'Bez załącznika.')->firstOrFail();
|
||||
expect($message->attachments)->toHaveCount(0);
|
||||
});
|
||||
44
src/tests/Feature/OperatorNewTicketTest.php
Normal file
44
src/tests/Feature/OperatorNewTicketTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\NewTicket;
|
||||
use App\Models\Category;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('operator can create a ticket on behalf of a client through the same wizard as the client uses', function () {
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna@example.com', 'roles' => ['client']]);
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
|
||||
Livewire::actingAs($operator)->test(NewTicket::class)
|
||||
->assertSee('Anna Kowalska (anna@example.com)')
|
||||
->set('customerId', $client->id)
|
||||
->call('selectCategory', $category->id)
|
||||
->assertSet('step', 2)
|
||||
->call('selectSubcategory', $sub->id)
|
||||
->assertSet('step', 3)
|
||||
->set('subject', 'Nie działa VPN')
|
||||
->set('body', 'Klient zgłasza problem z VPN.')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$ticket = Ticket::query()->where('subject', 'Nie działa VPN')->firstOrFail();
|
||||
|
||||
expect($ticket->customer_id)->toBe($client->id)
|
||||
->and($ticket->email)->toBe($client->email)
|
||||
->and($ticket->assignee_id)->toBe($operator->id)
|
||||
->and($ticket->messages()->first()->body)->toContain('zgłoszenie utworzone przez operatora w imieniu klienta');
|
||||
});
|
||||
|
||||
test('submitting without selecting a client fails validation', function () {
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op2@example.com', 'roles' => ['operator']]);
|
||||
|
||||
Livewire::actingAs($operator)->test(NewTicket::class)
|
||||
->set('subject', 'Temat')
|
||||
->set('body', 'Treść')
|
||||
->call('submit')
|
||||
->assertHasErrors(['customerId' => 'required']);
|
||||
});
|
||||
75
src/tests/Feature/OperatorQueueBulkActionsTest.php
Normal file
75
src/tests/Feature/OperatorQueueBulkActionsTest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Models\Ticket;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('operator can delete a single selected ticket after confirming', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('bulk-1@example.com');
|
||||
$ticket = makeTicket(['number' => '1001']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelect', $ticket->id)
|
||||
->call('requestDeleteSelected')
|
||||
->assertSet('pendingDeleteSelected', true)
|
||||
->call('confirmDeleteSelected')
|
||||
->assertOk();
|
||||
|
||||
expect(Ticket::query()->find($ticket->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('operator can delete several selected tickets at once, not just merge them', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('bulk-2@example.com');
|
||||
$a = makeTicket(['number' => '1001']);
|
||||
$b = makeTicket(['number' => '1002']);
|
||||
$c = makeTicket(['number' => '1003']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelect', $a->id)
|
||||
->call('toggleSelect', $b->id)
|
||||
->call('requestDeleteSelected')
|
||||
->call('confirmDeleteSelected')
|
||||
->assertOk();
|
||||
|
||||
expect(Ticket::query()->find($a->id))->toBeNull()
|
||||
->and(Ticket::query()->find($b->id))->toBeNull()
|
||||
->and(Ticket::query()->find($c->id))->not->toBeNull(); // untouched, wasn't selected
|
||||
});
|
||||
|
||||
test('deleting selected tickets requires confirmation first', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('bulk-3@example.com');
|
||||
$ticket = makeTicket(['number' => '1001']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelect', $ticket->id)
|
||||
->call('requestDeleteSelected')
|
||||
->assertSet('pendingDeleteSelected', true);
|
||||
|
||||
expect(Ticket::query()->find($ticket->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('cancelling the delete confirmation leaves the tickets untouched', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('bulk-4@example.com');
|
||||
$ticket = makeTicket(['number' => '1001']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelect', $ticket->id)
|
||||
->call('requestDeleteSelected')
|
||||
->call('cancelDeleteSelected')
|
||||
->assertSet('pendingDeleteSelected', false);
|
||||
|
||||
expect(Ticket::query()->find($ticket->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('requesting delete with nothing selected is a no-op', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('bulk-5@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('requestDeleteSelected')
|
||||
->assertSet('pendingDeleteSelected', false);
|
||||
});
|
||||
64
src/tests/Feature/OperatorQueueClosedTabTest.php
Normal file
64
src/tests/Feature/OperatorQueueClosedTabTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the operator queue has a dedicated tab listing only closed tickets', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('closed-tab@example.com');
|
||||
|
||||
$new = makeTicket(['number' => '1001', 'status_key' => 'new']);
|
||||
$open = makeTicket(['number' => '1002', 'status_key' => 'open']);
|
||||
$closed = makeTicket(['number' => '1003', 'status_key' => 'closed']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', 'closed')
|
||||
->assertSee($closed->number)
|
||||
->assertDontSee($new->number)
|
||||
->assertDontSee($open->number);
|
||||
});
|
||||
|
||||
test('the "Otwarte" tab never shows closed tickets', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('all-hides-closed@example.com');
|
||||
|
||||
$open = makeTicket(['number' => '2001', 'status_key' => 'new']);
|
||||
$closed = makeTicket(['number' => '2002', 'status_key' => 'closed']);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->assertSet('queue', 'all')
|
||||
->assertSee($open->number)
|
||||
->assertDontSee($closed->number);
|
||||
});
|
||||
|
||||
test('the "Otwarte" tab does not offer "Zamknięte" as a status filter option', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('all-no-closed-filter@example.com');
|
||||
|
||||
$keys = Livewire::actingAs($operator)->test(Queue::class)
|
||||
->instance()->filterableStatuses->pluck('key')->all();
|
||||
|
||||
expect($keys)->not->toContain('closed');
|
||||
});
|
||||
|
||||
test('other tabs still offer "Zamknięte" as a status filter option', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('mine-has-closed-filter@example.com');
|
||||
|
||||
$keys = Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', 'mine')
|
||||
->instance()->filterableStatuses->pluck('key')->all();
|
||||
|
||||
expect($keys)->toContain('closed');
|
||||
});
|
||||
|
||||
test('switching to "Otwarte" resets an active "Zamknięte" status filter, since it would always be empty there', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('reset-filter@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', 'closed')
|
||||
->set('filterStatus', 'closed')
|
||||
->call('setQueue', 'all')
|
||||
->assertSet('filterStatus', 'all');
|
||||
});
|
||||
72
src/tests/Feature/OperatorQueueSearchSortColumnsTest.php
Normal file
72
src/tests/Feature/OperatorQueueSearchSortColumnsTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('search filters tickets by subject, number, customer name or email', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('search-1@example.com');
|
||||
makeTicket(['number' => '1001', 'subject' => 'VPN nie działa', 'name' => 'Jan Kowalski', 'email' => 'jan@example.com']);
|
||||
makeTicket(['number' => '1002', 'subject' => 'Reset hasła', 'name' => 'Anna Nowak', 'email' => 'anna@example.com']);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(Queue::class)->set('search', 'VPN');
|
||||
expect($component->instance()->filteredTickets)->toHaveCount(1);
|
||||
expect($component->instance()->filteredTickets->first()->number)->toBe('1001');
|
||||
|
||||
$component->set('search', 'Nowak');
|
||||
expect($component->instance()->filteredTickets)->toHaveCount(1);
|
||||
expect($component->instance()->filteredTickets->first()->number)->toBe('1002');
|
||||
|
||||
$component->set('search', '');
|
||||
expect($component->instance()->filteredTickets)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('clicking a sortable column header sorts ascending, then descending on a second click', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('sort-1@example.com');
|
||||
makeTicket(['number' => '1001', 'subject' => 'Zebra']);
|
||||
makeTicket(['number' => '1002', 'subject' => 'Alfa']);
|
||||
makeTicket(['number' => '1003', 'subject' => 'Mango']);
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('sortByColumn', 'subject');
|
||||
|
||||
expect($component->get('sortDir'))->toBe('asc');
|
||||
expect($component->instance()->filteredTickets->pluck('subject')->all())->toBe(['Alfa', 'Mango', 'Zebra']);
|
||||
|
||||
$component->call('sortByColumn', 'subject');
|
||||
|
||||
expect($component->get('sortDir'))->toBe('desc');
|
||||
expect($component->instance()->filteredTickets->pluck('subject')->all())->toBe(['Zebra', 'Mango', 'Alfa']);
|
||||
});
|
||||
|
||||
test('sla is not a clickable sortable column', function () {
|
||||
$operator = operatorUser('sort-2@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('sortByColumn', 'sla')
|
||||
->assertSet('sortBy', 'updated_at');
|
||||
});
|
||||
|
||||
test('columns can be hidden and shown again, but at least one must stay visible', function () {
|
||||
$operator = operatorUser('columns-1@example.com');
|
||||
|
||||
$component = Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleColumn', 'sla')
|
||||
->assertSet('visibleColumns', fn ($cols) => ! in_array('sla', $cols, true));
|
||||
|
||||
$component->call('toggleColumn', 'sla')
|
||||
->assertSet('visibleColumns', fn ($cols) => in_array('sla', $cols, true));
|
||||
|
||||
// Hide every column except one, then try to hide the last one too.
|
||||
foreach (array_keys((new \App\Livewire\Operator\Queue)->columnDefs()) as $key) {
|
||||
if ($key !== 'number') {
|
||||
$component->call('toggleColumn', $key);
|
||||
}
|
||||
}
|
||||
|
||||
expect($component->get('visibleColumns'))->toBe(['number']);
|
||||
|
||||
$component->call('toggleColumn', 'number');
|
||||
expect($component->get('visibleColumns'))->toBe(['number']);
|
||||
});
|
||||
137
src/tests/Feature/OperatorTeamScopingTest.php
Normal file
137
src/tests/Feature/OperatorTeamScopingTest.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('a non-admin operator only sees their own team in the queue sidebar, not every team', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-1@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$teamKeys = Livewire::actingAs($operator)->test(Queue::class)
|
||||
->instance()->teams->pluck('name')->all();
|
||||
|
||||
expect($teamKeys)->toBe(['Infrastruktura']);
|
||||
});
|
||||
|
||||
test('an admin (even if also an operator) sees every team in the queue sidebar', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = User::query()->create(['name' => 'Admin Op', 'email' => 'admin-op@example.com', 'roles' => ['operator', 'admin']]);
|
||||
Team::query()->create(['name' => 'Infrastruktura']);
|
||||
Team::query()->create(['name' => 'Aplikacje']);
|
||||
|
||||
$teamNames = Livewire::actingAs($admin)->test(Queue::class)
|
||||
->instance()->teams->pluck('name')->sort()->values()->all();
|
||||
|
||||
expect($teamNames)->toBe(['Aplikacje', 'Infrastruktura']);
|
||||
});
|
||||
|
||||
test('a non-admin operator only sees tickets from their own team, unrouted tickets, or ones assigned to them', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-2@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$mine = makeTicket(['number' => '1001', 'team_id' => $myTeam->id]);
|
||||
$others = makeTicket(['number' => '1002', 'team_id' => $otherTeam->id]);
|
||||
$unrouted = makeTicket(['number' => '1003', 'team_id' => null]);
|
||||
$assignedToMeElsewhere = makeTicket(['number' => '1004', 'team_id' => $otherTeam->id, 'assignee_id' => $operator->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->assertSee($mine->number)
|
||||
->assertSee($unrouted->number)
|
||||
->assertSee($assignedToMeElsewhere->number)
|
||||
->assertDontSee($others->number);
|
||||
});
|
||||
|
||||
test('an admin operator still sees tickets from every team', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = User::query()->create(['name' => 'Admin Op', 'email' => 'admin-op-2@example.com', 'roles' => ['operator', 'admin']]);
|
||||
$teamA = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$teamB = Team::query()->create(['name' => 'Aplikacje']);
|
||||
|
||||
$ticketA = makeTicket(['number' => '2001', 'team_id' => $teamA->id]);
|
||||
$ticketB = makeTicket(['number' => '2002', 'team_id' => $teamB->id]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Queue::class)
|
||||
->assertSee($ticketA->number)
|
||||
->assertSee($ticketB->number);
|
||||
});
|
||||
|
||||
test('a non-admin operator gets a 403 opening a ticket outside their scope directly', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-3@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$outOfScope = makeTicket(['number' => '3001', 'team_id' => $otherTeam->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $outOfScope])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('a non-admin operator can still open a ticket outside their team if it is personally assigned to them', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-4@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$assignedElsewhere = makeTicket(['number' => '4001', 'team_id' => $otherTeam->id, 'assignee_id' => $operator->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $assignedElsewhere])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('the team reassignment dropdown on a ticket only offers a non-admin operator their own teams', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-5@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$ticket = makeTicket(['number' => '5001', 'team_id' => $myTeam->id]);
|
||||
|
||||
$teamNames = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->instance()->teams->pluck('name')->all();
|
||||
|
||||
expect($teamNames)->toBe(['Infrastruktura']);
|
||||
});
|
||||
|
||||
test('merging cannot pull in a ticket outside the operators scope via a crafted selection', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('scoped-6@example.com');
|
||||
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
|
||||
$operator->teams()->attach($myTeam->id);
|
||||
|
||||
$mineA = makeTicket(['number' => '6001', 'team_id' => $myTeam->id]);
|
||||
$mineB = makeTicket(['number' => '6002', 'team_id' => $myTeam->id]);
|
||||
$outOfScope = makeTicket(['number' => '6003', 'team_id' => $otherTeam->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelect', $mineA->id)
|
||||
->call('toggleSelect', $outOfScope->id)
|
||||
->call('mergeSelected'); // only 1 ticket is actually in scope, so this must be a no-op
|
||||
|
||||
expect(Ticket::query()->find($outOfScope->id)->status_key)->not->toBe('closed');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('toggleSelect', $mineA->id)
|
||||
->call('toggleSelect', $mineB->id)
|
||||
->call('toggleSelect', $outOfScope->id)
|
||||
->call('requestDeleteSelected')
|
||||
->call('confirmDeleteSelected');
|
||||
|
||||
expect(Ticket::query()->find($mineA->id))->toBeNull()
|
||||
->and(Ticket::query()->find($mineB->id))->toBeNull()
|
||||
->and(Ticket::query()->find($outOfScope->id))->not->toBeNull();
|
||||
});
|
||||
47
src/tests/Feature/OperatorTicketDetailsEditTest.php
Normal file
47
src/tests/Feature/OperatorTicketDetailsEditTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Category;
|
||||
use App\Models\CustomField;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('choosing a subcategory while editing details immediately shows its custom fields', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('details-edit@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$field = CustomField::query()->create(['label' => 'Teamviewer-ID', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
|
||||
$field->subcategories()->attach($subcategory->id, ['position' => 1]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('toggleEditDetails')
|
||||
->assertDontSee('Teamviewer-ID')
|
||||
->set('editDetailsForm.categoryId', $category->id)
|
||||
->set('editDetailsForm.subcategoryId', $subcategory->id)
|
||||
->assertSee('Teamviewer-ID');
|
||||
});
|
||||
|
||||
test('changing the category while editing details resets the previously selected subcategory', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('details-edit-2@example.com');
|
||||
|
||||
$categoryA = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$subA = $categoryA->subcategories()->create(['name' => 'VPN']);
|
||||
$fieldA = CustomField::query()->create(['label' => 'Teamviewer-ID', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
|
||||
$fieldA->subcategories()->attach($subA->id, ['position' => 1]);
|
||||
|
||||
$categoryB = Category::query()->create(['name' => 'Zamówienia']);
|
||||
$categoryB->subcategories()->create(['name' => 'Zamówienie sprzętu IT']);
|
||||
|
||||
$ticket = makeTicket(['subcategory_id' => $subA->id]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('toggleEditDetails')
|
||||
->assertSet('editDetailsForm.subcategoryId', $subA->id)
|
||||
->assertSee('Teamviewer-ID')
|
||||
->set('editDetailsForm.categoryId', $categoryB->id)
|
||||
->assertSet('editDetailsForm.subcategoryId', '')
|
||||
->assertDontSee('Teamviewer-ID');
|
||||
});
|
||||
103
src/tests/Feature/PriorityStatusReplyQuickActionKeyOrderTest.php
Normal file
103
src/tests/Feature/PriorityStatusReplyQuickActionKeyOrderTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Priority;
|
||||
use App\Models\ReplyQuickAction;
|
||||
use App\Models\SlaRule;
|
||||
use App\Models\Status;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can set a custom key and sort_order when creating a priority', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('newPriorityLabel', 'Krytyczny')
|
||||
->set('newPriorityKey', 'critical')
|
||||
->set('newPrioritySortOrder', '0')
|
||||
->call('addPriority')
|
||||
->assertOk();
|
||||
|
||||
$priority = Priority::query()->findOrFail('critical');
|
||||
expect($priority->label)->toBe('Krytyczny')
|
||||
->and($priority->sort_order)->toBe(0)
|
||||
->and(SlaRule::query()->where('priority_key', 'critical')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('creating a priority with a duplicate key fails validation instead of a DB error', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('newPriorityLabel', 'Duplikat')
|
||||
->set('newPriorityKey', 'high')
|
||||
->call('addPriority')
|
||||
->assertHasErrors(['newPriorityKey']);
|
||||
|
||||
expect(Priority::query()->where('label', 'Duplikat')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('leaving the priority key blank still auto-generates one, as before', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('newPriorityLabel', 'Auto Key Priority')
|
||||
->call('addPriority')
|
||||
->assertOk();
|
||||
|
||||
expect(Priority::query()->where('label', 'Auto Key Priority')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('admin can set a custom key and sort_order when creating a status', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('newStatusLabel', 'Czeka na klienta')
|
||||
->set('newStatusKey', 'waiting_on_customer')
|
||||
->set('newStatusSortOrder', '5')
|
||||
->call('addStatus')
|
||||
->assertOk();
|
||||
|
||||
$status = Status::query()->findOrFail('waiting_on_customer');
|
||||
expect($status->label)->toBe('Czeka na klienta')
|
||||
->and($status->sort_order)->toBe(5);
|
||||
});
|
||||
|
||||
test('creating a status with a duplicate key fails validation instead of a DB error', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('newStatusLabel', 'Duplikat')
|
||||
->set('newStatusKey', 'open')
|
||||
->call('addStatus')
|
||||
->assertHasErrors(['newStatusKey']);
|
||||
|
||||
expect(Status::query()->where('label', 'Duplikat')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin can set sort_order when creating and editing a reply quick action', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openReplyQuickActionForm')
|
||||
->set('replyQuickActionForm.label', 'Wyślij i eskaluj')
|
||||
->set('replyQuickActionForm.sortOrder', '1')
|
||||
->call('submitReplyQuickAction')
|
||||
->assertOk();
|
||||
|
||||
$action = ReplyQuickAction::query()->where('label', 'Wyślij i eskaluj')->firstOrFail();
|
||||
expect($action->sort_order)->toBe(1);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editReplyQuickAction', $action->id)
|
||||
->assertSet('replyQuickActionForm.sortOrder', '1')
|
||||
->set('replyQuickActionForm.sortOrder', '9')
|
||||
->call('submitReplyQuickAction')
|
||||
->assertOk();
|
||||
|
||||
expect($action->fresh()->sort_order)->toBe(9);
|
||||
});
|
||||
111
src/tests/Feature/ReplyQuickActionsTest.php
Normal file
111
src/tests/Feature/ReplyQuickActionsTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\ReplyQuickAction;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the 3 default reply quick actions exist out of the box, matching the old hardcoded menu', function () {
|
||||
// "Wyślij i oznacz jako rozwiązane" used to point at the now-removed
|
||||
// "resolved" status (folded into "closed" — see the status restructure
|
||||
// migration), so it points at "closed" today, same as "Wyślij i zamknij".
|
||||
expect(ReplyQuickAction::query()->orderBy('sort_order')->pluck('status_key', 'label')->all())->toBe([
|
||||
'Wyślij i „Oczekuje na klienta”' => 'waiting_customer',
|
||||
'Wyślij i oznacz jako rozwiązane' => 'closed',
|
||||
'Wyślij i zamknij' => 'closed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin can create a reply quick action that changes the ticket status', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'reply-quick-actions')
|
||||
->call('openReplyQuickActionForm')
|
||||
->set('replyQuickActionForm.label', 'Wyślij i eskaluj')
|
||||
->set('replyQuickActionForm.statusKey', 'in_progress')
|
||||
->call('submitReplyQuickAction')
|
||||
->assertOk();
|
||||
|
||||
$action = ReplyQuickAction::query()->where('label', 'Wyślij i eskaluj')->firstOrFail();
|
||||
expect($action->status_key)->toBe('in_progress');
|
||||
});
|
||||
|
||||
test('admin can create a reply quick action that does not change the status ("nie zmieniaj")', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'reply-quick-actions')
|
||||
->call('openReplyQuickActionForm')
|
||||
->set('replyQuickActionForm.label', 'Wyślij bez zmian')
|
||||
->set('replyQuickActionForm.statusKey', '')
|
||||
->call('submitReplyQuickAction')
|
||||
->assertOk();
|
||||
|
||||
$action = ReplyQuickAction::query()->where('label', 'Wyślij bez zmian')->firstOrFail();
|
||||
expect($action->status_key)->toBeNull()
|
||||
->and($action->statusLabel())->toBe('Nie zmieniaj statusu');
|
||||
});
|
||||
|
||||
test('admin can edit and delete a reply quick action', function () {
|
||||
$admin = adminUser();
|
||||
$action = ReplyQuickAction::query()->create(['label' => 'Tymczasowa', 'status_key' => 'closed', 'sort_order' => 99]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editReplyQuickAction', $action->id)
|
||||
->assertSet('replyQuickActionForm.label', 'Tymczasowa')
|
||||
->set('replyQuickActionForm.label', 'Zmieniona nazwa')
|
||||
->set('replyQuickActionForm.statusKey', '')
|
||||
->call('submitReplyQuickAction')
|
||||
->assertOk();
|
||||
|
||||
expect($action->fresh()->label)->toBe('Zmieniona nazwa')
|
||||
->and($action->fresh()->status_key)->toBeNull();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeReplyQuickAction', $action->id)
|
||||
->assertSet('pendingDeleteType', 'reply-quick-action')
|
||||
->call('confirmPendingDelete')
|
||||
->assertOk();
|
||||
|
||||
expect(ReplyQuickAction::query()->find($action->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('the operator ticket view lists the configured reply quick actions in the send menu', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('quickaction-view@example.com');
|
||||
$ticket = makeTicket(['number' => '1001']);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->assertSee('Wyślij i „Oczekuje na klienta”')
|
||||
->assertSee('Wyślij i oznacz jako rozwiązane')
|
||||
->assertSee('Wyślij i zamknij');
|
||||
});
|
||||
|
||||
test('sending via a status-changing quick action updates the ticket status', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('quickaction-send@example.com');
|
||||
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
|
||||
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Naprawione, proszę potwierdzić.')
|
||||
->call('sendAndTransition', $action->id);
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('closed')
|
||||
->and($ticket->fresh()->messages()->where('body', 'Naprawione, proszę potwierdzić.')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('sending via a "nie zmieniaj" quick action posts the reply without changing the status', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('quickaction-nochange@example.com');
|
||||
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
|
||||
$action = ReplyQuickAction::query()->create(['label' => 'Wyślij bez zmian', 'status_key' => null, 'sort_order' => 50]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->set('reply', 'Sprawdzam temat.')
|
||||
->call('sendAndTransition', $action->id);
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('new')
|
||||
->and($ticket->fresh()->messages()->where('body', 'Sprawdzam temat.')->exists())->toBeTrue();
|
||||
});
|
||||
66
src/tests/Feature/ResponseTemplatesAdminTest.php
Normal file
66
src/tests/Feature/ResponseTemplatesAdminTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\ResponseTemplate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can create a response template', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'response-templates')
|
||||
->call('openResponseTemplateForm')
|
||||
->set('responseTemplateForm.label', 'Prośba o zrzut ekranu')
|
||||
->set('responseTemplateForm.body', 'Czy mógłby Pan/Pani załączyć zrzut ekranu problemu?')
|
||||
->call('submitResponseTemplate')
|
||||
->assertOk();
|
||||
|
||||
$template = ResponseTemplate::query()->where('label', 'Prośba o zrzut ekranu')->firstOrFail();
|
||||
expect($template->body)->toBe('Czy mógłby Pan/Pani załączyć zrzut ekranu problemu?');
|
||||
});
|
||||
|
||||
test('creating a response template without a label fails validation', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openResponseTemplateForm')
|
||||
->set('responseTemplateForm.label', '')
|
||||
->set('responseTemplateForm.body', 'Treść.')
|
||||
->call('submitResponseTemplate')
|
||||
->assertHasErrors(['responseTemplateForm.label' => 'required']);
|
||||
|
||||
expect(ResponseTemplate::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('admin can edit and delete a response template', function () {
|
||||
$admin = adminUser();
|
||||
$template = ResponseTemplate::query()->create(['label' => 'Tymczasowy', 'body' => 'Stara treść.']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editResponseTemplate', $template->id)
|
||||
->assertSet('responseTemplateForm.label', 'Tymczasowy')
|
||||
->set('responseTemplateForm.label', 'Zmieniona nazwa')
|
||||
->set('responseTemplateForm.body', 'Nowa treść.')
|
||||
->call('submitResponseTemplate')
|
||||
->assertOk();
|
||||
|
||||
expect($template->fresh()->label)->toBe('Zmieniona nazwa')
|
||||
->and($template->fresh()->body)->toBe('Nowa treść.');
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeResponseTemplate', $template->id)
|
||||
->assertSet('pendingDeleteType', 'response-template')
|
||||
->call('confirmPendingDelete')
|
||||
->assertOk();
|
||||
|
||||
expect(ResponseTemplate::query()->find($template->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('the response templates tab lists existing templates', function () {
|
||||
$admin = adminUser();
|
||||
ResponseTemplate::query()->create(['label' => 'Restart usuwa problem', 'body' => 'Prosimy zrestartować urządzenie.']);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'response-templates')
|
||||
->assertSee('Restart usuwa problem');
|
||||
});
|
||||
46
src/tests/Feature/RoleMiddlewareTest.php
Normal file
46
src/tests/Feature/RoleMiddlewareTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
function userWithRole(string $role): User
|
||||
{
|
||||
return User::query()->create([
|
||||
'name' => ucfirst($role).' User',
|
||||
'email' => $role.'@example.com',
|
||||
'roles' => [$role],
|
||||
]);
|
||||
}
|
||||
|
||||
test('guests are redirected to login for every protected area', function (string $uri) {
|
||||
$this->get($uri)->assertRedirect('/login');
|
||||
})->with(['/client', '/operator', '/admin']);
|
||||
|
||||
test('each role can only access its own area', function () {
|
||||
$client = userWithRole('client');
|
||||
$operator = userWithRole('operator');
|
||||
$admin = userWithRole('admin');
|
||||
|
||||
$this->actingAs($client)->get('/client')->assertOk();
|
||||
$this->actingAs($client)->get('/operator')->assertForbidden();
|
||||
$this->actingAs($client)->get('/admin')->assertForbidden();
|
||||
|
||||
$this->actingAs($operator)->get('/operator')->assertOk();
|
||||
$this->actingAs($operator)->get('/client')->assertForbidden();
|
||||
$this->actingAs($operator)->get('/admin')->assertForbidden();
|
||||
|
||||
$this->actingAs($admin)->get('/admin')->assertOk();
|
||||
$this->actingAs($admin)->get('/client')->assertForbidden();
|
||||
$this->actingAs($admin)->get('/operator')->assertForbidden();
|
||||
});
|
||||
|
||||
test('a user with multiple roles can access every matching area', function () {
|
||||
$user = User::query()->create([
|
||||
'name' => 'Multi Role',
|
||||
'email' => 'multi@example.com',
|
||||
'roles' => ['operator', 'admin'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get('/operator')->assertOk();
|
||||
$this->actingAs($user)->get('/admin')->assertOk();
|
||||
$this->actingAs($user)->get('/client')->assertForbidden();
|
||||
});
|
||||
147
src/tests/Feature/RolesFieldValuesAndMessageAuthorTest.php
Normal file
147
src/tests/Feature/RolesFieldValuesAndMessageAuthorTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Client\TicketShow as ClientTicketShow;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\Role;
|
||||
use App\Models\TicketMessageAuthor;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use App\Models\UserFieldValue;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the roles table is seeded with client, operator and admin', function () {
|
||||
expect(Role::query()->pluck('key')->all())->toEqualCanonicalizing(['client', 'operator', 'admin']);
|
||||
});
|
||||
|
||||
test('a users roles are stored in the role_user pivot, not a JSON column', function () {
|
||||
$user = User::query()->create(['name' => 'Multi Role', 'email' => 'multirole@example.com', 'roles' => ['operator', 'admin']]);
|
||||
|
||||
expect(Schema::hasColumn('users', 'roles'))->toBeFalse()
|
||||
->and(DB::table('role_user')->where('user_id', $user->id)->count())->toBe(2)
|
||||
->and($user->fresh()->roles)->toEqualCanonicalizing(['operator', 'admin'])
|
||||
->and($user->isAdmin())->toBeTrue()
|
||||
->and($user->isOperator())->toBeTrue()
|
||||
->and($user->isClient())->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin toggling a users role in the panel updates role_user, not a JSON column', function () {
|
||||
$admin = adminUser();
|
||||
$target = User::query()->create(['name' => 'Toggle Me', 'email' => 'toggle@example.com', 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('toggleUserRole', $target->id, 'operator')
|
||||
->assertOk();
|
||||
|
||||
expect($target->fresh()->roles)->toEqualCanonicalizing(['client', 'operator']);
|
||||
});
|
||||
|
||||
test('a users custom field values are stored in user_field_values, not a JSON column', function () {
|
||||
$field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]);
|
||||
$user = User::query()->create([
|
||||
'name' => 'Field Test', 'email' => 'fieldtest@example.com', 'roles' => ['client'],
|
||||
'custom_field_values' => [$field->id => 'IT'],
|
||||
]);
|
||||
|
||||
expect(Schema::hasColumn('users', 'custom_field_values'))->toBeFalse()
|
||||
->and(UserFieldValue::query()->where('user_id', $user->id)->where('user_field_id', $field->id)->value('value'))->toBe('IT')
|
||||
->and($user->fresh()->custom_field_values)->toBe([$field->id => 'IT']);
|
||||
});
|
||||
|
||||
test('replacing a users field values drops the ones no longer present, matching the old JSON full-replace semantics', function () {
|
||||
$field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]);
|
||||
$user = User::query()->create([
|
||||
'name' => 'Field Test 2', 'email' => 'fieldtest2@example.com', 'roles' => ['client'],
|
||||
'custom_field_values' => [$field->id => 'IT'],
|
||||
]);
|
||||
|
||||
$user->update(['custom_field_values' => []]);
|
||||
|
||||
expect(UserFieldValue::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a client reply links the message to its author and the client role via ticket_message_authors', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'klient-msg@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź klienta');
|
||||
|
||||
$message = $ticket->messages()->latest('id')->first();
|
||||
|
||||
expect(Schema::hasColumn('ticket_messages', 'author_id'))->toBeFalse()
|
||||
->and(Schema::hasColumn('ticket_messages', 'role'))->toBeFalse()
|
||||
->and($message->role)->toBe('client')
|
||||
->and($message->author_id)->toBe($client->id)
|
||||
->and($message->author->id)->toBe($client->id);
|
||||
});
|
||||
|
||||
test('an operator reply and note link to the operator role', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('op-msg@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora');
|
||||
app(TicketService::class)->operatorNote($ticket, $operator, 'Notatka wewnętrzna');
|
||||
|
||||
$reply = $ticket->messages()->where('internal', false)->latest('id')->first();
|
||||
$note = $ticket->messages()->where('internal', true)->latest('id')->first();
|
||||
|
||||
expect($reply->role)->toBe('operator')->and($reply->author_id)->toBe($operator->id)
|
||||
->and($note->role)->toBe('operator')->and($note->author_id)->toBe($operator->id);
|
||||
});
|
||||
|
||||
test('a system-generated message (merge announcement) has no author and role defaults to "system"', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$primary = makeTicket(['number' => '2001']);
|
||||
$other = makeTicket(['number' => '2002']);
|
||||
|
||||
app(TicketService::class)->merge([$primary->id, $other->id]);
|
||||
|
||||
$announcement = $primary->messages()->where('body', 'like', '%Scalono zgłoszenia%')->firstOrFail();
|
||||
|
||||
expect($announcement->role)->toBe('system')
|
||||
->and($announcement->author_id)->toBeNull()
|
||||
->and(TicketMessageAuthor::query()->where('ticket_message_id', $announcement->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a client can edit their own message but not an operators message on the same ticket', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Owner', 'email' => 'owner@example.com', 'roles' => ['client']]);
|
||||
$operator = operatorUser('op-on-client-ticket@example.com');
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
app(TicketService::class)->clientReply($ticket, $client, 'Treść klienta');
|
||||
$ownMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora');
|
||||
$operatorMessage = $ticket->messages()->latest('id')->first();
|
||||
|
||||
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
|
||||
->call('startEdit', $ownMessage->id, 'zmieniona treść')
|
||||
->assertSet('editingMessageId', $ownMessage->id);
|
||||
|
||||
// abort_unless() inside an action call doesn't translate into a clean
|
||||
// HTTP response under Livewire's test harness (it disables the app's
|
||||
// exception handling for ->call()), so assert on the instance directly.
|
||||
$component = Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])->instance();
|
||||
|
||||
expect(fn () => $component->startEdit($operatorMessage->id, 'zmieniona treść'))
|
||||
->toThrow(Symfony\Component\HttpKernel\Exception\HttpException::class);
|
||||
});
|
||||
|
||||
test('an operator can edit an operator-authored message', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('op-edit@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->operatorReply($ticket, $operator, 'Treść operatora');
|
||||
$message = $ticket->messages()->latest('id')->first();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('startEditMessage', $message->id, 'zmieniona treść')
|
||||
->assertSet('editingMessageId', $message->id);
|
||||
});
|
||||
26
src/tests/Feature/SessionLifetimeConfigTest.php
Normal file
26
src/tests/Feature/SessionLifetimeConfigTest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save a custom session lifetime from the Konfiguracja tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('systemConfig.sessionLifetimeMinutes', '45')
|
||||
->call('saveSystemConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('session_lifetime_minutes'))->toBe('45');
|
||||
});
|
||||
|
||||
test('AppServiceProvider overrides session.lifetime from the saved setting', function () {
|
||||
Settings::set('session_lifetime_minutes', '30');
|
||||
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
|
||||
expect(config('session.lifetime'))->toBe(30);
|
||||
});
|
||||
95
src/tests/Feature/SlaBreachNotificationTest.php
Normal file
95
src/tests/Feature/SlaBreachNotificationTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\SlaRule;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('the SLA-breach check does nothing while its trigger is disabled (the default)', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'sla-op-1@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subDays(2)]);
|
||||
|
||||
Artisan::call('tickets:check-sla-breaches');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
expect($ticket->fresh()->sla_notified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('an overdue ticket with an assigned operator gets notified once the trigger is enabled, and only once', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
|
||||
$operator = User::query()->create(['name' => 'Ola Operator', 'email' => 'sla-op-2@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subDays(2)]);
|
||||
|
||||
Artisan::call('tickets:check-sla-breaches');
|
||||
|
||||
Notification::assertSentOnDemand(
|
||||
TicketNotification::class,
|
||||
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'sla-op-2@example.com'
|
||||
);
|
||||
expect($ticket->fresh()->sla_notified_at)->not->toBeNull();
|
||||
|
||||
Artisan::call('tickets:check-sla-breaches');
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('an overdue but unassigned ticket is never notified (nobody to send it to)', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
|
||||
makeTicket(['assignee_id' => null, 'status_key' => 'new', 'created_at' => now()->subDays(2)]);
|
||||
|
||||
Artisan::call('tickets:check-sla-breaches');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a closed overdue ticket is never notified', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'sla-op-3@example.com', 'roles' => ['operator']]);
|
||||
makeTicket(['assignee_id' => $operator->id, 'status_key' => 'closed', 'created_at' => now()->subDays(2)]);
|
||||
|
||||
Artisan::call('tickets:check-sla-breaches');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a ticket whose priority has resolution_mins set to 0 (no SLA) is never notified, however old', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
SlaRule::query()->where('priority_key', 'high')->update(['resolution_mins' => 0]);
|
||||
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'sla-op-5@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subYears(2)]);
|
||||
|
||||
Artisan::call('tickets:check-sla-breaches');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
expect($ticket->fresh()->sla_notified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('a ticket still within its SLA window is not notified', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'sla-op-4@example.com', 'roles' => ['operator']]);
|
||||
$ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subHour()]);
|
||||
|
||||
Artisan::call('tickets:check-sla-breaches');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
expect($ticket->fresh()->sla_notified_at)->toBeNull();
|
||||
});
|
||||
34
src/tests/Feature/SmokeTest.php
Normal file
34
src/tests/Feature/SmokeTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('every page renders without error against fully seeded data', function () {
|
||||
$this->seed();
|
||||
|
||||
$client = User::query()->withRole('client')->firstOrFail();
|
||||
$operator = User::query()->withRole('operator')->firstOrFail();
|
||||
$admin = User::query()->withRole('admin')->firstOrFail();
|
||||
$clientTicket = Ticket::query()->where('customer_id', $client->id)->firstOrFail();
|
||||
$anyTicket = Ticket::query()->firstOrFail();
|
||||
|
||||
$this->get('/')->assertOk()->assertSee('Jak możemy pomóc');
|
||||
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');
|
||||
|
||||
$this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Panel Klienta');
|
||||
$this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Panel Klienta');
|
||||
|
||||
$this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Panel Operatora');
|
||||
$this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Panel Operatora');
|
||||
|
||||
foreach (['categories', 'fields', 'users', 'teams', 'user-fields', 'reply-quick-actions', 'response-templates', 'statuses', 'priorities', 'templates', 'branding', 'config', 'about'] as $tab) {
|
||||
$this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Panel Administratora');
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', $tab)
|
||||
->assertOk();
|
||||
}
|
||||
});
|
||||
68
src/tests/Feature/StatusStageRestructureTest.php
Normal file
68
src/tests/Feature/StatusStageRestructureTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Client\Dashboard;
|
||||
use App\Livewire\Client\TicketShow;
|
||||
use App\Models\Status;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the 3 core statuses (new/open/closed) are locked and cannot be deleted from the admin panel', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
foreach (['new', 'open', 'closed'] as $key) {
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeStatus', $key)
|
||||
->call('confirmPendingDelete')
|
||||
->assertOk();
|
||||
|
||||
expect(Status::query()->find($key))->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('a custom status created by the admin is always unlocked and belongs to the "open" stage', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->set('newStatusLabel', 'Czeka na dostawę części')
|
||||
->call('addStatus')
|
||||
->assertOk();
|
||||
|
||||
$status = Status::query()->where('label', 'Czeka na dostawę części')->firstOrFail();
|
||||
expect($status->stage)->toBe('open')
|
||||
->and($status->locked)->toBeFalse();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeStatus', $status->key)
|
||||
->call('confirmPendingDelete')
|
||||
->assertOk();
|
||||
|
||||
expect(Status::query()->find($status->key))->toBeNull();
|
||||
});
|
||||
|
||||
test('reopening a closed ticket sends it back to "Otwarty", not "Nowy"', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Ewa', 'email' => 'ewa-reopen@example.com', 'roles' => ['client']]);
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']);
|
||||
|
||||
Livewire::actingAs($client)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->call('reopen');
|
||||
|
||||
expect($ticket->fresh()->status_key)->toBe('open');
|
||||
});
|
||||
|
||||
test('the client dashboard buckets any closed-stage status into "archiwalne" and everything else into "aktualne"', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Status::query()->create(['key' => 'on_hold', 'label' => 'Wstrzymany', 'color' => '#b5abfc', 'stage' => 'open', 'locked' => false, 'sort_order' => 4]);
|
||||
|
||||
$client = User::query()->create(['name' => 'Piotr', 'email' => 'piotr-dash@example.com', 'roles' => ['client']]);
|
||||
$onHold = makeTicket(['number' => '3001', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'on_hold']);
|
||||
$closed = makeTicket(['number' => '3002', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']);
|
||||
|
||||
$component = Livewire::actingAs($client)->test(Dashboard::class);
|
||||
|
||||
expect($component->instance()->currentTickets->pluck('number')->all())->toBe([$onHold->number])
|
||||
->and($component->instance()->archiveTickets->pluck('number')->all())->toBe([$closed->number]);
|
||||
});
|
||||
66
src/tests/Feature/SubcategoryDefaultPriorityTest.php
Normal file
66
src/tests/Feature/SubcategoryDefaultPriorityTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can set a default priority on a subcategory via the edit dialog', function () {
|
||||
$admin = adminUser();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN']);
|
||||
Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('openSubcategoryEditForm', $sub->id)
|
||||
->set('subcategoryEditForm.defaultPriorityKey', 'critical')
|
||||
->call('submitSubcategoryEdit')
|
||||
->assertOk();
|
||||
|
||||
expect($sub->fresh()->default_priority_key)->toBe('critical');
|
||||
});
|
||||
|
||||
test('a new ticket takes its priority from the chosen subcategory default', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]);
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'VPN', 'default_priority_key' => 'critical']);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'client-priority@example.com',
|
||||
'subject' => 'Problem VPN',
|
||||
'body' => 'Opis.',
|
||||
'subcategory_id' => $sub->id,
|
||||
], null);
|
||||
|
||||
expect($ticket->priority_key)->toBe('critical');
|
||||
});
|
||||
|
||||
test('a new ticket falls back to "medium" when its subcategory has no default priority set', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Drukarki']);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'client-priority-2@example.com',
|
||||
'subject' => 'Problem drukarki',
|
||||
'body' => 'Opis.',
|
||||
'subcategory_id' => $sub->id,
|
||||
], null);
|
||||
|
||||
expect($ticket->priority_key)->toBe('medium');
|
||||
});
|
||||
|
||||
test('a new ticket with no subcategory at all still falls back to "medium"', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'client-priority-3@example.com',
|
||||
'subject' => 'Zgłoszenie bez podkategorii',
|
||||
'body' => 'Opis.',
|
||||
], null);
|
||||
|
||||
expect($ticket->priority_key)->toBe('medium');
|
||||
});
|
||||
180
src/tests/Feature/TicketApiTest.php
Normal file
180
src/tests/Feature/TicketApiTest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\Category;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketHistory;
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
function apiClientWithAbilities(array $abilities): ApiClient
|
||||
{
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, $abilities);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
test('index lists tickets for a client with tickets:read', function () {
|
||||
seedStatusesAndPriorities();
|
||||
makeTicket(['number' => '1001']);
|
||||
makeTicket(['number' => '1002']);
|
||||
apiClientWithAbilities(['tickets:read']);
|
||||
|
||||
$this->getJson('/api/v1/tickets')
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
test('custom_fields are returned with their field id and label, and category nests its subcategory', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT Pomoc']);
|
||||
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$field = CustomField::query()->create(['label' => 'Wpływ na pracę zespołu', 'type' => 'text', 'required' => false]);
|
||||
$subcategory->customFields()->attach($field->id, ['position' => 0]);
|
||||
|
||||
$ticket = makeTicket([
|
||||
'subcategory_id' => $subcategory->id,
|
||||
'custom_fields' => [$field->id => 'Częściowy'],
|
||||
]);
|
||||
apiClientWithAbilities(['tickets:read']);
|
||||
|
||||
$this->getJson("/api/v1/tickets/{$ticket->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.category.name', 'IT Pomoc')
|
||||
->assertJsonPath('data.category.subcategory.name', 'VPN')
|
||||
->assertJsonPath('data.custom_fields.0.id', $field->id)
|
||||
->assertJsonPath('data.custom_fields.0.label', 'Wpływ na pracę zespołu')
|
||||
->assertJsonPath('data.custom_fields.0.value', 'Częściowy');
|
||||
});
|
||||
|
||||
test('index filters by status_key', function () {
|
||||
seedStatusesAndPriorities();
|
||||
makeTicket(['number' => '1001', 'status_key' => 'new']);
|
||||
makeTicket(['number' => '1002', 'status_key' => 'closed']);
|
||||
apiClientWithAbilities(['tickets:read']);
|
||||
|
||||
$this->getJson('/api/v1/tickets?status_key=closed')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.number', '1002');
|
||||
});
|
||||
|
||||
test('index filters by a negated status_key, excluding it instead of matching it', function () {
|
||||
seedStatusesAndPriorities();
|
||||
makeTicket(['number' => '1001', 'status_key' => 'new']);
|
||||
makeTicket(['number' => '1002', 'status_key' => 'closed']);
|
||||
apiClientWithAbilities(['tickets:read']);
|
||||
|
||||
$this->getJson('/api/v1/tickets?status_key=!closed')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.number', '1001');
|
||||
});
|
||||
|
||||
test('index without tickets:read ability is forbidden', function () {
|
||||
apiClientWithAbilities(['dictionaries:read']);
|
||||
|
||||
$this->getJson('/api/v1/tickets')->assertForbidden();
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
$this->getJson('/api/v1/tickets')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('store creates a guest-style ticket stamped with the api client', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = apiClientWithAbilities(['tickets:write']);
|
||||
|
||||
$response = $this->postJson('/api/v1/tickets', [
|
||||
'email' => 'zgloszenie@firma.pl',
|
||||
'name' => 'Jan Kowalski',
|
||||
'subject' => 'VPN nie działa',
|
||||
'body' => 'Nie mogę się połączyć z VPN.',
|
||||
])->assertCreated();
|
||||
|
||||
$ticket = Ticket::query()->latest('id')->first();
|
||||
|
||||
expect($ticket)->not->toBeNull();
|
||||
expect($ticket->customer_id)->toBeNull();
|
||||
expect($ticket->email)->toBe('zgloszenie@firma.pl');
|
||||
expect($ticket->api_client_id)->toBe($client->id);
|
||||
expect($ticket->messages()->first()->api_client_id)->toBe($client->id);
|
||||
$response->assertJsonPath('data.number', $ticket->number);
|
||||
});
|
||||
|
||||
test('store validates required fields', function () {
|
||||
apiClientWithAbilities(['tickets:write']);
|
||||
|
||||
$this->postJson('/api/v1/tickets', [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['email', 'name', 'subject', 'body']);
|
||||
});
|
||||
|
||||
test('store without tickets:write ability is forbidden', function () {
|
||||
apiClientWithAbilities(['tickets:read']);
|
||||
|
||||
$this->postJson('/api/v1/tickets', [
|
||||
'email' => 'a@example.com', 'name' => 'A', 'subject' => 'S', 'body' => 'B',
|
||||
])->assertForbidden();
|
||||
});
|
||||
|
||||
test('update changes status, priority, assignee and team and records history', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]);
|
||||
$team = Team::query()->create(['name' => 'Infra']);
|
||||
apiClientWithAbilities(['tickets:write']);
|
||||
|
||||
$this->patchJson("/api/v1/tickets/{$ticket->id}", [
|
||||
'status_key' => 'closed',
|
||||
'priority_key' => 'high',
|
||||
'assignee_id' => $operator->id,
|
||||
'team_id' => $team->id,
|
||||
])->assertOk()->assertJsonPath('data.status.key', 'closed');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->status_key)->toBe('closed');
|
||||
expect($ticket->assignee_id)->toBe($operator->id);
|
||||
expect($ticket->team_id)->toBe($team->id);
|
||||
expect(TicketHistory::query()->where('ticket_id', $ticket->id)->count())->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('update only touches fields present in the request', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket(['subject' => 'Original subject']);
|
||||
apiClientWithAbilities(['tickets:write']);
|
||||
|
||||
$this->patchJson("/api/v1/tickets/{$ticket->id}", ['status_key' => 'closed'])->assertOk();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->subject)->toBe('Original subject');
|
||||
expect($ticket->status_key)->toBe('closed');
|
||||
});
|
||||
|
||||
test('a real bearer token authenticates end to end', function () {
|
||||
seedStatusesAndPriorities();
|
||||
makeTicket();
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
$token = $client->createToken('test', ['tickets:read']);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token->plainTextToken)
|
||||
->getJson('/api/v1/tickets')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('a revoked token no longer authenticates', function () {
|
||||
$client = ApiClient::factory()->create();
|
||||
$token = $client->createToken('test', ['tickets:read']);
|
||||
$plaintext = $token->plainTextToken;
|
||||
|
||||
$client->tokens()->delete();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$plaintext)
|
||||
->getJson('/api/v1/tickets')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
71
src/tests/Feature/TicketBodyAndAttachmentDisplayTest.php
Normal file
71
src/tests/Feature/TicketBodyAndAttachmentDisplayTest.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Client\TicketShow as ClientTicketShow;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Models\TicketAttachment;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the ticket body shows once under the subject in the operator view, not as a duplicated message bubble', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('body-op@example.com');
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'client-body@example.com',
|
||||
'subject' => 'Problem z drukarką',
|
||||
'body' => 'Drukarka nie drukuje od rana, unikalny-fragment-tresci-XYZ',
|
||||
], null);
|
||||
|
||||
$html = Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])->html();
|
||||
|
||||
expect(substr_count($html, 'unikalny-fragment-tresci-XYZ'))->toBe(1);
|
||||
});
|
||||
|
||||
test('the ticket body shows once under the subject in the client view, not as a duplicated message bubble', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'client-body-2@example.com', 'roles' => ['client']]);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $client->email,
|
||||
'subject' => 'Problem z VPN',
|
||||
'body' => 'VPN się rozłącza, unikalny-fragment-tresci-ABC',
|
||||
], $client);
|
||||
|
||||
$html = Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])->html();
|
||||
|
||||
expect(substr_count($html, 'unikalny-fragment-tresci-ABC'))->toBe(1);
|
||||
});
|
||||
|
||||
test('an attachment uploaded with the original ticket still shows, now under the header instead of a message', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('body-attach@example.com');
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'client-attach@example.com',
|
||||
'subject' => 'Zrzut ekranu w załączniku',
|
||||
'body' => 'Patrz załącznik.',
|
||||
], null);
|
||||
|
||||
$firstMessage = $ticket->messages()->firstOrFail();
|
||||
TicketAttachment::query()->create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'message_id' => $firstMessage->id,
|
||||
'original_name' => 'poczatkowy-zalacznik.png',
|
||||
'path' => 'attachments/poczatkowy-zalacznik.png',
|
||||
'size' => 100,
|
||||
'mime' => 'image/png',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSee('poczatkowy-zalacznik.png');
|
||||
});
|
||||
|
||||
test('the reply attachment box shows "Brak załączników" when nothing is picked yet', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('no-attachment-yet@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->assertSeeHtml('Brak załączników');
|
||||
});
|
||||
130
src/tests/Feature/TicketBusinessRulesTest.php
Normal file
130
src/tests/Feature/TicketBusinessRulesTest.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\SlaRule;
|
||||
use App\Models\Status;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketService;
|
||||
|
||||
function seedStatusesAndPriorities(): void
|
||||
{
|
||||
Status::query()->create(['key' => 'new', 'label' => 'Nowe', 'color' => '#9184d9', 'stage' => 'new', 'locked' => true, 'sort_order' => 1]);
|
||||
Status::query()->create(['key' => 'open', 'label' => 'Otwarty', 'color' => '#b5abfc', 'stage' => 'open', 'locked' => true, 'sort_order' => 2]);
|
||||
Status::query()->create(['key' => 'closed', 'label' => 'Zamknięte', 'color' => '#75798c', 'stage' => 'closed', 'locked' => true, 'sort_order' => 3]);
|
||||
|
||||
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#9184d9', 'sort_order' => 1]);
|
||||
SlaRule::query()->create(['priority_key' => 'high', 'response_mins' => 60, 'resolution_mins' => 480]);
|
||||
}
|
||||
|
||||
function makeTicket(array $overrides = []): Ticket
|
||||
{
|
||||
return Ticket::query()->create(array_merge([
|
||||
'number' => '1001',
|
||||
'email' => 'client@example.com',
|
||||
'name' => 'Test Client',
|
||||
'subject' => 'Test subject',
|
||||
'body' => 'Test body',
|
||||
'status_key' => 'new',
|
||||
'priority_key' => 'high',
|
||||
'custom_fields' => [],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
test('next ticket number is one above the current max', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
expect(Ticket::nextNumber())->toBe('1001');
|
||||
|
||||
makeTicket(['number' => '1001']);
|
||||
makeTicket(['number' => '0998']); // archived tickets can have lower numbers
|
||||
|
||||
expect(Ticket::nextNumber())->toBe('1002');
|
||||
});
|
||||
|
||||
test('sla info reports overdue once the resolution deadline has passed', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = makeTicket(['created_at' => now()->subHours(9)]); // 480 min = 8h resolution window
|
||||
|
||||
expect($ticket->slaInfo()['text'])->toBe('Przekroczono SLA');
|
||||
});
|
||||
|
||||
test('sla info reports remaining hours before the deadline', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = makeTicket(['created_at' => now()->subHours(1)]);
|
||||
|
||||
expect($ticket->slaInfo()['text'])->toContain('Pozostało');
|
||||
});
|
||||
|
||||
test('sla info is closed once the ticket status is in the "closed" stage', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subDays(10)]);
|
||||
|
||||
expect($ticket->slaInfo())->toBe(['text' => 'Zamknięte', 'short' => '—', 'cls' => 'tag tag-neutral']);
|
||||
});
|
||||
|
||||
test('sla info respects any status marked with the "closed" stage, not just the literal "closed" key', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Status::query()->create(['key' => 'archived', 'label' => 'Zarchiwizowane', 'color' => '#75798c', 'stage' => 'closed', 'locked' => false, 'sort_order' => 9]);
|
||||
|
||||
$ticket = makeTicket(['status_key' => 'archived', 'created_at' => now()->subDays(10)]);
|
||||
|
||||
expect($ticket->isClosed())->toBeTrue()
|
||||
->and($ticket->slaInfo()['text'])->toBe('Zamknięte');
|
||||
});
|
||||
|
||||
test('a priority with resolution_mins set to 0 has no SLA and is never overdue, no matter how old the ticket is', function () {
|
||||
seedStatusesAndPriorities();
|
||||
SlaRule::query()->where('priority_key', 'high')->update(['resolution_mins' => 0]);
|
||||
|
||||
$ticket = makeTicket(['created_at' => now()->subYears(2)]);
|
||||
|
||||
expect($ticket->isOverdue())->toBeFalse()
|
||||
->and($ticket->slaInfo())->toBe(['text' => 'Brak SLA', 'short' => 'Brak SLA', 'cls' => 'tag tag-neutral']);
|
||||
});
|
||||
|
||||
test('merging tickets copies messages onto the primary and closes the others', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$primary = makeTicket(['number' => '1001']);
|
||||
$primary->messages()->create(['author_name' => 'A', 'body' => 'first'])->attachAuthor(null, 'client');
|
||||
|
||||
$other = makeTicket(['number' => '1002']);
|
||||
$other->messages()->create(['author_name' => 'B', 'body' => 'second'])->attachAuthor(null, 'client');
|
||||
|
||||
app(TicketService::class)->merge([$primary->id, $other->id]);
|
||||
|
||||
$primary->refresh();
|
||||
$other->refresh();
|
||||
|
||||
expect($other->status_key)->toBe('closed')
|
||||
->and($primary->messages()->count())->toBe(3) // original + copied + system note
|
||||
->and($primary->messages()->where('body', 'second')->exists())->toBeTrue()
|
||||
->and($other->messages()->where('internal', true)->where('body', 'like', '%Scalone ze zgłoszeniem #1001%')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('merge is a no-op with fewer than two tickets', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$primary = makeTicket(['number' => '1001']);
|
||||
|
||||
app(TicketService::class)->merge([$primary->id]);
|
||||
|
||||
expect($primary->fresh()->status_key)->toBe('new');
|
||||
});
|
||||
|
||||
test('creating a ticket via the service records the initial client message', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'guest@example.com',
|
||||
'subject' => 'Help please',
|
||||
'body' => 'Something is broken',
|
||||
], null);
|
||||
|
||||
expect($ticket->number)->toBe('1001')
|
||||
->and($ticket->messages()->count())->toBe(1)
|
||||
->and($ticket->messages()->first()->role)->toBe('client');
|
||||
});
|
||||
84
src/tests/Feature/TicketMessageApiTest.php
Normal file
84
src/tests/Feature/TicketMessageApiTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
test('index lists public and internal messages', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$ticket->messages()->create(['author_name' => 'Client', 'body' => 'Public message']);
|
||||
$ticket->messages()->create(['author_name' => 'Op', 'internal' => true, 'body' => 'Internal note']);
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:read']);
|
||||
|
||||
$this->getJson("/api/v1/tickets/{$ticket->id}/messages")
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
test('store creates a public message with system authorship', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = ApiClient::factory()->create(['name' => 'Monitoring Bot']);
|
||||
Sanctum::actingAs($client, ['tickets:write']);
|
||||
|
||||
$response = $this->postJson("/api/v1/tickets/{$ticket->id}/messages", [
|
||||
'body' => 'Automated update from monitoring.',
|
||||
])->assertCreated();
|
||||
|
||||
$response->assertJsonPath('data.role', 'system');
|
||||
$response->assertJsonPath('data.author_name', 'Monitoring Bot');
|
||||
|
||||
$message = $ticket->messages()->latest('id')->first();
|
||||
expect($message->internal)->toBeFalse();
|
||||
expect($message->api_client_id)->toBe($client->id);
|
||||
});
|
||||
|
||||
test('store creates an internal note when internal is true', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:write']);
|
||||
|
||||
$this->postJson("/api/v1/tickets/{$ticket->id}/messages", [
|
||||
'body' => 'Internal-only note.',
|
||||
'internal' => true,
|
||||
])->assertCreated()->assertJsonPath('data.internal', true);
|
||||
});
|
||||
|
||||
test('store respects a custom author_name', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = ApiClient::factory()->create(['name' => 'Bot']);
|
||||
Sanctum::actingAs($client, ['tickets:write']);
|
||||
|
||||
$this->postJson("/api/v1/tickets/{$ticket->id}/messages", [
|
||||
'body' => 'Message body',
|
||||
'author_name' => 'External System',
|
||||
])->assertCreated()->assertJsonPath('data.author_name', 'External System');
|
||||
});
|
||||
|
||||
test('store attaches uploaded files', function () {
|
||||
Storage::fake('public');
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:write']);
|
||||
|
||||
$this->postJson("/api/v1/tickets/{$ticket->id}/messages", [
|
||||
'body' => 'See attached file.',
|
||||
'attachments' => [UploadedFile::fake()->create('log.txt', 50)],
|
||||
])->assertCreated()->assertJsonCount(1, 'data.attachments');
|
||||
});
|
||||
|
||||
test('store requires tickets:write ability', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:read']);
|
||||
|
||||
$this->postJson("/api/v1/tickets/{$ticket->id}/messages", ['body' => 'x'])->assertForbidden();
|
||||
});
|
||||
202
src/tests/Feature/TicketTimeTrackingTest.php
Normal file
202
src/tests/Feature/TicketTimeTrackingTest.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('opening a ticket for the first time auto-starts the timer', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-start@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->timer_started_at)->not->toBeNull()
|
||||
->and($ticket->time_spent_seconds)->toBe(0);
|
||||
});
|
||||
|
||||
test('the elapsed time grows while the timer runs and is exposed via timerElapsedSeconds', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-elapse@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
$ticket->refresh();
|
||||
|
||||
$this->travel(90)->seconds();
|
||||
|
||||
expect($ticket->fresh()->timerElapsedSeconds())->toBe(90);
|
||||
});
|
||||
|
||||
test('stopping the timer checkpoints the elapsed seconds and clears the running marker', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-stop@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
$ticket->refresh();
|
||||
|
||||
$this->travel(60)->seconds();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('stopTimer');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->timer_started_at)->toBeNull()
|
||||
->and($ticket->time_spent_seconds)->toBe(60);
|
||||
});
|
||||
|
||||
test('resuming a stopped timer starts a new running segment on top of the saved total', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-resume@example.com');
|
||||
$ticket = makeTicket(['time_spent_seconds' => 120, 'timer_started_at' => null]);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('resumeTimer');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->timer_started_at)->not->toBeNull()
|
||||
->and($ticket->timerElapsedSeconds())->toBe(120);
|
||||
|
||||
$this->travel(15)->seconds();
|
||||
|
||||
expect($ticket->fresh()->timerElapsedSeconds())->toBe(135);
|
||||
});
|
||||
|
||||
test('opening a previously-stopped ticket resumes the timer, since it only tracks time the ticket is actually open', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('timer-reopen-resumes@example.com');
|
||||
$ticket = makeTicket(['time_spent_seconds' => 45, 'timer_started_at' => null]);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->timer_started_at)->not->toBeNull()
|
||||
->and($ticket->timerElapsedSeconds())->toBe(45);
|
||||
});
|
||||
|
||||
test('resetting the timer zeroes the saved total and stops the running segment', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-reset@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
$ticket->refresh();
|
||||
|
||||
$this->travel(30)->seconds();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('resetTimer');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->timer_started_at)->toBeNull()
|
||||
->and($ticket->time_spent_seconds)->toBe(0);
|
||||
});
|
||||
|
||||
test('an operator action checkpoints the running timer via flushTimer without pausing it', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-flush@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
$ticket->refresh();
|
||||
|
||||
$this->travel(40)->seconds();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('setPriority', 'high');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->timer_started_at)->not->toBeNull()
|
||||
->and($ticket->time_spent_seconds)->toBe(40);
|
||||
});
|
||||
|
||||
test('an operator can manually correct the tracked time while the timer is stopped', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-edit-stopped@example.com');
|
||||
$ticket = makeTicket(['time_spent_seconds' => 45, 'timer_started_at' => null]);
|
||||
|
||||
// Mounting resumes the timer (it now only tracks time the ticket is
|
||||
// actually open), so stop it explicitly to get back to a stopped state.
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('stopTimer')
|
||||
->call('startEditTimer')
|
||||
->assertSet('editTimerHours', '0')
|
||||
->assertSet('editTimerMinutes', '0')
|
||||
->assertSet('editTimerSeconds', '45')
|
||||
->set('editTimerHours', '2')
|
||||
->set('editTimerMinutes', '30')
|
||||
->set('editTimerSeconds', '0')
|
||||
->call('saveEditTimer')
|
||||
->assertSet('editingTimer', false);
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->time_spent_seconds)->toBe(9000) // 2h30m
|
||||
->and($ticket->timer_started_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('editing the time while the timer is running re-bases the running segment instead of stacking on top', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-edit-running@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
$ticket->refresh();
|
||||
|
||||
$this->travel(20)->seconds();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('startEditTimer')
|
||||
->set('editTimerHours', '1')
|
||||
->set('editTimerMinutes', '0')
|
||||
->set('editTimerSeconds', '0')
|
||||
->call('saveEditTimer');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->time_spent_seconds)->toBe(3600)
|
||||
->and($ticket->timer_started_at)->not->toBeNull()
|
||||
->and($ticket->fresh()->timerElapsedSeconds())->toBe(3600);
|
||||
});
|
||||
|
||||
test('the stop-timer beacon endpoint checkpoints and stops a running timer', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->travelTo(now());
|
||||
$operator = operatorUser('timer-beacon@example.com');
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||
$ticket->refresh();
|
||||
|
||||
$this->travel(50)->seconds();
|
||||
|
||||
$this->actingAs($operator)
|
||||
->post(route('operator.ticket.stop-timer', $ticket))
|
||||
->assertNoContent();
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->timer_started_at)->toBeNull()
|
||||
->and($ticket->time_spent_seconds)->toBe(50);
|
||||
});
|
||||
|
||||
test('cancelling the timer edit leaves the tracked time untouched', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('timer-edit-cancel@example.com');
|
||||
$ticket = makeTicket(['time_spent_seconds' => 45, 'timer_started_at' => null]);
|
||||
|
||||
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||
->call('startEditTimer')
|
||||
->set('editTimerHours', '9')
|
||||
->call('cancelEditTimer')
|
||||
->assertSet('editingTimer', false);
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->time_spent_seconds)->toBe(45);
|
||||
});
|
||||
48
src/tests/Feature/TimezoneConfigTest.php
Normal file
48
src/tests/Feature/TimezoneConfigTest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can save a custom timezone from the Konfiguracja tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('systemConfig.timezone', 'Europe/Warsaw')
|
||||
->call('saveSystemConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('timezone'))->toBe('Europe/Warsaw');
|
||||
});
|
||||
|
||||
test('an unrecognized timezone identifier is silently rejected, keeping the previous value', function () {
|
||||
$admin = adminUser();
|
||||
Settings::set('timezone', 'Europe/Warsaw');
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->set('systemConfig.timezone', 'Not/A_Real_Zone')
|
||||
->call('saveSystemConfig')
|
||||
->assertOk();
|
||||
|
||||
expect(Settings::get('timezone'))->toBe('Europe/Warsaw');
|
||||
});
|
||||
|
||||
test('Settings::timezone() falls back to the default when the stored value is invalid', function () {
|
||||
Settings::set('timezone', 'Not/A_Real_Zone');
|
||||
|
||||
expect(Settings::timezone())->toBe('UTC');
|
||||
});
|
||||
|
||||
test('AppServiceProvider overrides app.timezone and the PHP default timezone from the saved setting', function () {
|
||||
Settings::set('timezone', 'Europe/Warsaw');
|
||||
|
||||
(new AppServiceProvider(app()))->boot();
|
||||
|
||||
expect(config('app.timezone'))->toBe('Europe/Warsaw')
|
||||
->and(date_default_timezone_get())->toBe('Europe/Warsaw');
|
||||
|
||||
date_default_timezone_set('UTC'); // restore, since this is a process-wide PHP setting
|
||||
});
|
||||
185
src/tests/Feature/UserFieldsTest.php
Normal file
185
src/tests/Feature/UserFieldsTest.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Livewire\Operator\TicketShow;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
use Livewire\Livewire;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
});
|
||||
|
||||
test('admin can create, edit and delete a user field', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'user-fields')
|
||||
->call('openUserFieldForm')
|
||||
->set('userFieldForm.label', 'Numer telefonu')
|
||||
->set('userFieldForm.type', 'text')
|
||||
->set('userFieldForm.ldapAttribute', 'mobile')
|
||||
->call('submitUserField')
|
||||
->assertOk();
|
||||
|
||||
$field = UserField::query()->where('label', 'Numer telefonu')->firstOrFail();
|
||||
expect($field->ldap_attribute)->toBe('mobile');
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editUserField', $field->id)
|
||||
->assertSet('userFieldForm.label', 'Numer telefonu')
|
||||
->set('userFieldForm.label', 'Telefon komórkowy')
|
||||
->call('submitUserField')
|
||||
->assertOk();
|
||||
|
||||
expect($field->fresh()->label)->toBe('Telefon komórkowy');
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('removeUserField', $field->id)
|
||||
->assertSet('pendingDeleteType', 'user-field')
|
||||
->call('confirmPendingDelete')
|
||||
->assertOk();
|
||||
|
||||
expect(UserField::query()->find($field->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('user field values only show up and save when editing a user, never when inviting one', function () {
|
||||
$admin = adminUser();
|
||||
$op = operatorUser('phonefield@example.com');
|
||||
|
||||
$field = UserField::query()->create(['label' => 'Numer telefonu', 'type' => 'text', 'sort_order' => 1]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'users')
|
||||
->call('openUserForm')
|
||||
->assertSet('userForm.fieldValues', []);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('editUser', $op->id)
|
||||
->set("userForm.fieldValues.{$field->id}", '+48 111 222 333')
|
||||
->call('submitUser')
|
||||
->assertOk();
|
||||
|
||||
expect($op->fresh()->custom_field_values)->toBe([(string) $field->id => '+48 111 222 333']);
|
||||
});
|
||||
|
||||
test('the ldap sync button pulls attribute values for existing users into their user fields', function () {
|
||||
$fake = DirectoryEmulator::setup();
|
||||
$admin = adminUser();
|
||||
$op = operatorUser('anna.sync@firma.pl');
|
||||
|
||||
$field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'ldap_attribute' => 'departmentNumber', 'sort_order' => 1]);
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'anna.sync',
|
||||
'cn' => 'Anna Sync',
|
||||
'mail' => 'anna.sync@firma.pl',
|
||||
'departmentnumber' => 'IT-42',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'users')
|
||||
->call('syncUsersWithLdap')
|
||||
->assertOk();
|
||||
|
||||
expect($op->fresh()->custom_field_values)->toBe([(string) $field->id => 'IT-42']);
|
||||
});
|
||||
|
||||
test('a first-time guest ticket from a known ldap e-mail auto-provisions the account and fills its ldap-backed fields', function () {
|
||||
$fake = DirectoryEmulator::setup();
|
||||
|
||||
$field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'ldap_attribute' => 'departmentnumber', 'sort_order' => 1]);
|
||||
|
||||
LldapUser::create([
|
||||
'uid' => 'nowy.klient',
|
||||
'cn' => 'Nowy Klient',
|
||||
'mail' => 'nowy.klient@firma.pl',
|
||||
'departmentnumber' => 'HR-7',
|
||||
'entryuuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
expect(User::query()->where('email', 'nowy.klient@firma.pl')->exists())->toBeFalse();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'nowy.klient@firma.pl',
|
||||
'subject' => 'Test',
|
||||
'body' => 'Treść zgłoszenia',
|
||||
], null);
|
||||
|
||||
$user = User::query()->where('email', 'nowy.klient@firma.pl')->first();
|
||||
|
||||
expect($user)->not->toBeNull()
|
||||
->and($user->name)->toBe('Nowy Klient')
|
||||
->and($user->roles)->toBe(['client'])
|
||||
->and($user->custom_field_values)->toBe([(string) $field->id => 'HR-7'])
|
||||
->and($ticket->customer_id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('a guest ticket from an e-mail unknown to ldap stays anonymous, as before', function () {
|
||||
DirectoryEmulator::setup();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => 'ktos@nieznany.pl',
|
||||
'subject' => 'Test',
|
||||
'body' => 'Treść zgłoszenia',
|
||||
], null);
|
||||
|
||||
expect($ticket->customer_id)->toBeNull()
|
||||
->and(User::query()->where('email', 'ktos@nieznany.pl')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the operator ticket view shows the reporting customer\'s user field values', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('op-view@example.com');
|
||||
$client = User::query()->create(['name' => 'Reporter Kowalski', 'email' => 'reporter@example.com', 'roles' => ['client']]);
|
||||
|
||||
$field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]);
|
||||
$client->update(['custom_field_values' => [$field->id => 'Księgowość']]);
|
||||
|
||||
$ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->assertSee('Dział')
|
||||
->assertSee('Księgowość');
|
||||
});
|
||||
|
||||
test('the operator ticket view does not blow up for a reporter with no field values set', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('op-view2@example.com');
|
||||
UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
|
||||
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('the admin "Zobacz" button shows every detail about a user, including their field values', function () {
|
||||
$admin = adminUser();
|
||||
$op = operatorUser('zobacz@example.com');
|
||||
|
||||
$team = Team::query()->create(['name' => 'Infrastruktura']);
|
||||
$op->teams()->attach($team->id);
|
||||
|
||||
$field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]);
|
||||
$op->update(['custom_field_values' => [$field->id => 'IT-42']]);
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'users')
|
||||
->call('viewUser', $op->id)
|
||||
->assertSee('zobacz@example.com')
|
||||
->assertSee('Infrastruktura')
|
||||
->assertSee('Dział')
|
||||
->assertSee('IT-42')
|
||||
->call('editUser', $op->id)
|
||||
->assertSet('viewingUserId', null)
|
||||
->assertSet('userForm.id', $op->id);
|
||||
});
|
||||
66
src/tests/Feature/UsersApiTest.php
Normal file
66
src/tests/Feature/UsersApiTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
test('index lists users without exposing password or remember_token', function () {
|
||||
User::query()->create(['name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client']]);
|
||||
User::query()->create(['name' => 'Marek', 'email' => 'marek@example.com', 'roles' => ['operator']]);
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['users:read']);
|
||||
|
||||
$response = $this->getJson('/api/v1/users')->assertOk()->assertJsonCount(2, 'data');
|
||||
|
||||
$response->assertJsonMissingPath('data.0.password');
|
||||
$response->assertJsonMissingPath('data.0.remember_token');
|
||||
});
|
||||
|
||||
test('index filters by role', function () {
|
||||
User::query()->create(['name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client']]);
|
||||
User::query()->create(['name' => 'Marek', 'email' => 'marek@example.com', 'roles' => ['operator']]);
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['users:read']);
|
||||
|
||||
$this->getJson('/api/v1/users?role=operator')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.name', 'Marek');
|
||||
});
|
||||
|
||||
test('show returns a single user', function () {
|
||||
$user = User::query()->create(['name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client']]);
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['users:read']);
|
||||
|
||||
$this->getJson("/api/v1/users/{$user->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.email', 'anna@example.com');
|
||||
});
|
||||
|
||||
test('show returns the user\'s custom field values', function () {
|
||||
$field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 0]);
|
||||
$user = User::query()->create([
|
||||
'name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client'],
|
||||
'custom_field_values' => [$field->id => 'Księgowość'],
|
||||
]);
|
||||
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['users:read']);
|
||||
|
||||
$this->getJson("/api/v1/users/{$user->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.custom_fields.0.label', 'Dział')
|
||||
->assertJsonPath('data.custom_fields.0.value', 'Księgowość');
|
||||
});
|
||||
|
||||
test('users require the users:read ability', function () {
|
||||
$client = ApiClient::factory()->create();
|
||||
Sanctum::actingAs($client, ['tickets:read']);
|
||||
|
||||
$this->getJson('/api/v1/users')->assertForbidden();
|
||||
});
|
||||
Reference in New Issue
Block a user