Refactor: Enforce type safety, fix close deadlock, implement assignee restrictions and notify admin fallbacks
This commit is contained in:
@@ -187,3 +187,157 @@ test('manager cannot close ticket from department they do not manage', function
|
||||
|
||||
expect($feedback->fresh()->status)->toBe('resolved');
|
||||
});
|
||||
|
||||
// ─── Scenario 2d: Staff with close permission cannot close ticket from other department ──
|
||||
test('staff with close permission cannot close ticket from other department', function () {
|
||||
$staffRole = Role::create(['name' => 'staff']);
|
||||
$staffRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
|
||||
|
||||
// Staff belongs to Department A
|
||||
$deptA = Department::create(['name' => 'CSKH']);
|
||||
$staff = User::factory()->create(['department_id' => $deptA->id]);
|
||||
$staff->assignRole('staff');
|
||||
|
||||
// Ticket belongs to Department B
|
||||
$deptB = Department::create(['name' => 'Ky Thuat']);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-staff-close']);
|
||||
$customer = Customer::create(['name' => 'Test Customer Staff', 'email' => 'staff@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Test Feedback Staff',
|
||||
'content' => 'Test content',
|
||||
'status' => 'resolved',
|
||||
'current_department_id' => $deptB->id,
|
||||
]);
|
||||
|
||||
FeedbackAttachment::create([
|
||||
'uuid' => 'test-uuid-staff-1',
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => $staff->id,
|
||||
'name' => 'proof.pdf',
|
||||
'path' => 'feedback-attachments/proof_staff.pdf',
|
||||
'disk' => 'local',
|
||||
'collection' => 'proof_of_resolution',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 1024,
|
||||
]);
|
||||
|
||||
$this->actingAs($staff);
|
||||
|
||||
$closingService = app(ClosingService::class);
|
||||
|
||||
try {
|
||||
$closingService->close($feedback, $staff);
|
||||
$this->fail('Expected HttpException (403) but none thrown.');
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
expect($e->getStatusCode())->toBe(403);
|
||||
expect($e->getMessage())->toBe('Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.');
|
||||
}
|
||||
|
||||
expect($feedback->fresh()->status)->toBe('resolved');
|
||||
});
|
||||
|
||||
// ─── Scenario 2e: Staff with close permission can close ticket from same department ────
|
||||
test('staff with close permission can close ticket from same department', function () {
|
||||
$staffRole = Role::create(['name' => 'staff']);
|
||||
$staffRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
|
||||
|
||||
// Staff belongs to Department A
|
||||
$deptA = Department::create(['name' => 'CSKH']);
|
||||
$staff = User::factory()->create(['department_id' => $deptA->id]);
|
||||
$staff->assignRole('staff');
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-staff-close-success']);
|
||||
$customer = Customer::create(['name' => 'Test Customer Staff Success', 'email' => 'staff_success@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Test Feedback Staff Success',
|
||||
'content' => 'Test content',
|
||||
'status' => 'resolved',
|
||||
'current_department_id' => $deptA->id,
|
||||
]);
|
||||
|
||||
FeedbackAttachment::create([
|
||||
'uuid' => 'test-uuid-staff-2',
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => $staff->id,
|
||||
'name' => 'proof.pdf',
|
||||
'path' => 'feedback-attachments/proof_staff2.pdf',
|
||||
'disk' => 'local',
|
||||
'collection' => 'proof_of_resolution',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 1024,
|
||||
]);
|
||||
|
||||
$this->actingAs($staff);
|
||||
|
||||
$closingService = app(ClosingService::class);
|
||||
$closingService->close($feedback, $staff);
|
||||
|
||||
expect($feedback->fresh()->status)->toBe('closed');
|
||||
});
|
||||
|
||||
// ─── Scenario 2f: Notify admin when closing a department ticket with no manager ────────
|
||||
test('admin notification is sent when closing department ticket with no manager', function () {
|
||||
\Illuminate\Support\Facades\Notification::fake();
|
||||
|
||||
// Create admin role and user
|
||||
$adminRole = Role::create(['name' => 'admin']);
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$managerRole = Role::create(['name' => 'manager']);
|
||||
$managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
|
||||
|
||||
// Department with no manager
|
||||
$dept = Department::create(['name' => 'Unmanaged Dept', 'manager_id' => null]);
|
||||
|
||||
$manager = User::factory()->create(['department_id' => $dept->id]);
|
||||
$manager->assignRole('manager');
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-no-manager']);
|
||||
$customer = Customer::create(['name' => 'Customer', 'email' => 'no_manager@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Ticket',
|
||||
'content' => 'Content',
|
||||
'status' => 'resolved',
|
||||
'current_department_id' => $dept->id,
|
||||
]);
|
||||
|
||||
FeedbackAttachment::create([
|
||||
'uuid' => 'test-uuid-no-manager',
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => $manager->id,
|
||||
'name' => 'proof.pdf',
|
||||
'path' => 'feedback-attachments/proof3.pdf',
|
||||
'disk' => 'local',
|
||||
'collection' => 'proof_of_resolution',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 1024,
|
||||
]);
|
||||
|
||||
$this->actingAs($manager);
|
||||
|
||||
$closingService = app(ClosingService::class);
|
||||
$closingService->close($feedback, $manager);
|
||||
|
||||
expect($feedback->fresh()->status)->toBe('closed');
|
||||
|
||||
// Assert notification sent to admin
|
||||
\Illuminate\Support\Facades\Notification::assertSentTo(
|
||||
$admin,
|
||||
\App\Notifications\TicketClosed::class,
|
||||
function ($notification) use ($feedback, $manager) {
|
||||
return $notification->feedback->id === $feedback->id
|
||||
&& $notification->actor->id === $manager->id;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
72
tests/Feature/EditFeedbackTest.php
Normal file
72
tests/Feature/EditFeedbackTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\FeedbackChannel;
|
||||
use App\Models\User;
|
||||
use App\Filament\Resources\Feedback\Pages\EditFeedback;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Livewire\Livewire;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
|
||||
Permission::firstOrCreate(['name' => 'close-ticket']);
|
||||
Permission::firstOrCreate(['name' => 'view-feedback']);
|
||||
Permission::firstOrCreate(['name' => 'update-feedback']);
|
||||
Permission::firstOrCreate(['name' => 'transfer-department']);
|
||||
Permission::firstOrCreate(['name' => 'delete-feedback']);
|
||||
Permission::firstOrCreate(['name' => 'create-feedback']);
|
||||
});
|
||||
|
||||
test('can upload proof and close ticket in one save operation', function () {
|
||||
Storage::fake('public');
|
||||
|
||||
// Create manager who has close permission
|
||||
$managerRole = Role::create(['name' => 'manager']);
|
||||
$managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
|
||||
|
||||
$manager = User::factory()->create();
|
||||
$manager->assignRole('manager');
|
||||
|
||||
$dept = Department::create(['name' => 'CSKH', 'manager_id' => $manager->id]);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Email', 'slug' => 'email']);
|
||||
$customer = Customer::create(['name' => 'Test Customer', 'email' => 'test@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Test Feedback',
|
||||
'content' => 'Test content',
|
||||
'status' => 'resolved',
|
||||
'current_department_id' => $dept->id,
|
||||
]);
|
||||
|
||||
// Mock an uploaded file on disk
|
||||
$path = 'feedback-attachments/mock-proof.pdf';
|
||||
Storage::disk('public')->put($path, 'dummy content');
|
||||
|
||||
$this->actingAs($manager);
|
||||
|
||||
// Run Filament form test
|
||||
Livewire::test(EditFeedback::class, ['record' => $feedback->id])
|
||||
->fillForm([
|
||||
'status' => 'closed',
|
||||
'attachment_collection' => 'proof_of_resolution',
|
||||
'attachments' => [$path],
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
// Verify feedback is closed
|
||||
expect($feedback->fresh()->status)->toBe('closed');
|
||||
|
||||
// Verify attachment exists in database
|
||||
expect($feedback->attachments()->where('collection', 'proof_of_resolution')->exists())->toBeTrue();
|
||||
});
|
||||
@@ -116,11 +116,11 @@ test('cskh import command processes rows correctly in dry run and active modes',
|
||||
|
||||
$handover = Handover::where('product_id', $productA->id)->first();
|
||||
expect($handover)->not->toBeNull();
|
||||
expect($handover->handover_date)->toBe('2026-01-01');
|
||||
expect($handover->handover_date->format('Y-m-d'))->toBe('2026-01-01');
|
||||
|
||||
$contract = Contract::where('product_id', $productA->id)->first();
|
||||
expect($contract)->not->toBeNull();
|
||||
expect($contract->type)->toBe('lease'); // type is a string 'lease'
|
||||
expect($contract->type->value)->toBe('lease'); // type is ContractType enum
|
||||
|
||||
// Clean up temporary file
|
||||
@unlink($csvPath);
|
||||
|
||||
@@ -119,3 +119,44 @@ test('transfer without reason throws validation error', function () {
|
||||
|
||||
expect(TicketTransferLog::count())->toBe(0);
|
||||
});
|
||||
|
||||
// ─── Transfer to invalid handler should fail ─────────────────────────
|
||||
test('transfer to handler not in target department throws validation error', function () {
|
||||
$managerRole = Role::create(['name' => 'manager']);
|
||||
$managerRole->givePermissionTo(['transfer-department', 'view-feedback', 'create-feedback', 'update-feedback']);
|
||||
|
||||
$sender = User::factory()->create();
|
||||
$sender->assignRole('manager');
|
||||
|
||||
$fromDept = Department::create(['name' => 'CSKH']);
|
||||
$toDept = Department::create(['name' => 'Ky Thuat']);
|
||||
|
||||
// Handler belongs to Finance department
|
||||
$financeDept = Department::create(['name' => 'Tai Chinh']);
|
||||
$handler = User::factory()->create(['department_id' => $financeDept->id]);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-transfer']);
|
||||
$customer = Customer::create(['name' => 'Test', 'email' => 't@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Transfer To Invalid Handler',
|
||||
'content' => 'Test',
|
||||
'status' => 'pending',
|
||||
'current_department_id' => $fromDept->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($sender);
|
||||
|
||||
$transferService = app(TransferService::class);
|
||||
|
||||
try {
|
||||
$transferService->transfer($feedback, $toDept, 'Chuyen sang kiem tra', $sender, $handler->id);
|
||||
$this->fail('Expected ValidationException but none thrown.');
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
expect($e->errors()['new_assignee_id'][0])->toContain('Người xử lý được chọn không thuộc phòng ban đích');
|
||||
}
|
||||
|
||||
expect(TicketTransferLog::count())->toBe(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user