feat: hồ sơ khách (ngày sinh, tỉnh/phường theo mã), gộp menu + nút tắt, thông báo theo uuid
- PUT v1/client/update: gender theo enum ClientGender (0 Nam/1 Nữ), thêm
birthday (cột mới), province_code/ward_code validate theo bảng provinces/wards
(phường phải thuộc tỉnh). Migration nạp dữ liệu hành chính 2025 từ
province_ward_import.sql (chỉ bảng trống), API GET v1/provinces[/{code}/wards].
- CustomerObserver/OrderObserver log giá trị cũ bằng getRawOriginal: getOriginal
trả enum object, nối chuỗi gây 500 khi khách đổi giới tính.
- ClientResource: các cờ trả boolean, CCCD chỉ trả cccd_updated (không lộ ảnh/số).
- GET v1/setting/menu trả cả items + quick_links, bỏ v1/setting/quick-links.
- Thông báo admin/khách tra theo uuid thay vì id tự tăng.
- Chặn gửi template marketing cho khách đã tắt nhận email marketing.
- Xoá ProvinceWardSeeder và Customer::$updatable không dùng.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
12d5b4951c
commit
bf78f3b810
@@ -40,11 +40,11 @@ class ClientMenuTest extends TestCase
|
||||
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
|
||||
->getJson('/v1/setting/menu')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Dashboard')
|
||||
->assertJsonPath('data.0.children', [])
|
||||
->assertJsonPath('data.1.href', null)
|
||||
->assertJsonPath('data.1.children.0.href', '/dich-vu/vps')
|
||||
->assertJsonPath('data.1.children.0.disabled', true);
|
||||
->assertJsonPath('data.items.0.title', 'Dashboard')
|
||||
->assertJsonPath('data.items.0.children', [])
|
||||
->assertJsonPath('data.items.1.href', null)
|
||||
->assertJsonPath('data.items.1.children.0.href', '/dich-vu/vps')
|
||||
->assertJsonPath('data.items.1.children.0.disabled', true);
|
||||
}
|
||||
|
||||
public function test_rejects_unsafe_href_and_leaf_without_href(): void
|
||||
@@ -73,10 +73,10 @@ class ClientMenuTest extends TestCase
|
||||
->assertOk();
|
||||
|
||||
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
|
||||
->getJson('/v1/setting/quick-links')
|
||||
->getJson('/v1/setting/menu')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Tạo Cloud VPS')
|
||||
->assertJsonPath('data.0.disabled', false);
|
||||
->assertJsonPath('data.quick_links.0.title', 'Tạo Cloud VPS')
|
||||
->assertJsonPath('data.quick_links.0.disabled', false);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->putJson('/api/v1/administrator/client-menu/quick-links', ['items' => [
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use App\Jobs\Mail\SendMailCampaignRecipient;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Template;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MarketingEmailOptOutTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'mail-campaign.create', 'guard_name' => 'admin']));
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole($role);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
private function template(?string $key): Template
|
||||
{
|
||||
return Template::create([
|
||||
'type_id' => 0,
|
||||
'name' => 'T ' . uniqid(),
|
||||
'key' => $key,
|
||||
'format' => 'html',
|
||||
'subject' => 'Ưu đãi',
|
||||
'message' => '<p>Hello</p>',
|
||||
'status' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_marketing_campaign_skips_opted_out_customers_but_system_template_does_not(): void
|
||||
{
|
||||
Queue::fake();
|
||||
$admin = $this->admin();
|
||||
$subscribed = Customer::factory()->create(['email_marketing' => 1]);
|
||||
$optedOut = Customer::factory()->create(['email_marketing' => 0]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson('/api/v1/administrator/mail-campaigns', [
|
||||
'template_id' => $this->template(null)->id,
|
||||
'recipient_type' => 'selected',
|
||||
'uuids' => [$subscribed->uuid, $optedOut->uuid],
|
||||
'emails' => [strtoupper($optedOut->email)],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total_recipients', 1);
|
||||
|
||||
Queue::assertPushed(SendMailCampaignRecipient::class, 1);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson('/api/v1/administrator/mail-campaigns', [
|
||||
'template_id' => $this->template('service_renewal_reminder')->id,
|
||||
'recipient_type' => 'selected',
|
||||
'uuids' => [$optedOut->uuid],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total_recipients', 1);
|
||||
}
|
||||
|
||||
public function test_marketing_campaign_with_only_opted_out_recipients_is_rejected(): void
|
||||
{
|
||||
Queue::fake();
|
||||
$optedOut = Customer::factory()->create(['email_marketing' => 0]);
|
||||
|
||||
$this->actingAs($this->admin(), 'admin')
|
||||
->postJson('/api/v1/administrator/mail-campaigns', [
|
||||
'subject' => 'Tin tức',
|
||||
'message' => '<p>Hi</p>',
|
||||
'recipient_type' => 'selected',
|
||||
'uuids' => [$optedOut->uuid],
|
||||
])
|
||||
->assertStatus(422);
|
||||
|
||||
Queue::assertNotPushed(SendMailCampaignRecipient::class);
|
||||
}
|
||||
}
|
||||
@@ -142,24 +142,45 @@ class ClientAuthSecurityTest extends TestCase
|
||||
$this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_cccd_images_are_private_and_served_only_via_signed_url(): void
|
||||
public function test_cccd_details_are_not_exposed_to_client(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Storage::disk('local')->put('cccd/front.jpg', 'img');
|
||||
Storage::disk('local')->put('cccd/back.jpg', 'img');
|
||||
$customer = Customer::factory()->create([
|
||||
'status' => 1,
|
||||
'identity_number' => '079123456789',
|
||||
'front_identity_number' => 'cccd/front.jpg',
|
||||
'back_identity_number' => 'cccd/back.jpg',
|
||||
]);
|
||||
|
||||
$me = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')->assertOk();
|
||||
$data = $me->json('data.client.customer');
|
||||
|
||||
$this->assertSame('********6789', $data['cmnd']);
|
||||
$this->assertStringStartsWith('/v1/client/cccd/' . $customer->id . '/front?expires=', $data['cmnd_before']);
|
||||
$this->assertTrue($data['cccd_updated']);
|
||||
$this->assertArrayNotHasKey('cmnd', $data);
|
||||
$this->assertArrayNotHasKey('cmnd_before', $data);
|
||||
$this->assertArrayNotHasKey('cmnd_after', $data);
|
||||
$this->api()->get("/v1/client/cccd/{$customer->id}/front")->assertNotFound();
|
||||
}
|
||||
|
||||
$this->api()->get('/v1/client/cccd/' . $customer->id . '/front')->assertStatus(403);
|
||||
$this->api()->get($data['cmnd_before'])->assertOk();
|
||||
public function test_phone_is_masked_to_last_three_digits_in_client_response(): void
|
||||
{
|
||||
$customer = Customer::factory()->create([
|
||||
'status' => 1,
|
||||
'phone' => '0929768789',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.client.customer.phone', '*******789');
|
||||
|
||||
$this->actingAs($customer->fresh(), 'client')->api()->putJson('/v1/client/update', [
|
||||
'fullname' => 'Bảo',
|
||||
'company' => 'Công ty mới',
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame('0929768789', $customer->fresh()->phone);
|
||||
}
|
||||
|
||||
public function test_access_token_is_revoked_when_password_changes(): void
|
||||
@@ -262,14 +283,62 @@ class ClientAuthSecurityTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_legacy_cccd_path_is_not_exposed(): void
|
||||
public function test_incomplete_legacy_cccd_is_reported_only_as_not_updated(): void
|
||||
{
|
||||
$customer = Customer::factory()->create(['status' => 1, 'front_identity_number' => '/uploads/2023/11/06/abc.jpg']);
|
||||
|
||||
$url = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')
|
||||
->json('data.client.customer.cmnd_before');
|
||||
$data = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')
|
||||
->assertOk()
|
||||
->json('data.client.customer');
|
||||
|
||||
$this->assertStringNotContainsString('uploads', $url);
|
||||
$this->api()->get($url)->assertNotFound();
|
||||
$this->assertFalse($data['cccd_updated']);
|
||||
$this->assertArrayNotHasKey('cmnd_before', $data);
|
||||
}
|
||||
|
||||
public function test_profile_update_accepts_gender_enum_values_only(): void
|
||||
{
|
||||
$customer = Customer::factory()->create(['status' => 1, 'gender' => 0])->fresh();
|
||||
$payload = ['fullname' => 'Bảo', 'phone' => '0929768789'];
|
||||
|
||||
$this->actingAs($customer, 'client')->api()
|
||||
->putJson('/v1/client/update', $payload + ['gender' => 1])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.client.customer.gender', 1)
|
||||
->assertJsonPath('data.client.customer.gender_label', 'Nữ');
|
||||
|
||||
$this->actingAs($customer->fresh(), 'client')->api()
|
||||
->putJson('/v1/client/update', $payload + ['gender' => 2])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_profile_update_saves_birthday_and_validates_ward_belongs_to_province(): void
|
||||
{
|
||||
$customer = Customer::factory()->create(['status' => 1])->fresh();
|
||||
$payload = ['fullname' => 'Bảo', 'phone' => '0929768789'];
|
||||
|
||||
$this->actingAs($customer, 'client')->api()
|
||||
->putJson('/v1/client/update', $payload + [
|
||||
'birthday' => '1995-06-20',
|
||||
'province_code' => '79',
|
||||
'ward_code' => '27448',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.client.customer.birthday', '1995-06-20')
|
||||
->assertJsonPath('data.client.customer.province_code', '79')
|
||||
->assertJsonPath('data.client.customer.province', 'Hồ Chí Minh')
|
||||
->assertJsonPath('data.client.customer.ward', 'Bình Trị Đông');
|
||||
|
||||
$this->actingAs($customer->fresh(), 'client')->api()
|
||||
->putJson('/v1/client/update', $payload + ['province_code' => '01', 'ward_code' => '27448'])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['ward_code']);
|
||||
|
||||
$this->actingAs($customer->fresh(), 'client')->api()
|
||||
->putJson('/v1/client/update', $payload + ['birthday' => now()->addDay()->toDateString()])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['birthday']);
|
||||
|
||||
$this->api()->getJson('/v1/provinces')->assertOk()->assertJsonCount(34, 'data');
|
||||
$this->api()->getJson('/v1/provinces/79/wards')->assertOk()->assertJsonFragment(['code' => '27448']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,12 +167,16 @@ class ClientTamperingTest extends TestCase
|
||||
public function test_cannot_read_reply_or_close_another_customers_ticket_or_notification(): void
|
||||
{
|
||||
$ticket = Ticket::create(['uuid' => (string) Str::uuid(), 'ticket_num' => 'TK-VICTIM', 'subject' => 'S', 'customer_id' => $this->victim->id, 'description' => 'D', 'status' => 'open']);
|
||||
$notification = Notification::create(['recipient_type' => 'customer', 'recipient_id' => $this->victim->id, 'type' => 'info', 'title' => 'T', 'body' => 'B', 'is_read' => false]);
|
||||
$notification = Notification::create(['recipient_type' => 'client', 'recipient_id' => $this->victim->id, 'type' => 'info', 'title' => 'T', 'body' => 'B', 'is_read' => false]);
|
||||
|
||||
$this->assertDenied($this->as($this->attacker)->getJson("/v1/client/tickets/{$ticket->ticket_num}"));
|
||||
$this->assertDenied($this->as($this->attacker)->postJson("/v1/client/tickets/{$ticket->ticket_num}/reply", ['message' => 'pwned', 'content' => 'pwned']));
|
||||
$this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/tickets/{$ticket->ticket_num}/close"));
|
||||
$this->as($this->attacker)->patchJson("/v1/client/notifications/{$notification->id}/read");
|
||||
$this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/notifications/{$notification->uuid}/read"));
|
||||
$this->assertDenied($this->as($this->attacker)->getJson("/v1/client/notifications/{$notification->uuid}"));
|
||||
$own = $this->as($this->victim)->getJson("/v1/client/notifications/{$notification->uuid}")->assertOk()->json('data');
|
||||
$this->assertSame($notification->uuid, $own['uuid']);
|
||||
$this->assertArrayNotHasKey('id', $own);
|
||||
|
||||
$this->assertSame('open', $ticket->fresh()->status);
|
||||
$this->assertFalse((bool) $notification->fresh()->is_read);
|
||||
|
||||
@@ -85,7 +85,7 @@ class ClientTwoFactorTest extends TestCase
|
||||
|
||||
$this->actingAs($customer->fresh(), 'client')->api()
|
||||
->getJson('/v1/client/me')
|
||||
->assertJsonPath('data.client.customer.active_2fa', 1);
|
||||
->assertJsonPath('data.client.customer.active_2fa', true);
|
||||
}
|
||||
|
||||
public function test_login_requires_emailed_code_and_can_remember_device(): void
|
||||
|
||||
Reference in New Issue
Block a user