v1.5.0
Co nowego: - Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów i auto-odświeżaniem. - Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez przypisanej kategorii/podkategorii. - Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto), łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po e-mailu, nowe flagi --assign-operators i --fix-closed-dates do donaprawiania wcześniejszych importów, dedykowany log storage/logs/hesk-import.log. - Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez podkategorii (np. z poczty IMAP) — teraz liczone poprawnie. - Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy operatora). - Porządki w bazie: usunięte niewykorzystywane kolumny (users.remember_token, users.email_verified_at, email_templates.trigger_label); wartości pól dodatkowych, stan triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries, ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach pivot; tickets.source/ticket_messages.source walidowane względem znanego zestawu wartości. Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md, CHANGELOG.md, wiki/admin, wiki/operator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
78
src/tests/Feature/AdminLogsViewerTest.php
Normal file
78
src/tests/Feature/AdminLogsViewerTest.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Logs;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function adminUserForLogsTest(): User
|
||||
{
|
||||
return User::query()->create(['name' => 'Admin', 'email' => 'admin-logs@example.com', 'roles' => ['admin']]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->logPath = storage_path('logs/pest-admin-logs-test.log');
|
||||
file_put_contents($this->logPath, implode("\n", [
|
||||
'[2026-08-05 08:00:00] production.INFO: normalny wpis o połączeniu',
|
||||
'[2026-08-05 08:01:00] production.ERROR: coś się nie udało',
|
||||
'#0 stack trace line belonging to the ERROR entry above',
|
||||
'[2026-08-05 08:02:00] production.WARNING: uwaga na przyszłość',
|
||||
])."\n");
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@unlink($this->logPath);
|
||||
});
|
||||
|
||||
test('admin sees the log file list and its tail content', function () {
|
||||
$admin = adminUserForLogsTest();
|
||||
|
||||
Livewire::actingAs($admin)->test(Logs::class)
|
||||
->set('selectedFile', 'pest-admin-logs-test.log')
|
||||
->assertSee('pest-admin-logs-test.log')
|
||||
->assertSee('normalny wpis o połączeniu')
|
||||
->assertSee('coś się nie udało')
|
||||
->assertSee('stack trace line belonging to the ERROR entry above');
|
||||
});
|
||||
|
||||
test('multi-line entries stay grouped and level filter narrows to matching entries', function () {
|
||||
$admin = adminUserForLogsTest();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Logs::class)
|
||||
->set('selectedFile', 'pest-admin-logs-test.log');
|
||||
|
||||
expect($component->get('entries'))->toHaveCount(3);
|
||||
|
||||
$component->set('levelFilter', 'ERROR');
|
||||
$entries = $component->get('entries');
|
||||
|
||||
expect($entries)->toHaveCount(1)
|
||||
->and($entries[0]['text'])->toContain('coś się nie udało')
|
||||
->and($entries[0]['text'])->toContain('stack trace line belonging to the ERROR entry above');
|
||||
});
|
||||
|
||||
test('search filters entries by substring', function () {
|
||||
$admin = adminUserForLogsTest();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Logs::class)
|
||||
->set('selectedFile', 'pest-admin-logs-test.log')
|
||||
->set('search', 'uwaga');
|
||||
|
||||
expect($component->get('entries'))->toHaveCount(1)
|
||||
->and($component->get('entries')[0]['text'])->toContain('uwaga na przyszłość');
|
||||
});
|
||||
|
||||
test('selecting an unknown file name is ignored, preventing path traversal via the public property', function () {
|
||||
$admin = adminUserForLogsTest();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Logs::class)
|
||||
->set('selectedFile', 'pest-admin-logs-test.log')
|
||||
->call('selectFile', '../../.env');
|
||||
|
||||
expect($component->get('selectedFile'))->toBe('pest-admin-logs-test.log');
|
||||
});
|
||||
|
||||
test('non-admin cannot open the admin panel logs tab', function () {
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-logs@example.com', 'roles' => ['operator']]);
|
||||
|
||||
$this->actingAs($operator)->get('/admin')->assertForbidden();
|
||||
});
|
||||
59
src/tests/Feature/StatsCategoryBreakdownTest.php
Normal file
59
src/tests/Feature/StatsCategoryBreakdownTest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Stats;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('byCategory counts tickets routed to a bare category (no subcategory) alongside subcategorized ones', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat@example.com', 'roles' => ['admin']]);
|
||||
|
||||
$it = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$vpn = $it->subcategories()->create(['name' => 'VPN']);
|
||||
$delegacje = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
makeTicket(['number' => '7001', 'subcategory_id' => $vpn->id]);
|
||||
makeTicket(['number' => '7002', 'subcategory_id' => $vpn->id]);
|
||||
// Routed to a whole category with no subcategory (e.g. an IMAP mailbox
|
||||
// routed to "całą kategorię") — previously invisible to byCategory().
|
||||
makeTicket(['number' => '7003', 'category_id' => $delegacje->id]);
|
||||
makeTicket(['number' => '7004', 'category_id' => $delegacje->id]);
|
||||
makeTicket(['number' => '7005', 'category_id' => $delegacje->id]);
|
||||
|
||||
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCategory;
|
||||
|
||||
expect($rows->firstWhere('label', 'IT-Pomoc'))->toBe(['label' => 'IT-Pomoc', 'count' => 2])
|
||||
->and($rows->firstWhere('label', 'Delegacje'))->toBe(['label' => 'Delegacje', 'count' => 3])
|
||||
->and($rows->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('byCategory sums both bare-category and subcategorized tickets into the same category total', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat-2@example.com', 'roles' => ['admin']]);
|
||||
|
||||
$it = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$vpn = $it->subcategories()->create(['name' => 'VPN']);
|
||||
|
||||
makeTicket(['number' => '7101', 'subcategory_id' => $vpn->id]);
|
||||
makeTicket(['number' => '7102', 'category_id' => $it->id]);
|
||||
|
||||
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCategory;
|
||||
|
||||
expect($rows->firstWhere('label', 'IT-Pomoc'))->toBe(['label' => 'IT-Pomoc', 'count' => 2]);
|
||||
});
|
||||
|
||||
test('filterCategory includes tickets routed to a bare category with no subcategory', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cat-3@example.com', 'roles' => ['admin']]);
|
||||
|
||||
$delegacje = Category::query()->create(['name' => 'Delegacje']);
|
||||
makeTicket(['number' => '7201', 'category_id' => $delegacje->id]);
|
||||
makeTicket(['number' => '7202']);
|
||||
|
||||
$kpis = Livewire::actingAs($admin)->test(Stats::class)
|
||||
->set('filterCategory', (string) $delegacje->id)
|
||||
->instance()->kpis;
|
||||
|
||||
expect($kpis['total'])->toBe(1);
|
||||
});
|
||||
@@ -28,3 +28,22 @@ test('bySubcategory groups tickets per subcategory, labeled "Category / Subcateg
|
||||
->and($rows->firstWhere('label', 'Zamówienia / Sprzęt'))->toBe(['label' => 'Zamówienia / Sprzęt', 'count' => 1])
|
||||
->and($rows->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('bySubcategory gives tickets routed to a bare category (no subcategory) their own "(bez podkategorii)" row', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-sub-2@example.com', 'roles' => ['admin']]);
|
||||
|
||||
$it = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$vpn = $it->subcategories()->create(['name' => 'VPN']);
|
||||
$delegacje = Category::query()->create(['name' => 'Delegacje']);
|
||||
|
||||
makeTicket(['number' => '4101', 'subcategory_id' => $vpn->id]);
|
||||
makeTicket(['number' => '4102', 'category_id' => $delegacje->id]);
|
||||
makeTicket(['number' => '4103', 'category_id' => $delegacje->id]);
|
||||
|
||||
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->bySubcategory;
|
||||
|
||||
expect($rows->firstWhere('label', 'IT-Pomoc / VPN'))->toBe(['label' => 'IT-Pomoc / VPN', 'count' => 1])
|
||||
->and($rows->firstWhere('label', 'Delegacje (bez podkategorii)'))->toBe(['label' => 'Delegacje (bez podkategorii)', 'count' => 2])
|
||||
->and($rows->count())->toBe(2);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,18 @@ test('next ticket number is one above the current max', function () {
|
||||
expect(Ticket::nextNumber())->toBe('1002');
|
||||
});
|
||||
|
||||
test('next ticket number compares numerically, not lexicographically, across differing digit counts', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
// A plain string MAX()/ORDER BY would rank '999' above '1000' (lexicographic
|
||||
// "9" > "1"), which is exactly the bug nextNumber()'s length-first ordering
|
||||
// guards against — see Ticket::nextNumber().
|
||||
makeTicket(['number' => '999']);
|
||||
makeTicket(['number' => '1000']);
|
||||
|
||||
expect(Ticket::nextNumber())->toBe('1001');
|
||||
});
|
||||
|
||||
test('sla info reports overdue once the resolution deadline has passed', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
|
||||
50
src/tests/Feature/TicketFieldValuesSyncTest.php
Normal file
50
src/tests/Feature/TicketFieldValuesSyncTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\CustomField;
|
||||
|
||||
test('creating a ticket with custom_fields populates ticket_field_values with matching rows', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$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]);
|
||||
|
||||
$ticket = makeTicket(['subcategory_id' => $subcategory->id, 'custom_fields' => [$field->id => '123-456']]);
|
||||
|
||||
expect($ticket->fieldValues)->toHaveCount(1);
|
||||
expect($ticket->fieldValues->first()->custom_field_id)->toBe($field->id);
|
||||
expect($ticket->fieldValues->first()->value)->toBe('123-456');
|
||||
});
|
||||
|
||||
test('updating custom_fields keeps ticket_field_values in sync — added, changed and removed entries', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$fieldA = CustomField::query()->create(['label' => 'A', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
|
||||
$fieldB = CustomField::query()->create(['label' => 'B', 'type' => 'text', 'required' => false, 'sort_order' => 2]);
|
||||
$fieldA->subcategories()->attach($subcategory->id, ['position' => 1]);
|
||||
$fieldB->subcategories()->attach($subcategory->id, ['position' => 2]);
|
||||
|
||||
$ticket = makeTicket([
|
||||
'subcategory_id' => $subcategory->id,
|
||||
'custom_fields' => [$fieldA->id => 'first', $fieldB->id => 'second'],
|
||||
]);
|
||||
|
||||
$ticket->update(['custom_fields' => [$fieldA->id => 'changed']]);
|
||||
|
||||
expect($ticket->fieldValues()->count())->toBe(1);
|
||||
expect($ticket->fieldValues()->first()->value)->toBe('changed');
|
||||
});
|
||||
|
||||
test('a blank custom field value is not synced into ticket_field_values', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
$subcategory = $category->subcategories()->create(['name' => 'VPN']);
|
||||
$field = CustomField::query()->create(['label' => 'A', 'type' => 'text', 'required' => false, 'sort_order' => 1]);
|
||||
$field->subcategories()->attach($subcategory->id, ['position' => 1]);
|
||||
|
||||
$ticket = makeTicket(['subcategory_id' => $subcategory->id, 'custom_fields' => [$field->id => '']]);
|
||||
|
||||
expect($ticket->fieldValues()->count())->toBe(0);
|
||||
});
|
||||
38
src/tests/Feature/TicketSourceValidationTest.php
Normal file
38
src/tests/Feature/TicketSourceValidationTest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Ticket;
|
||||
|
||||
test('a ticket accepts every known source value', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
foreach (Ticket::SOURCES as $source) {
|
||||
$ticket = makeTicket(['number' => uniqid(), 'source' => $source]);
|
||||
expect($ticket->source)->toBe($source);
|
||||
}
|
||||
});
|
||||
|
||||
test('creating a ticket with an unrecognized source throws instead of silently storing it', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
expect(fn () => makeTicket(['source' => 'totally-made-up']))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
test('a ticket message accepts a null source (implicit web) and the known email source', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
$webMessage = $ticket->messages()->create(['author_name' => 'A', 'body' => 'web reply']);
|
||||
$mailMessage = $ticket->messages()->create(['author_name' => 'B', 'body' => 'mail reply', 'source' => 'email']);
|
||||
|
||||
expect($webMessage->source)->toBeNull()
|
||||
->and($mailMessage->source)->toBe('email');
|
||||
});
|
||||
|
||||
test('a ticket message with an unrecognized source throws instead of silently storing it', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$ticket = makeTicket();
|
||||
|
||||
expect(fn () => $ticket->messages()->create(['author_name' => 'A', 'body' => 'x', 'source' => 'totally-made-up']))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
Reference in New Issue
Block a user