diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results index 8bed5c6a..a3d3de3f 100644 --- a/.phpunit.cache/test-results +++ b/.phpunit.cache/test-results @@ -1 +1 @@ -{"version":1,"defects":{"Tests\\Browser\\ExampleTest::test_basic_example":7,"Tests\\Browser\\CreateAccountTest::test_empty_form_shows_validation_errors":7,"Tests\\Browser\\CreateAccountTest::test_successful_registration_redirects_to_game":8,"Tests\\Browser\\CreateAccountTest::test_authenticated_user_sees_account_page":8,"Tests\\Browser\\CreateAccountTest::test_invalid_phone_shows_error":8,"Tests\\Browser\\AccountManagementTest::test_update_account_successfully":8,"Tests\\Browser\\AccountManagementTest::test_update_account_with_empty_name_shows_error":8,"Tests\\Browser\\AccountManagementTest::test_update_account_with_invalid_phone_shows_error":8,"Tests\\Browser\\AccountManagementTest::test_history_section_visible_when_authenticated":8,"Tests\\Browser\\AccountManagementTest::test_logout_works":8,"Tests\\Browser\\GamePageTest::test_root_redirects_to_game":7,"Tests\\Browser\\GamePageTest::test_game_rule_endpoint_returns_content":7,"Tests\\Browser\\GamePageTest::test_game_rule_popup_loads":8,"Tests\\Browser\\GamePageTest::test_game_rule_api_returns_html":7,"Tests\\Browser\\AdminLoginTest::test_admin_login_with_empty_fields_shows_validation":7,"Tests\\Browser\\AdminLoginTest::test_authenticated_admin_gets_redirected_from_login":7,"Tests\\Browser\\AdminLoginTest::test_admin_logout":7,"Tests\\Browser\\AdminPrizeTest::test_add_prize_with_empty_name_shows_error":8,"Tests\\Browser\\AdminPrizeTest::test_create_prize_successfully":8,"Tests\\Browser\\AdminPrizeTest::test_update_prize_successfully":8,"Tests\\Browser\\AdminPrizeTest::test_delete_prize_successfully":8,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_list_has_date_filter":7,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_export_link_present":7,"Tests\\Browser\\AdminUserTest::test_user_list_shows_target_user":7,"Tests\\Browser\\AdminUserTest::test_update_user_name_successfully":8,"Tests\\Browser\\AdminUserTest::test_admin_account_page_shows_user_info":8,"Tests\\Browser\\AdminParameterTest::test_update_game_rule_successfully":8,"Tests\\Browser\\AdminUserTest::test_user_list_page_loads":7,"Tests\\Browser\\AdminUserTest::test_edit_user_form_shows_existing_data":8,"Tests\\Browser\\CreateAccountTest::test_login_page_shows_form":7,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_number_cannot_be_negative":7,"Tests\\Browser\\AdminEdgeCaseTest::test_invalid_logo_upload_is_rejected":7,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_winner_export_excel_response":7,"Tests\\Browser\\SecurityTest::test_xss_injection_on_registration_form":7},"times":{"Tests\\Browser\\ExampleTest::test_basic_example":2.335,"Tests\\Browser\\CreateAccountTest::test_login_page_shows_form":2.454,"Tests\\Browser\\CreateAccountTest::test_empty_form_shows_validation_errors":0.837,"Tests\\Browser\\CreateAccountTest::test_invalid_phone_shows_error":1.223,"Tests\\Browser\\CreateAccountTest::test_successful_registration_redirects_to_game":1.822,"Tests\\Browser\\CreateAccountTest::test_authenticated_user_sees_account_page":3.239,"Tests\\Browser\\AccountManagementTest::test_update_account_successfully":5.692,"Tests\\Browser\\AccountManagementTest::test_update_account_with_empty_name_shows_error":3.063,"Tests\\Browser\\AccountManagementTest::test_update_account_with_invalid_phone_shows_error":4.023,"Tests\\Browser\\AccountManagementTest::test_history_section_visible_when_authenticated":2.485,"Tests\\Browser\\AccountManagementTest::test_logout_works":4.094,"Tests\\Browser\\GamePageTest::test_game_page_loads_when_guest":2.015,"Tests\\Browser\\GamePageTest::test_root_redirects_to_game":0.214,"Tests\\Browser\\GamePageTest::test_game_page_shows_lucky_wheel":0.141,"Tests\\Browser\\GamePageTest::test_game_rule_endpoint_returns_content":0.375,"Tests\\Browser\\GamePageTest::test_game_page_shows_lucky_wheel_canvas":0.466,"Tests\\Browser\\GamePageTest::test_spin_button_is_present":0.099,"Tests\\Browser\\GamePageTest::test_game_rule_ribbon_is_present":0.212,"Tests\\Browser\\GamePageTest::test_guest_sees_login_prompt_on_game":0.694,"Tests\\Browser\\GamePageTest::test_logged_in_user_can_see_spin_button_enabled":2.403,"Tests\\Browser\\GamePageTest::test_already_spined_user_sees_spin_button_disabled":1.515,"Tests\\Browser\\GamePageTest::test_game_rule_popup_loads":1.922,"Tests\\Browser\\GamePageTest::test_game_rule_api_returns_html":0.19,"Tests\\Browser\\GamePageTest::test_checkspined_returns_error_for_guest":0.603,"Tests\\Browser\\GamePageTest::test_checkspined_returns_zero_for_new_user":0.961,"Tests\\Browser\\AdminLoginTest::test_admin_login_page_shows_form":3.075,"Tests\\Browser\\AdminLoginTest::test_admin_login_with_wrong_credentials_shows_error":1.657,"Tests\\Browser\\AdminLoginTest::test_admin_login_with_empty_fields_shows_validation":1.435,"Tests\\Browser\\AdminLoginTest::test_admin_login_successful":2.879,"Tests\\Browser\\AdminLoginTest::test_authenticated_admin_gets_redirected_from_login":1.149,"Tests\\Browser\\AdminLoginTest::test_unauthenticated_access_to_admin_account_redirects":0.911,"Tests\\Browser\\AdminLoginTest::test_admin_logout":2.54,"Tests\\Browser\\AdminPrizeTest::test_prize_list_page_loads":3.042,"Tests\\Browser\\AdminPrizeTest::test_add_prize_form_loads":1.847,"Tests\\Browser\\AdminPrizeTest::test_add_prize_with_empty_name_shows_error":2.441,"Tests\\Browser\\AdminPrizeTest::test_create_prize_successfully":3.089,"Tests\\Browser\\AdminPrizeTest::test_update_prize_successfully":2.237,"Tests\\Browser\\AdminPrizeTest::test_delete_prize_successfully":2.061,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_list_loads":0.641,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_list_has_date_filter":0.649,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_export_link_present":0.662,"Tests\\Browser\\AdminUserTest::test_user_list_page_loads":3.372,"Tests\\Browser\\AdminUserTest::test_user_list_shows_target_user":1.774,"Tests\\Browser\\AdminUserTest::test_edit_user_form_shows_existing_data":0.706,"Tests\\Browser\\AdminUserTest::test_update_user_name_successfully":1.379,"Tests\\Browser\\AdminUserTest::test_admin_account_page_shows_user_info":0.773,"Tests\\Browser\\AdminParameterTest::test_parameter_page_loads":2.642,"Tests\\Browser\\AdminParameterTest::test_parameter_page_shows_game_rule_field":0.588,"Tests\\Browser\\AdminParameterTest::test_update_game_rule_successfully":1.865,"Tests\\Browser\\AdminParameterTest::test_parameter_page_has_image_upload_fields":1.147,"Tests\\Browser\\AdminParameterTest::test_parameter_page_requires_auth":2.094,"Tests\\Browser\\AdminLoginTest::test_authenticated_admin_visiting_login_sees_form":0.494,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_number_cannot_be_negative":4.313,"Tests\\Browser\\AdminEdgeCaseTest::test_invalid_logo_upload_is_rejected":2.143,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_winner_export_excel_response":0.703,"Tests\\Browser\\GameLogicTest::test_prevent_double_click_spam_on_spin_button":25.007,"Tests\\Browser\\GameLogicTest::test_complete_spin_reward_flow":20.052,"Tests\\Browser\\GameLogicTest::test_spin_when_out_of_stock":2.442,"Tests\\Browser\\SecurityTest::test_regular_user_cannot_access_admin_area":4.976,"Tests\\Browser\\SecurityTest::test_xss_injection_on_registration_form":4.683}} \ No newline at end of file +{"version":1,"defects":{"Tests\\Browser\\ExampleTest::test_basic_example":7,"Tests\\Browser\\CreateAccountTest::test_empty_form_shows_validation_errors":7,"Tests\\Browser\\CreateAccountTest::test_successful_registration_redirects_to_game":8,"Tests\\Browser\\CreateAccountTest::test_authenticated_user_sees_account_page":8,"Tests\\Browser\\CreateAccountTest::test_invalid_phone_shows_error":8,"Tests\\Browser\\AccountManagementTest::test_update_account_successfully":8,"Tests\\Browser\\AccountManagementTest::test_update_account_with_empty_name_shows_error":8,"Tests\\Browser\\AccountManagementTest::test_update_account_with_invalid_phone_shows_error":8,"Tests\\Browser\\AccountManagementTest::test_history_section_visible_when_authenticated":8,"Tests\\Browser\\AccountManagementTest::test_logout_works":8,"Tests\\Browser\\GamePageTest::test_root_redirects_to_game":7,"Tests\\Browser\\GamePageTest::test_game_rule_endpoint_returns_content":7,"Tests\\Browser\\GamePageTest::test_game_rule_popup_loads":8,"Tests\\Browser\\GamePageTest::test_game_rule_api_returns_html":7,"Tests\\Browser\\AdminLoginTest::test_admin_login_with_empty_fields_shows_validation":5,"Tests\\Browser\\AdminLoginTest::test_authenticated_admin_gets_redirected_from_login":7,"Tests\\Browser\\AdminLoginTest::test_admin_logout":5,"Tests\\Browser\\AdminPrizeTest::test_add_prize_with_empty_name_shows_error":8,"Tests\\Browser\\AdminPrizeTest::test_create_prize_successfully":8,"Tests\\Browser\\AdminPrizeTest::test_update_prize_successfully":8,"Tests\\Browser\\AdminPrizeTest::test_delete_prize_successfully":8,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_list_has_date_filter":7,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_export_link_present":7,"Tests\\Browser\\AdminUserTest::test_user_list_shows_target_user":7,"Tests\\Browser\\AdminUserTest::test_update_user_name_successfully":8,"Tests\\Browser\\AdminUserTest::test_admin_account_page_shows_user_info":8,"Tests\\Browser\\AdminParameterTest::test_update_game_rule_successfully":8,"Tests\\Browser\\AdminUserTest::test_user_list_page_loads":7,"Tests\\Browser\\AdminUserTest::test_edit_user_form_shows_existing_data":8,"Tests\\Browser\\CreateAccountTest::test_login_page_shows_form":7,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_number_cannot_be_negative":7,"Tests\\Browser\\AdminEdgeCaseTest::test_invalid_logo_upload_is_rejected":7,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_winner_export_excel_response":7,"Tests\\Browser\\SecurityTest::test_xss_injection_on_registration_form":7,"Tests\\Browser\\BulkWorkflowTest::test_1000_users_full_workflow_from_excel":8,"Tests\\Browser\\GameLogicTest::test_prevent_double_click_spam_on_spin_button":8,"Tests\\Browser\\GameLogicTest::test_complete_spin_reward_flow":8,"Tests\\Browser\\AdminLoginTest::test_admin_login_page_shows_form":5,"Tests\\Browser\\AdminLoginTest::test_admin_login_with_wrong_credentials_shows_error":5,"Tests\\Browser\\AdminLoginTest::test_admin_login_successful":5,"Tests\\Browser\\AdminLoginTest::test_authenticated_admin_visiting_login_sees_form":5,"Tests\\Browser\\AdminLoginTest::test_unauthenticated_access_to_admin_account_redirects":5},"times":{"Tests\\Browser\\ExampleTest::test_basic_example":1.945,"Tests\\Browser\\CreateAccountTest::test_login_page_shows_form":2.244,"Tests\\Browser\\CreateAccountTest::test_empty_form_shows_validation_errors":0.499,"Tests\\Browser\\CreateAccountTest::test_invalid_phone_shows_error":1.06,"Tests\\Browser\\CreateAccountTest::test_successful_registration_redirects_to_game":1.134,"Tests\\Browser\\CreateAccountTest::test_authenticated_user_sees_account_page":1.62,"Tests\\Browser\\AccountManagementTest::test_update_account_successfully":5.281,"Tests\\Browser\\AccountManagementTest::test_update_account_with_empty_name_shows_error":3.215,"Tests\\Browser\\AccountManagementTest::test_update_account_with_invalid_phone_shows_error":2.587,"Tests\\Browser\\AccountManagementTest::test_history_section_visible_when_authenticated":3.196,"Tests\\Browser\\AccountManagementTest::test_logout_works":2.211,"Tests\\Browser\\GamePageTest::test_game_page_loads_when_guest":1.839,"Tests\\Browser\\GamePageTest::test_root_redirects_to_game":0.359,"Tests\\Browser\\GamePageTest::test_game_page_shows_lucky_wheel":0.141,"Tests\\Browser\\GamePageTest::test_game_rule_endpoint_returns_content":0.375,"Tests\\Browser\\GamePageTest::test_game_page_shows_lucky_wheel_canvas":0.331,"Tests\\Browser\\GamePageTest::test_spin_button_is_present":0.205,"Tests\\Browser\\GamePageTest::test_game_rule_ribbon_is_present":0.31,"Tests\\Browser\\GamePageTest::test_guest_sees_login_prompt_on_game":0.565,"Tests\\Browser\\GamePageTest::test_logged_in_user_can_see_spin_button_enabled":2.04,"Tests\\Browser\\GamePageTest::test_already_spined_user_sees_spin_button_disabled":1.073,"Tests\\Browser\\GamePageTest::test_game_rule_popup_loads":1.753,"Tests\\Browser\\GamePageTest::test_game_rule_api_returns_html":0.336,"Tests\\Browser\\GamePageTest::test_checkspined_returns_error_for_guest":0.462,"Tests\\Browser\\GamePageTest::test_checkspined_returns_zero_for_new_user":0.872,"Tests\\Browser\\AdminLoginTest::test_admin_login_page_shows_form":2.798,"Tests\\Browser\\AdminLoginTest::test_admin_login_with_wrong_credentials_shows_error":1.062,"Tests\\Browser\\AdminLoginTest::test_admin_login_with_empty_fields_shows_validation":0.853,"Tests\\Browser\\AdminLoginTest::test_admin_login_successful":1.123,"Tests\\Browser\\AdminLoginTest::test_authenticated_admin_gets_redirected_from_login":1.149,"Tests\\Browser\\AdminLoginTest::test_unauthenticated_access_to_admin_account_redirects":0.639,"Tests\\Browser\\AdminLoginTest::test_admin_logout":1.675,"Tests\\Browser\\AdminPrizeTest::test_prize_list_page_loads":2.185,"Tests\\Browser\\AdminPrizeTest::test_add_prize_form_loads":0.706,"Tests\\Browser\\AdminPrizeTest::test_add_prize_with_empty_name_shows_error":1.159,"Tests\\Browser\\AdminPrizeTest::test_create_prize_successfully":1.652,"Tests\\Browser\\AdminPrizeTest::test_update_prize_successfully":1.056,"Tests\\Browser\\AdminPrizeTest::test_delete_prize_successfully":1.862,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_list_loads":0.673,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_list_has_date_filter":0.396,"Tests\\Browser\\AdminPrizeTest::test_prize_winner_export_link_present":0.587,"Tests\\Browser\\AdminUserTest::test_user_list_page_loads":2.493,"Tests\\Browser\\AdminUserTest::test_user_list_shows_target_user":0.948,"Tests\\Browser\\AdminUserTest::test_edit_user_form_shows_existing_data":0.514,"Tests\\Browser\\AdminUserTest::test_update_user_name_successfully":1.038,"Tests\\Browser\\AdminUserTest::test_admin_account_page_shows_user_info":0.42,"Tests\\Browser\\AdminParameterTest::test_parameter_page_loads":2.294,"Tests\\Browser\\AdminParameterTest::test_parameter_page_shows_game_rule_field":0.432,"Tests\\Browser\\AdminParameterTest::test_update_game_rule_successfully":1.067,"Tests\\Browser\\AdminParameterTest::test_parameter_page_has_image_upload_fields":0.415,"Tests\\Browser\\AdminParameterTest::test_parameter_page_requires_auth":1.855,"Tests\\Browser\\AdminLoginTest::test_authenticated_admin_visiting_login_sees_form":0.463,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_number_cannot_be_negative":3.498,"Tests\\Browser\\AdminEdgeCaseTest::test_invalid_logo_upload_is_rejected":1.817,"Tests\\Browser\\AdminEdgeCaseTest::test_prize_winner_export_excel_response":0.403,"Tests\\Browser\\GameLogicTest::test_prevent_double_click_spam_on_spin_button":19.981,"Tests\\Browser\\GameLogicTest::test_complete_spin_reward_flow":17.534,"Tests\\Browser\\GameLogicTest::test_spin_when_out_of_stock":2.223,"Tests\\Browser\\SecurityTest::test_regular_user_cannot_access_admin_area":3.682,"Tests\\Browser\\SecurityTest::test_xss_injection_on_registration_form":4.279,"Tests\\Browser\\BulkWorkflowTest::test_1000_users_full_workflow_from_excel":208.416}} \ No newline at end of file diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 00000000..b6b29b95 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":[],"times":{"Tests\\Browser\\BulkWorkflowTest::test_1000_users_full_workflow_from_excel":33.781}} \ No newline at end of file diff --git a/docs/testcase.txt b/docs/testcase.txt index 92bda5d0..6c0a262c 100644 --- a/docs/testcase.txt +++ b/docs/testcase.txt @@ -488,6 +488,8 @@ FILE 11: tests/Browser/AdminEdgeCaseTest.php (3 tests) -------------------------------------------------------------------------------- Chuẩn bị chung: Tạo adminUser với đầy đủ roles trong DB. Dọn dẹp chung: Xóa UserRole, adminUser khỏi DB; xóa prize test nếu còn sót. +Chuẩn bị chung: Tạo adminUser với đầy đủ roles trong DB. +Dọn dẹp chung: Xóa UserRole, adminUser khỏi DB; xóa prize test nếu còn sót. [TC-55] test_prize_number_cannot_be_negative Mô tả : Nhập prize_number âm (-5) vào form thêm giải thưởng phải bị @@ -534,14 +536,94 @@ Dọn dẹp chung: Xóa UserRole, adminUser khỏi DB; xóa prize test nếu cò Lưu ý : Không dùng actingAs()->get(exportRoute) vì excel.blade.php gọi exit() sau khi xuất file — điều này sẽ terminate PHPUnit process. +-------------------------------------------------------------------------------- +FILE 12: tests/Browser/BulkWorkflowTest.php (1 test) +-------------------------------------------------------------------------------- +Chuẩn bị chung: Tạo adminUser (đầy đủ roles), tạo prize có prize_number=2000, + tạo file Excel tạm chứa 1000 bản ghi người dùng bằng PhpSpreadsheet. +Dọn dẹp chung: Xóa 1000 users + PrizeUser (theo chunk 200), xóa admin, prize, file Excel. + +[TC-58] test_1000_users_full_workflow_from_excel + Mô tả : Kiểm tra toàn bộ workflow hệ thống (end-to-end) với 1000 người dùng + được cung cấp từ file Excel — từ import dữ liệu đến quay số đến + xác nhận trên giao diện admin. + Luồng : + [A] Đọc file Excel → Xác nhận file có đúng 1001 dòng (1 header + 1000 data). + [B] Bulk-import 1000 users vào DB (đọc từng ô Excel, insert theo chunk 100). + [C] Simulate 1000 lượt quay số (bulk insert prize_users + update spined=1). + [D] 1 lượt quay thực qua HTTP POST /updateprizeuser để kiểm tra API layer. + [E] Browser: Admin mở /admin/users → bảng #table hiển thị. + Browser: Admin mở /admin/prizeuser → bảng + bộ lọc ngày + nút export hiển thị. + [F] Browser: Xác nhận href của #excel_export chứa "prizeuser/export"; + PHP route('admin/prizeuser/export') trả URL hợp lệ. + + Chi tiết từng bước: + + [A] Tạo & Đọc Excel: + Chuẩn bị: generateTestExcel() dùng PhpOffice\PhpSpreadsheet tạo file .xlsx + với 6 cột: STT | HỌ TÊN | SỐ ĐIỆN THOẠI | ĐỊA CHỈ | NGHỀ NGHIỆP | NGUỒN BIẾT SIS. + Phone dùng dải riêng 0900000001..0900001000 (tránh đụng dữ liệu thật). + Bước : XlsxReader::load() → getHighestDataRow() = 1001. + Kết quả : File Excel hợp lệ, đọc được 1001 dòng. + + [B] Bulk-import 1000 users: + Bước : Đọc từng row (2..1001) → lấy phone, name (mb_strtoupper), address, + job, known_sis → build $importBatch[]. + DB::table('users')->insert() theo chunk 100. + Lấy IDs: User::whereIn('phone', ...)->pluck('id'). + Kết quả : assertCount(1000, $bulkUserIds) — 1000 users được import. + + [C] Simulate 1000 lần quay: + Bước : Build $spinBatch[] với user_id + prize_id cho 1000 user. + DB::table('prize_users')->insert() theo chunk 100. + User::whereIn('id', chunk)->update(['spined' => 1]) theo chunk 200. + Kết quả : PrizeUser::whereIn('user_id', $bulkUserIds)->count() = 1000. + User::whereIn(...)->where('spined', 1)->count() = 1000. + + [D] Kiểm tra luồng spin thực qua Browser (1 user mới, giao diện game): + Chuẩn bị: Tạo 1 user riêng (phone='0900000000') ngoài dải bulk, spined=0. + Bước : browser->loginAs(spinUser) → visit('/game') → pause(800ms) + → assertAttribute('#spinButton','data-check','true') + → click('#spinButton') → waitForText('XIN CHÚC MỪNG', 30s) + → pause(1500ms) để DB commit. + Kết quả : spinUser->refresh()->spined = 1. + PrizeUser::where('user_id', spinUserId)->first() != null. + Lợi thế : Dùng Browser thực → CSRF token có trong session → không bị lỗi 419. + Đây là cách kiểm tra API /updateprizeuser trung thực nhất. + Dọn dẹp : Xóa spinUser và PrizeUser của spinUser ngay sau khi assert. + + [E] Browser admin xác nhận: + Bước : browser->loginAs($adminUser). + Truy cập /admin/users → waitFor('#table', 15) → assertPresent('#table'). + Truy cập /admin/prizeuser → waitFor('table, .table', 15) + → assertPresent('#fromDateTime', '#toDateTime', '#excel_export'). + Kết quả : Bảng quản lý người dùng và bảng người trúng giải đều hiển thị đúng. + + [F] Xác nhận export Excel: + Bước : JS override document.createElement để capture href khi click #excel_export. + PHP: route('admin/prizeuser/export') kiểm tra URL. + Kết quả : capturedHref chứa 'prizeuser/export' (nếu JS capture thành công). + route('admin/prizeuser/export') chứa '/admin/prizeuser/export'. + Lưu ý : Không gọi actingAs()->get(exportUrl) vì excel.blade.php gọi exit() + sau khi xuất file → terminate PHPUnit process. + + Dữ liệu test: + - 1000 users với phone 0900000001..0900001000 + - 1 prize "Giải Thưởng Bulk Test" (prize_number=2000, prize_order=98) + - 1 admin "DUSK BULK ADMIN" (email: dusk_bulk_admin@test.local) + - 1 file Excel tạm (sys_get_temp_dir()/dusk_bulk_{timestamp}.xlsx) + + Assertions: 15 assertions + Thời gian : ~37 giây + ================================================================================ TỔNG KẾT ================================================================================ - Số file test : 11 files - Số test cases : 57 test cases - Số assertions : 114 assertions - Thời gian : ~178 giây + Số file test : 12 files + Số test cases : 58 test cases + Số assertions : 129 assertions + Thời gian : ~215 giây Kết quả : ALL PASSED ✓ Phân bổ theo chức năng: @@ -559,8 +641,9 @@ TỔNG KẾT │ Logic game nâng cao │ 3 │ │ Bảo mật & phân quyền │ 2 │ │ Admin edge cases │ 3 │ + │ Bulk workflow 1000 users từ Excel │ 1 │ ├──────────────────────────────────────────┼────────┤ - │ TỔNG CỘNG │ 57 │ + │ TỔNG CỘNG │ 58 │ └──────────────────────────────────────────┴────────┘ Công nghệ kiểm thử: @@ -570,6 +653,7 @@ TỔNG KẾT - Môi trường : WSL2 + WSLg, DUSK_HEADLESS_DISABLED=true - Server : php artisan serve --port=8001 - DB : MySQL (fsg3kct926qj_quaysoDB) + - Excel lib : PhpOffice\PhpSpreadsheet (có sẵn trong vendor/phpoffice/) Sửa lỗi ứng dụng phát hiện qua quá trình kiểm thử: ┌──────────────────────────────────────────────────────────────────────┐ diff --git a/dusk b/dusk new file mode 100755 index 00000000..ef65af56 --- /dev/null +++ b/dusk @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# ============================================================ +# dusk — Wrapper chạy Laravel Dusk tests +# +# Cách dùng: +# ./dusk # Có giao diện Chrome (mặc định) +# ./dusk --headless # Chạy headless (không mở cửa sổ) +# ./dusk --headless --filter=BulkWorkflowTest +# ./dusk --filter=AdminPrizeTest +# +# Tất cả tham số còn lại được chuyển thẳng sang php artisan dusk. +# ============================================================ + +set -e + +HEADLESS=0 +ARTISAN_ARGS=() + +for arg in "$@"; do + if [[ "$arg" == "--headless" ]]; then + HEADLESS=1 + else + ARTISAN_ARGS+=("$arg") + fi +done + +if [[ $HEADLESS -eq 1 ]]; then + echo "[dusk] Chạy HEADLESS (không mở cửa sổ Chrome)" + # Set DUSK_HEADLESS=true trước khi PHP khởi động. + # Laravel dùng Dotenv::createImmutable() → OS env var có độ ưu tiên cao hơn .env + # → DUSK_HEADLESS_DISABLED trong .env sẽ bị bỏ qua bởi hasHeadlessDisabled() + exec env DUSK_HEADLESS=true php artisan dusk "${ARTISAN_ARGS[@]}" +else + echo "[dusk] Chạy có giao diện Chrome (DUSK_HEADLESS_DISABLED=true)" + exec php artisan dusk "${ARTISAN_ARGS[@]}" +fi diff --git a/phpunit.dusk.xml b/phpunit.dusk.xml new file mode 100644 index 00000000..24fbe459 --- /dev/null +++ b/phpunit.dusk.xml @@ -0,0 +1,15 @@ + + + + + ./tests/Browser + + + diff --git a/tests/Browser/AdminEdgeCaseTest.php b/tests/Browser/AdminEdgeCaseTest.php index db1b64d4..52d4a901 100644 --- a/tests/Browser/AdminEdgeCaseTest.php +++ b/tests/Browser/AdminEdgeCaseTest.php @@ -24,6 +24,11 @@ class AdminEdgeCaseTest extends DuskTestCase protected function setUp(): void { parent::setUp(); + $stale = User::where('email', 'dusk_edge_admin@test.local')->first(); + if ($stale) { + UserRole::where('user_id', $stale->id)->delete(); + $stale->delete(); + } $this->adminUser = User::create([ 'name' => 'DUSK EDGE ADMIN', 'email' => 'dusk_edge_admin@test.local', diff --git a/tests/Browser/AdminLoginTest.php b/tests/Browser/AdminLoginTest.php index 2be8e2a9..f302ac2b 100644 --- a/tests/Browser/AdminLoginTest.php +++ b/tests/Browser/AdminLoginTest.php @@ -15,6 +15,14 @@ class AdminLoginTest extends DuskTestCase protected function setUp(): void { parent::setUp(); + + // Bảo vệ chống duplicate từ lần chạy bị interrupt trước đó + $stale = User::where('email', 'dusk_admin_test@test.local')->first(); + if ($stale) { + UserRole::where('user_id', $stale->id)->delete(); + $stale->delete(); + } + // Tạo admin test user với đầy đủ roles $this->adminUser = User::create([ 'name' => 'DUSK ADMIN TEST', diff --git a/tests/Browser/AdminParameterTest.php b/tests/Browser/AdminParameterTest.php index b9d786db..ff154ba9 100644 --- a/tests/Browser/AdminParameterTest.php +++ b/tests/Browser/AdminParameterTest.php @@ -16,6 +16,11 @@ class AdminParameterTest extends DuskTestCase protected function setUp(): void { parent::setUp(); + $stale = User::where('email', 'dusk_param_admin@test.local')->first(); + if ($stale) { + UserRole::where('user_id', $stale->id)->delete(); + $stale->delete(); + } $this->adminUser = User::create([ 'name' => 'DUSK PARAM ADMIN', 'email' => 'dusk_param_admin@test.local', diff --git a/tests/Browser/AdminPrizeTest.php b/tests/Browser/AdminPrizeTest.php index 1dad3c29..9a349128 100644 --- a/tests/Browser/AdminPrizeTest.php +++ b/tests/Browser/AdminPrizeTest.php @@ -17,6 +17,14 @@ class AdminPrizeTest extends DuskTestCase protected function setUp(): void { parent::setUp(); + + // Bảo vệ chống duplicate từ lần chạy bị interrupt trước đó + $stale = User::where('email', 'dusk_prize_admin@test.local')->first(); + if ($stale) { + UserRole::where('user_id', $stale->id)->delete(); + $stale->delete(); + } + $this->adminUser = User::create([ 'name' => 'DUSK PRIZE ADMIN', 'email' => 'dusk_prize_admin@test.local', diff --git a/tests/Browser/AdminUserTest.php b/tests/Browser/AdminUserTest.php index 22b4c654..e205012f 100644 --- a/tests/Browser/AdminUserTest.php +++ b/tests/Browser/AdminUserTest.php @@ -16,6 +16,11 @@ class AdminUserTest extends DuskTestCase protected function setUp(): void { parent::setUp(); + $stale = User::where('email', 'dusk_user_admin@test.local')->first(); + if ($stale) { + UserRole::where('user_id', $stale->id)->delete(); + $stale->delete(); + } $this->adminUser = User::create([ 'name' => 'DUSK USER ADMIN', 'email' => 'dusk_user_admin@test.local', diff --git a/tests/Browser/BulkWorkflowTest.php b/tests/Browser/BulkWorkflowTest.php new file mode 100644 index 00000000..5aa8b859 --- /dev/null +++ b/tests/Browser/BulkWorkflowTest.php @@ -0,0 +1,393 @@ +first(); + if ($stale) { + UserRole::where('user_id', $stale->id)->delete(); + $stale->delete(); + } + Prize::where('prize_name', 'Giải Thưởng Bulk Test')->each(function ($p) { + \App\Models\PrizeUser::where('prize_id', $p->id)->delete(); + $p->delete(); + }); + // Xóa bulk users tồn đọng (phone 0900000000..0900001000) + $staleUsers = User::whereBetween('phone', ['0900000000', '0900001000'])->pluck('id'); + if ($staleUsers->isNotEmpty()) { + \App\Models\PrizeUser::whereIn('user_id', $staleUsers)->delete(); + User::whereIn('id', $staleUsers)->delete(); + } + + // Tạo admin với đầy đủ roles + $this->adminUser = User::create([ + 'name' => 'DUSK BULK ADMIN', + 'email' => 'dusk_bulk_admin@test.local', + 'phone' => '0900999999', + 'password' => bcrypt('dusk_bulk_admin_2026'), + 'status' => 1, + 'address' => 'Cần Thơ', + 'known_sis' => 0, + 'job' => 'Admin', + 'spined' => 0, + ]); + $roles = Role::where('role_status', 1)->get(); + foreach ($roles as $role) { + UserRole::create(['user_id' => $this->adminUser->id, 'role_id' => $role->id]); + } + + // Tạo giải thưởng với prize_number đủ lớn cho 1001 lượt (1000 bulk + 1 HTTP) + $this->bulkPrize = Prize::create([ + 'prize_name' => 'Giải Thưởng Bulk Test', + 'prize_status' => 1, + 'prize_number' => 2000, + 'prize_order' => 98, + 'user_id' => 0, + ]); + + // Tạo file Excel tạm chứa 1000 bản ghi người dùng + $this->excelPath = sys_get_temp_dir() . '/dusk_bulk_' . time() . '.xlsx'; + $this->generateTestExcel($this->excelPath, self::BULK_COUNT); + } + + protected function tearDown(): void + { + // Dọn dẹp theo thứ tự: PrizeUser → Users → Prize → AdminUser + if (!empty($this->bulkUserIds)) { + foreach (array_chunk($this->bulkUserIds, 200) as $chunk) { + PrizeUser::whereIn('user_id', $chunk)->delete(); + User::whereIn('id', $chunk)->delete(); + } + } + // Xóa spin verify test user nếu còn + $spinUser = User::where('phone', $this->makeTestPhone(0))->first(); + if ($spinUser) { + PrizeUser::where('user_id', $spinUser->id)->delete(); + $spinUser->delete(); + } + + UserRole::where('user_id', $this->adminUser->id)->delete(); + $this->adminUser->delete(); + $this->bulkPrize->delete(); + + if ($this->excelPath && file_exists($this->excelPath)) { + unlink($this->excelPath); + } + + parent::tearDown(); + } + + // ========================================================================= + // Helper: Tạo file Excel 1000 bản ghi người dùng + // ========================================================================= + + private function generateTestExcel(string $path, int $count): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Danh Sách Người Dùng'); + + // Header row + $sheet->setCellValue('A1', 'STT'); + $sheet->setCellValue('B1', 'HỌ TÊN'); + $sheet->setCellValue('C1', 'SỐ ĐIỆN THOẠI'); + $sheet->setCellValue('D1', 'ĐỊA CHỈ'); + $sheet->setCellValue('E1', 'NGHỀ NGHIỆP'); + $sheet->setCellValue('F1', 'NGUỒN BIẾT SIS'); + + // Data rows (index 1..1000 → phone 0900000001..0900001000) + for ($i = 1; $i <= $count; $i++) { + $row = $i + 1; + $sheet->setCellValue('A' . $row, $i); + $sheet->setCellValue('B' . $row, 'Người Dùng Bulk ' . $i); + $sheet->setCellValue('C' . $row, $this->makeTestPhone($i)); + $sheet->setCellValue('D' . $row, ($i % 2 === 0) ? 'Hà Nội' : 'Cần Thơ'); + $sheet->setCellValue('E' . $row, ($i % 3 === 0) ? 'Giáo viên' : 'Kỹ sư'); + $sheet->setCellValue('F' . $row, $i % 2); // 0 hoặc 1 + } + + $writer = new XlsxWriter($spreadsheet); + $writer->save($path); + } + + // ========================================================================= + // [TC-58] Toàn bộ workflow 1000 người dùng từ file Excel + // ========================================================================= + + /** + * [BƯỚC A] Đọc 1000 bản ghi từ file Excel. + * [BƯỚC B] Bulk-import 1000 users vào DB (giả lập chức năng admin import từ Excel). + * [BƯỚC C] Simulate 1000 lượt quay số (DB bulk insert + cập nhật spined). + * [BƯỚC D] 1 lượt quay thực qua HTTP POST /updateprizeuser (kiểm tra API layer). + * [BƯỚC E] Browser: Admin mở /admin/users và /admin/prizeuser, xác nhận bảng hiển thị. + * [BƯỚC F] Browser: Xác nhận #excel_export tồn tại; route export hợp lệ. + */ + public function test_1000_users_full_workflow_from_excel(): void + { + // ================================================================== + // [A] Đọc file Excel + // ================================================================== + $this->assertFileExists($this->excelPath, 'File Excel test phải được tạo thành công'); + + $reader = new XlsxReader(); + $spreadsheet = $reader->load($this->excelPath); + $sheet = $spreadsheet->getActiveSheet(); + $highestRow = $sheet->getHighestDataRow(); // phải là 1001 (1 header + 1000 data) + + $this->assertEquals( + self::BULK_COUNT + 1, + $highestRow, + 'File Excel phải có ' . (self::BULK_COUNT + 1) . ' dòng (1 header + 1000 data)' + ); + + // ================================================================== + // [B] Bulk-import 1000 users vào DB từ dữ liệu trong Excel + // ================================================================== + $now = now(); + $importBatch = []; + + for ($row = 2; $row <= $highestRow; $row++) { + $phone = (string) $sheet->getCell('C' . $row)->getValue(); + $name = mb_strtoupper( + (string) $sheet->getCell('B' . $row)->getValue(), 'UTF-8' + ); + $address = (string) $sheet->getCell('D' . $row)->getValue(); + $job = (string) $sheet->getCell('E' . $row)->getValue(); + $knownSis = (int) $sheet->getCell('F' . $row)->getValue(); + + $importBatch[] = [ + 'name' => $name, + 'phone' => $phone, + 'email' => $phone . '@phone.local', + 'password' => bcrypt('bulk2026'), + 'address' => $address, + 'known_sis' => $knownSis, + 'job' => $job, + 'status' => 1, + 'spined' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + // Chia chunks 100 để tránh quá nhiều placeholders trong 1 query + foreach (array_chunk($importBatch, 100) as $chunk) { + DB::table('users')->insert($chunk); + } + + // Lấy IDs của 1000 users vừa import + $importedPhones = array_column($importBatch, 'phone'); + $this->bulkUserIds = User::whereIn('phone', $importedPhones) + ->pluck('id') + ->toArray(); + + $this->assertCount( + self::BULK_COUNT, + $this->bulkUserIds, + '1000 users phải được import thành công từ file Excel' + ); + + // ================================================================== + // [C] Simulate 1000 lượt quay số (bulk PrizeUser + update spined) + // ================================================================== + $prizeId = $this->bulkPrize->id; + $spinBatch = []; + + foreach ($this->bulkUserIds as $userId) { + $spinBatch[] = [ + 'user_id' => $userId, + 'prize_id' => $prizeId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + foreach (array_chunk($spinBatch, 100) as $chunk) { + DB::table('prize_users')->insert($chunk); + } + + // Bulk update spined = 1 cho tất cả users vừa import + foreach (array_chunk($this->bulkUserIds, 200) as $chunk) { + User::whereIn('id', $chunk)->update(['spined' => 1]); + } + + // DB assertions sau khi spin + $prizeUserCount = PrizeUser::whereIn('user_id', $this->bulkUserIds)->count(); + $this->assertEquals( + self::BULK_COUNT, + $prizeUserCount, + '1000 bản ghi PrizeUser phải được tạo — mỗi user có đúng 1 lần quay' + ); + + $spinedCount = User::whereIn('id', $this->bulkUserIds) + ->where('spined', 1) + ->count(); + $this->assertEquals( + self::BULK_COUNT, + $spinedCount, + '1000 users phải có spined = 1 sau khi quay thành công' + ); + + // ================================================================== + // [D] 1 lần quay thực qua Browser (spin 1 user chưa quay qua trang game) + // Dùng browser + loginAs thay vì HTTP test client để tránh vấn đề CSRF. + // Luồng: user mới (spined=0) → /game → click #spinButton → waitFor kết quả + // → DB: PrizeUser + spined=1 + // ================================================================== + $spinTestPhone = $this->makeTestPhone(0); // '0900000000' — ngoài dải bulk + + // Cleanup nếu còn tồn đọng từ lần chạy trước + $staleSpinUser = User::where('phone', $spinTestPhone)->first(); + if ($staleSpinUser) { + PrizeUser::where('user_id', $staleSpinUser->id)->delete(); + $staleSpinUser->delete(); + } + + $spinUser = User::create([ + 'name' => 'DUSK SPIN VERIFY USER', + 'phone' => $spinTestPhone, + 'email' => $spinTestPhone . '@phone.local', + 'password' => bcrypt('spin_verify_2026'), + 'address' => 'Cần Thơ', + 'known_sis' => 1, + 'job' => 'Tester', + 'status' => 1, + 'spined' => 0, + ]); + $spinUserId = $spinUser->id; + + $this->browse(function (Browser $browser) use ($spinUserId, $prizeId) { + $spinUser = User::find($spinUserId); + $browser->loginAs($spinUser) + ->visit('/game') + ->pause(800); // Chờ wheel canvas render + + $browser->assertAttribute('#spinButton', 'data-check', 'true'); + $browser->click('#spinButton'); + $browser->waitForText('XIN CHÚC MỪNG', 30); // Spin mất ~10-15s + $browser->pause(1500); // Chờ DB transaction commit + }); + + // DB assertions sau khi spin thực + $spinUser->refresh(); + $this->assertEquals(1, $spinUser->spined, + '/game spin thực: user.spined phải = 1 sau khi quay thành công'); + + $spinPrizeUser = PrizeUser::where('user_id', $spinUserId)->first(); + $this->assertNotNull($spinPrizeUser, + '/game spin thực: bản ghi PrizeUser phải tồn tại sau khi quay'); + + // Cleanup spin test user + PrizeUser::where('user_id', $spinUserId)->delete(); + $spinUser->delete(); + + // ================================================================== + // [E] Browser: Admin xác nhận giao diện quản trị hiển thị đúng + // ================================================================== + $this->browse(function (Browser $browser) { + $browser->loginAs($this->adminUser); + + // Trang danh sách người dùng + $browser->visit('/admin/users') + ->waitFor('#table', 15) + ->assertPresent('#table'); + + // Trang danh sách người trúng giải + $browser->visit('/admin/prizeuser') + ->waitFor('table, .table', 15) + ->assertPresent('#fromDateTime') + ->assertPresent('#toDateTime') + ->assertPresent('#excel_export'); + + // ================================================================== + // [F] Xác nhận route export Excel hợp lệ (không click để tránh exit()) + // ================================================================== + $capturedHref = $browser->script(" + var captured = null; + var _orig = document.createElement.bind(document); + document.createElement = function(tag) { + var el = _orig(tag); + if (tag.toLowerCase() === 'a') { + Object.defineProperty(el, 'href', { + set: function(v) { captured = v; }, + get: function() { return captured || ''; } + }); + } + return el; + }; + try { + document.getElementById('excel_export').click(); + } catch(e) {} + document.createElement = _orig; + return captured; + ")[0]; + + // capturedHref có thể là null nếu JS build URL khác cách — kiểm tra fallback + if ($capturedHref !== null) { + $browser->assertScript( + "return ('" . addslashes($capturedHref) . "').indexOf('prizeuser/export') !== -1", + true + ); + } + + // Xác nhận route export đã được đăng ký trong hệ thống (PHP route helper) + $exportUrl = route('admin/prizeuser/export'); + $this->assertStringContainsString( + '/admin/prizeuser/export', + $exportUrl, + 'Route admin/prizeuser/export phải tồn tại trong hệ thống' + ); + }); + } +} diff --git a/tests/Browser/SecurityTest.php b/tests/Browser/SecurityTest.php index e578338c..3b758564 100644 --- a/tests/Browser/SecurityTest.php +++ b/tests/Browser/SecurityTest.php @@ -38,6 +38,11 @@ class SecurityTest extends DuskTestCase ]); // Admin user — có đầy đủ roles + $stale = User::where('email', 'dusk_secure_admin@test.local')->first(); + if ($stale) { + UserRole::where('user_id', $stale->id)->delete(); + $stale->delete(); + } $this->adminUser = User::create([ 'name' => 'DUSK SECURE ADMIN', 'email' => 'dusk_secure_admin@test.local', diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php index 4460d97e..87076636 100644 --- a/tests/DuskTestCase.php +++ b/tests/DuskTestCase.php @@ -11,6 +11,23 @@ use PHPUnit\Framework\Attributes\BeforeClass; abstract class DuskTestCase extends BaseTestCase { + /** + * Kiểm tra xem có nên chạy Chrome ở chế độ headless không. + * + * Ưu tiên: + * 1. Nếu DUSK_HEADLESS=true (set từ shell trước khi PHP chạy) → headless + * 2. Ngược lại fallback về DUSK_HEADLESS_DISABLED trong .env + */ + protected function hasHeadlessDisabled(): bool + { + if (isset($_SERVER['DUSK_HEADLESS']) || isset($_ENV['DUSK_HEADLESS'])) { + return false; // Buộc headless dù .env có DUSK_HEADLESS_DISABLED + } + + return isset($_SERVER['DUSK_HEADLESS_DISABLED']) || + isset($_ENV['DUSK_HEADLESS_DISABLED']); + } + /** * Prepare for Dusk test execution. */