feat: AfterSales CRM - SOP compliant real-estate customer care system
- Departments + cross-department transfer workflow - Role-based closing (staff→resolved, manager→closed) with proof_of_resolution - Private file storage with collection system + temporary signed URLs - Dashboard: resolved tickets table, stats, handler performance bar chart - Spatie RBAC (admin/manager/staff) - Notifications: TicketTransferred, TicketClosed (database + mail) - Pest tests: 8 tests, 21 assertions - Docker production-ready (Dockerfile + compose + entrypoint)
This commit is contained in:
170
tests/Feature/ClosingPermissionTest.php
Normal file
170
tests/Feature/ClosingPermissionTest.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\FeedbackAttachment;
|
||||
use App\Models\FeedbackChannel;
|
||||
use App\Models\User;
|
||||
use App\Services\ClosingService;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
// ─── Scenario 1: Staff cannot close ticket ───────────────────────────
|
||||
test('staff cannot close ticket - returns 403 forbidden', function () {
|
||||
\Spatie\Permission\Models\Role::create(['name' => 'staff']);
|
||||
|
||||
$staff = User::factory()->create();
|
||||
$staff->assignRole('staff');
|
||||
|
||||
$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',
|
||||
]);
|
||||
|
||||
$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('Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu.');
|
||||
}
|
||||
|
||||
expect($feedback->fresh()->status)->toBe('resolved');
|
||||
});
|
||||
|
||||
// ─── Scenario 2: Manager cannot close without proof_of_resolution ────
|
||||
test('manager cannot close ticket without proof_of_resolution evidence', function () {
|
||||
\Spatie\Permission\Models\Role::create(['name' => 'manager']);
|
||||
|
||||
$manager = User::factory()->create();
|
||||
$manager->assignRole('manager');
|
||||
|
||||
$dept = Department::create(['name' => 'CSKH', 'manager_id' => $manager->id]);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Email', 'slug' => 'email-2']);
|
||||
$customer = Customer::create(['name' => 'Test Customer 2', 'email' => 'test2@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Test Feedback 2',
|
||||
'content' => 'Test content',
|
||||
'status' => 'resolved',
|
||||
'current_department_id' => $dept->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($manager);
|
||||
|
||||
$closingService = app(ClosingService::class);
|
||||
|
||||
try {
|
||||
$closingService->close($feedback, $manager);
|
||||
$this->fail('Expected ValidationException but none thrown.');
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
expect($e->errors()['status'][0])->toBe('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.');
|
||||
}
|
||||
|
||||
expect($feedback->fresh()->status)->toBe('resolved');
|
||||
});
|
||||
|
||||
// ─── Scenario 2b: Manager CAN close with proof_of_resolution ─────────
|
||||
test('manager can close ticket with proof_of_resolution evidence', function () {
|
||||
\Illuminate\Support\Facades\Notification::fake();
|
||||
\Spatie\Permission\Models\Role::create(['name' => 'manager']);
|
||||
|
||||
$manager = User::factory()->create();
|
||||
$manager->assignRole('manager');
|
||||
|
||||
$dept = Department::create(['name' => 'Ky Thuat', 'manager_id' => $manager->id]);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone']);
|
||||
$customer = Customer::create(['name' => 'Test Customer 3', 'email' => 'test3@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Test Feedback 3',
|
||||
'content' => 'Test content',
|
||||
'status' => 'resolved',
|
||||
'current_department_id' => $dept->id,
|
||||
]);
|
||||
|
||||
FeedbackAttachment::create([
|
||||
'uuid' => 'test-uuid-123',
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => $manager->id,
|
||||
'name' => 'proof.pdf',
|
||||
'path' => 'feedback-attachments/proof.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');
|
||||
});
|
||||
|
||||
// ─── Scenario 2c: Manager cannot close ticket from other department ──
|
||||
test('manager cannot close ticket from department they do not manage', function () {
|
||||
\Spatie\Permission\Models\Role::create(['name' => 'manager']);
|
||||
|
||||
$manager = User::factory()->create();
|
||||
$manager->assignRole('manager');
|
||||
|
||||
$deptA = Department::create(['name' => 'CSKH', 'manager_id' => $manager->id]);
|
||||
$deptB = Department::create(['name' => 'Ky Thuat', 'manager_id' => null]);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-2']);
|
||||
$customer = Customer::create(['name' => 'Test Customer 4', 'email' => 'test4@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Test Feedback 4',
|
||||
'content' => 'Test content',
|
||||
'status' => 'resolved',
|
||||
'current_department_id' => $deptB->id,
|
||||
]);
|
||||
|
||||
FeedbackAttachment::create([
|
||||
'uuid' => 'test-uuid-456',
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => $manager->id,
|
||||
'name' => 'proof.pdf',
|
||||
'path' => 'feedback-attachments/proof2.pdf',
|
||||
'disk' => 'local',
|
||||
'collection' => 'proof_of_resolution',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 1024,
|
||||
]);
|
||||
|
||||
$this->actingAs($manager);
|
||||
|
||||
$closingService = app(ClosingService::class);
|
||||
|
||||
try {
|
||||
$closingService->close($feedback, $manager);
|
||||
$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');
|
||||
});
|
||||
108
tests/Feature/TransferFlowTest.php
Normal file
108
tests/Feature/TransferFlowTest.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\FeedbackChannel;
|
||||
use App\Models\TicketTransferLog;
|
||||
use App\Models\User;
|
||||
use App\Services\TransferService;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
// ─── Scenario 3: Transfer Flow ──────────────────────────────────────
|
||||
test('transfer creates log, updates department, and notifies manager', function () {
|
||||
\Spatie\Permission\Models\Role::create(['name' => 'manager']);
|
||||
|
||||
\Illuminate\Support\Facades\Notification::fake();
|
||||
|
||||
$sender = User::factory()->create();
|
||||
$sender->assignRole('manager');
|
||||
|
||||
$deptManager = User::factory()->create(['name' => 'Tech Manager']);
|
||||
$deptManager->assignRole('manager');
|
||||
|
||||
$fromDept = Department::create(['name' => 'CSKH', 'manager_id' => null]);
|
||||
$toDept = Department::create(['name' => 'Ky Thuat', 'manager_id' => $deptManager->id]);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Email', 'slug' => 'email-transfer']);
|
||||
$customer = Customer::create(['name' => 'Test Transfer', 'email' => 'transfer@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Transfer Test Ticket',
|
||||
'content' => 'Test content',
|
||||
'status' => 'pending',
|
||||
'current_department_id' => $fromDept->id,
|
||||
'assigned_to' => $sender->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($sender);
|
||||
|
||||
$transferService = app(TransferService::class);
|
||||
$log = $transferService->transfer(
|
||||
$feedback,
|
||||
$toDept,
|
||||
'Can chuyen sang phong ky thuat de kiem tra',
|
||||
$sender,
|
||||
);
|
||||
|
||||
// Assert log created
|
||||
expect($log)->toBeInstanceOf(TicketTransferLog::class);
|
||||
expect($log->from_department_id)->toBe($fromDept->id);
|
||||
expect($log->to_department_id)->toBe($toDept->id);
|
||||
expect($log->sender_id)->toBe($sender->id);
|
||||
expect($log->reason)->toBe('Can chuyen sang phong ky thuat de kiem tra');
|
||||
|
||||
// Assert department updated
|
||||
$feedback->refresh();
|
||||
expect($feedback->current_department_id)->toBe($toDept->id);
|
||||
expect($feedback->assignedTo->id)->toBe($deptManager->id);
|
||||
|
||||
// Assert notification sent to target department manager
|
||||
\Illuminate\Support\Facades\Notification::assertSentTo(
|
||||
$deptManager,
|
||||
\App\Notifications\TicketTransferred::class,
|
||||
function ($notification) use ($feedback, $sender) {
|
||||
return $notification->feedback->id === $feedback->id
|
||||
&& $notification->sender->id === $sender->id;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Transfer without reason should fail ────────────────────────────
|
||||
test('transfer without reason throws validation error', function () {
|
||||
\Spatie\Permission\Models\Role::create(['name' => 'manager']);
|
||||
|
||||
$sender = User::factory()->create();
|
||||
$sender->assignRole('manager');
|
||||
|
||||
$fromDept = Department::create(['name' => 'CSKH']);
|
||||
$toDept = Department::create(['name' => 'Ky Thuat']);
|
||||
|
||||
$channel = FeedbackChannel::create(['name' => 'Zalo', 'slug' => 'zalo-transfer']);
|
||||
$customer = Customer::create(['name' => 'Test', 'email' => 'fail@test.com']);
|
||||
|
||||
$feedback = Feedback::create([
|
||||
'customer_id' => $customer->id,
|
||||
'feedback_channel_id' => $channel->id,
|
||||
'title' => 'Fail Transfer',
|
||||
'content' => 'Test',
|
||||
'status' => 'pending',
|
||||
'current_department_id' => $fromDept->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($sender);
|
||||
|
||||
$transferService = app(TransferService::class);
|
||||
|
||||
try {
|
||||
$transferService->transfer($feedback, $toDept, ' ', $sender);
|
||||
$this->fail('Expected ValidationException but none thrown.');
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
expect($e->errors()['reason'][0])->toContain('Vui lòng nhập lý do chuyển tiếp');
|
||||
}
|
||||
|
||||
expect(TicketTransferLog::count())->toBe(0);
|
||||
});
|
||||
5
tests/Pest.php
Normal file
5
tests/Pest.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
uses(
|
||||
Tests\TestCase::class,
|
||||
)->in('Feature');
|
||||
Reference in New Issue
Block a user