diff --git a/test/membership/group_test.exs b/test/membership/group_test.exs index 1c84eeb..c51bc66 100644 --- a/test/membership/group_test.exs +++ b/test/membership/group_test.exs @@ -2,7 +2,7 @@ defmodule Mv.Membership.GroupTest do @moduledoc """ Tests for Group resource validations, CRUD operations, and relationships. """ - use Mv.DataCase, async: true + use Mv.DataCase, async: false alias Mv.Membership diff --git a/test/membership/member_group_test.exs b/test/membership/member_group_test.exs index b3c048f..4dd4ae8 100644 --- a/test/membership/member_group_test.exs +++ b/test/membership/member_group_test.exs @@ -2,7 +2,7 @@ defmodule Mv.Membership.MemberGroupTest do @moduledoc """ Tests for MemberGroup join table resource - validations and cascade delete behavior. """ - use Mv.DataCase, async: true + use Mv.DataCase, async: false alias Mv.Membership diff --git a/test/mv_web/live/global_settings_live_config_test.exs b/test/mv_web/live/global_settings_live_config_test.exs index 1f06145..73f831f 100644 --- a/test/mv_web/live/global_settings_live_config_test.exs +++ b/test/mv_web/live/global_settings_live_config_test.exs @@ -39,9 +39,10 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do original_config = Application.get_env(:mv, :csv_import, []) try do + # Arrange: Set custom row limit to 500 Application.put_env(:mv, :csv_import, max_rows: 500) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/admin/import-export") # Generate CSV with 501 rows (exceeding custom limit of 500) header = "first_name;last_name;email;street;postal_code;city\n" @@ -53,17 +54,17 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do large_csv = header <> Enum.join(rows) - # Simulate file upload using helper function + # Act: Upload CSV and submit form upload_csv_file(view, large_csv, "too_many_rows_custom.csv") view |> form("#csv-upload-form", %{}) |> render_submit() + # Assert: Import should be rejected with error message html = render(view) # Business rule: import should be rejected when exceeding configured limit - assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or - html =~ "Failed to prepare" + assert html =~ "Failed to prepare CSV import" after # Restore original config Application.put_env(:mv, :csv_import, original_config) diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 083c813..86680f3 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -3,22 +3,6 @@ defmodule MvWeb.GlobalSettingsLiveTest do import Phoenix.LiveViewTest alias Mv.Membership - # Helper function to upload CSV file in tests - # Reduces code duplication across multiple test cases - defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do - view - |> file_input("#csv-upload-form", :csv_file, [ - %{ - last_modified: System.system_time(:second), - name: filename, - content: csv_content, - size: byte_size(csv_content), - type: "text/csv" - } - ]) - |> render_upload(filename) - end - describe "Global Settings LiveView" do setup %{conn: conn} do user = create_test_user(%{email: "admin@example.com"}) @@ -97,595 +81,4 @@ defmodule MvWeb.GlobalSettingsLiveTest do assert render(view) =~ "updated" or render(view) =~ "success" end end - - describe "CSV Import Section" do - test "admin user sees import section", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - # Check for import section heading or identifier - assert html =~ "Import" or html =~ "CSV" or html =~ "member_import" - end - - test "admin user sees custom fields notice", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - # Check for custom fields notice text - assert html =~ "Use the data field name" - end - - test "admin user sees template download links", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - html = render(view) - - # Check for English template link - assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv" - - # Check for German template link - assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv" - end - - test "template links use static path helper", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - html = render(view) - - # Check that links contain the static path pattern - # Static paths typically start with /templates/ or contain the full path - assert html =~ "/templates/member_import_en.csv" or - html =~ ~r/href=["'][^"']*member_import_en\.csv["']/ - - assert html =~ "/templates/member_import_de.csv" or - html =~ ~r/href=["'][^"']*member_import_de\.csv["']/ - end - - test "admin user sees file upload input", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - html = render(view) - - # Check for file input element - assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload" - end - - test "file upload has CSV-only restriction", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - html = render(view) - - # Check for CSV file type restriction in help text or accept attribute - assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i - end - - test "non-admin user does not see import section", %{conn: conn} do - # Member (own_data) is redirected when accessing /settings (no page permission) - member_user = Mv.Fixtures.user_with_role_fixture("own_data") - conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user) - - assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings") - assert to == "/users/#{member_user.id}" - end - end - - describe "CSV Import - Import" do - setup %{conn: conn} do - # Ensure admin user - admin_user = Mv.Fixtures.user_with_role_fixture("admin") - conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) - - # Read valid CSV fixture - csv_content = - Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) - |> File.read!() - - {:ok, conn: conn, admin_user: admin_user, csv_content: csv_content} - end - - test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate file upload using helper function - upload_csv_file(view, csv_content) - - # Trigger start_import event via form submit - assert view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Check that import has started or shows appropriate message - html = render(view) - # Either import started successfully OR we see a specific error (not admin error) - import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress" - no_admin_error = not (html =~ "Only administrators can import") - # If import failed, it should be a CSV parsing error, not an admin error - if html =~ "Failed to prepare CSV import" do - # This is acceptable - CSV might have issues, but admin check passed - assert no_admin_error - else - # Import should have started - assert import_started or html =~ "CSV File" - end - end - - test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate file upload using helper function - upload_csv_file(view, csv_content) - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Check that import has started or shows appropriate message - html = render(view) - # Either import started successfully OR we see a specific error (not admin error) - import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress" - no_admin_error = not (html =~ "Only administrators can import") - # If import failed, it should be a CSV parsing error, not an admin error - if html =~ "Failed to prepare CSV import" do - # This is acceptable - CSV might have issues, but admin check passed - assert no_admin_error - else - # Import should have started - assert import_started or html =~ "CSV File" - end - end - - test "non-admin cannot start import", %{conn: conn} do - # Member (own_data) is redirected when accessing /settings (no page permission) - member_user = Mv.Fixtures.user_with_role_fixture("own_data") - conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user) - - assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings") - assert to == "/users/#{member_user.id}" - end - - test "invalid CSV shows user-friendly error", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Create invalid CSV (missing required fields) - invalid_csv = "invalid_header\nincomplete_row" - - # Simulate file upload using helper function - upload_csv_file(view, invalid_csv, "invalid.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Check for error message (flash) - html = render(view) - assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare" - end - - @tag :skip - test "empty CSV shows error", %{conn: conn} do - # Skip this test - Phoenix LiveView has issues with empty file uploads in tests - # The error is handled correctly in production, but test framework has limitations - {:ok, view, _html} = live(conn, ~p"/settings") - - empty_csv = " " - csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"]) - File.write!(csv_path, empty_csv) - - view - |> file_input("#csv-upload-form", :csv_file, [ - %{ - last_modified: System.system_time(:second), - name: "empty.csv", - content: empty_csv, - size: byte_size(empty_csv), - type: "text/csv" - } - ]) - |> render_upload("empty.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Check for error message - html = render(view) - assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare" - end - end - - describe "CSV Import - Step 3: Chunk Processing" do - setup %{conn: conn} do - # Ensure admin user - admin_user = Mv.Fixtures.user_with_role_fixture("admin") - conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) - - # Read valid CSV fixture - valid_csv_content = - Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) - |> File.read!() - - # Read invalid CSV fixture - invalid_csv_content = - Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"]) - |> File.read!() - - {:ok, - conn: conn, - admin_user: admin_user, - valid_csv_content: valid_csv_content, - invalid_csv_content: invalid_csv_content} - end - - test "happy path: valid CSV processes all chunks and shows done status", %{ - conn: conn, - valid_csv_content: csv_content - } do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate file upload using helper function - upload_csv_file(view, csv_content) - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for processing to complete - # In test mode, chunks are processed synchronously and messages are sent via send/2 - # render(view) processes handle_info messages, so we call it multiple times - # to ensure all messages are processed - # Use the same approach as "success rendering" test which works - Process.sleep(1000) - - html = render(view) - # Should show success count (inserted count) - assert html =~ "Inserted" or html =~ "inserted" or html =~ "2" - # Should show completed status - assert html =~ "completed" or html =~ "done" or html =~ "Import completed" or - has_element?(view, "[data-testid='import-results-panel']") - end - - test "error handling: invalid CSV shows errors with line numbers", %{ - conn: conn, - invalid_csv_content: csv_content - } do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate file upload using helper function - upload_csv_file(view, csv_content, "invalid_import.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for chunk processing - Process.sleep(500) - - html = render(view) - # Should show failure count > 0 - assert html =~ "failed" or html =~ "error" or html =~ "Failed" - - # Should show line numbers in errors (from service, not recalculated) - # Line numbers should be 2, 3 (header is line 1) - assert html =~ "2" or html =~ "3" or html =~ "line" - end - - test "error cap: many failing rows caps errors at 50", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Generate CSV with 100 invalid rows (all missing email) - header = "first_name;last_name;email;street;postal_code;city\n" - invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n" - large_invalid_csv = header <> Enum.join(invalid_rows) - - # Simulate file upload using helper function - upload_csv_file(view, large_invalid_csv, "large_invalid.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for chunk processing - Process.sleep(1000) - - html = render(view) - # Should show failed count == 100 - assert html =~ "100" or html =~ "failed" - - # Errors should be capped at 50 (but we can't easily check exact count in HTML) - # The important thing is that processing completes without crashing - assert html =~ "done" or html =~ "complete" or html =~ "finished" - end - - test "chunk scheduling: progress updates show chunk processing", %{ - conn: conn, - valid_csv_content: csv_content - } do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate file upload using helper function - upload_csv_file(view, csv_content) - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait a bit for processing to start - Process.sleep(200) - - # Check that status area exists (with aria-live for accessibility) - html = render(view) - - assert html =~ "aria-live" or html =~ "status" or html =~ "progress" or - html =~ "Processing" or html =~ "chunk" - - # Final state should be :done - Process.sleep(500) - final_html = render(view) - assert final_html =~ "done" or final_html =~ "complete" or final_html =~ "finished" - end - end - - describe "CSV Import - Step 4: Results UI" do - setup %{conn: conn} do - # Ensure admin user - admin_user = Mv.Fixtures.user_with_role_fixture("admin") - conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) - - # Read valid CSV fixture - valid_csv_content = - Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) - |> File.read!() - - # Read invalid CSV fixture - invalid_csv_content = - Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"]) - |> File.read!() - - # Read CSV with unknown custom field - unknown_custom_field_csv = - Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"]) - |> File.read!() - - {:ok, - conn: conn, - admin_user: admin_user, - valid_csv_content: valid_csv_content, - invalid_csv_content: invalid_csv_content, - unknown_custom_field_csv: unknown_custom_field_csv} - end - - test "success rendering: valid CSV shows success count", %{ - conn: conn, - valid_csv_content: csv_content - } do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate file upload using helper function - upload_csv_file(view, csv_content) - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for processing to complete - Process.sleep(1000) - - html = render(view) - # Should show success count (inserted count) - assert html =~ "Inserted" or html =~ "inserted" or html =~ "2" - # Should show completed status - assert html =~ "completed" or html =~ "done" or html =~ "Import completed" - end - - test "error rendering: invalid CSV shows failure count and error list with line numbers", %{ - conn: conn, - invalid_csv_content: csv_content - } do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Simulate file upload using helper function - upload_csv_file(view, csv_content, "invalid_import.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for processing - Process.sleep(1000) - - html = render(view) - # Should show failure count - assert html =~ "Failed" or html =~ "failed" - - # Should show error list with line numbers (from service, not recalculated) - assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3" - # Should show error messages - assert html =~ "error" or html =~ "Error" or html =~ "Errors" - end - - test "warning rendering: CSV with unknown custom field shows warnings block", %{ - conn: conn, - unknown_custom_field_csv: csv_content - } do - {:ok, view, _html} = live(conn, ~p"/settings") - - csv_path = - Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"]) - - File.write!(csv_path, csv_content) - - view - |> file_input("#csv-upload-form", :csv_file, [ - %{ - last_modified: System.system_time(:second), - name: "unknown_custom.csv", - content: csv_content, - size: byte_size(csv_content), - type: "text/csv" - } - ]) - |> render_upload("unknown_custom.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for processing - Process.sleep(1000) - - html = render(view) - # Should show warnings block (if warnings were generated) - # Warnings are generated when unknown custom field columns are detected - # Check if warnings section exists OR if import completed successfully - has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings" - import_completed = html =~ "completed" or html =~ "done" or html =~ "Import Results" - - # If warnings exist, they should contain the column name - if has_warnings do - assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or - html =~ "will be ignored" - end - - # Import should complete (either with or without warnings) - assert import_completed - end - - test "A11y: file input has label", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - # Check for label associated with file input - assert html =~ ~r/]*for=["']csv_file["']/i or - html =~ ~r/]*>.*CSV File/i - end - - test "A11y: status/progress container has aria-live", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - html = render(view) - # Check for aria-live attribute in status area - assert html =~ ~r/aria-live=["']polite["']/i - end - - test "A11y: links have descriptive text", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - # Check that links have descriptive text (not just "click here") - # Template links should have text like "English Template" or "German Template" - assert html =~ "English Template" or html =~ "German Template" or - html =~ "English" or html =~ "German" - - # Custom Fields section should have descriptive text (Data Field button) - # The component uses "New Data Field" button, not a link - assert html =~ "Data Field" or html =~ "New Data Field" - end - end - - describe "CSV Import - Step 5: Edge Cases" do - setup %{conn: conn} do - # Ensure admin user - admin_user = Mv.Fixtures.user_with_role_fixture("admin") - conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) - - {:ok, conn: conn, admin_user: admin_user} - end - - test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Read CSV with BOM - csv_content = - Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"]) - |> File.read!() - - # Simulate file upload using helper function - upload_csv_file(view, csv_content, "bom_import.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for processing - Process.sleep(1000) - - html = render(view) - # Should succeed (BOM is stripped automatically) - assert html =~ "completed" or html =~ "done" or html =~ "Inserted" - # Should not show error about BOM - refute html =~ "BOM" or html =~ "encoding" - end - - test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4) - csv_content = - Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"]) - |> File.read!() - - # Simulate file upload using helper function - upload_csv_file(view, csv_content, "empty_lines.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for processing - Process.sleep(1000) - - html = render(view) - # Should show error with correct line number (line 4, not line 3) - # The error should be on the line with invalid email, which is after the empty line - assert html =~ "Line 4" or html =~ "line 4" or html =~ "4" - # Should show error message - assert html =~ "error" or html =~ "Error" or html =~ "invalid" - end - - test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Generate CSV with 1001 rows dynamically - header = "first_name;last_name;email;street;postal_code;city\n" - - rows = - for i <- 1..1001 do - "Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n" - end - - large_csv = header <> Enum.join(rows) - - # Simulate file upload using helper function - upload_csv_file(view, large_csv, "too_many_rows.csv") - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - html = render(view) - # Should show user-friendly error about row limit - assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or - html =~ "Failed to prepare" - end - - test "wrong file type (.txt): upload shows error", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/settings") - - # Create .txt file (not .csv) - txt_content = "This is not a CSV file\nJust some text\n" - txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"]) - File.write!(txt_path, txt_content) - - # Try to upload .txt file - # Note: allow_upload is configured to accept only .csv, so this should fail - # In tests, we can't easily simulate file type rejection, but we can check - # that the UI shows appropriate help text - html = render(view) - # Should show CSV-only restriction in help text - assert html =~ "CSV" or html =~ "csv" or html =~ ".csv" - end - - test "file input has correct accept attribute for CSV only", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/settings") - - # Check that file input has accept attribute for CSV - assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only" - end - end end diff --git a/test/mv_web/live/import_export_live_test.exs b/test/mv_web/live/import_export_live_test.exs new file mode 100644 index 0000000..1ec25f2 --- /dev/null +++ b/test/mv_web/live/import_export_live_test.exs @@ -0,0 +1,655 @@ +defmodule MvWeb.ImportExportLiveTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + # Helper function to upload CSV file in tests + # Reduces code duplication across multiple test cases + defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do + view + |> file_input("#csv-upload-form", :csv_file, [ + %{ + last_modified: System.system_time(:second), + name: filename, + content: csv_content, + size: byte_size(csv_content), + type: "text/csv" + } + ]) + |> render_upload(filename) + end + + describe "Import/Export LiveView" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + {:ok, conn: conn, admin_user: admin_user} + end + + test "renders the import/export page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + assert html =~ "Import/Export" + end + + test "displays import section for admin user", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + assert html =~ "Import Members (CSV)" + end + + test "displays export section placeholder", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + assert html =~ "Export Members (CSV)" or html =~ "Export" + end + end + + describe "CSV Import Section" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + {:ok, conn: conn, admin_user: admin_user} + end + + test "admin user sees import section", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + # Check for import section heading or identifier + assert html =~ "Import" or html =~ "CSV" or html =~ "member_import" + end + + test "admin user sees custom fields notice", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + # Check for custom fields notice text + assert html =~ "Use the data field name" + end + + test "admin user sees template download links", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + html = render(view) + + # Check for English template link + assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv" + + # Check for German template link + assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv" + end + + test "template links use static path helper", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + html = render(view) + + # Check that links contain the static path pattern + # Static paths typically start with /templates/ or contain the full path + assert html =~ "/templates/member_import_en.csv" or + html =~ ~r/href=["'][^"']*member_import_en\.csv["']/ + + assert html =~ "/templates/member_import_de.csv" or + html =~ ~r/href=["'][^"']*member_import_de\.csv["']/ + end + + test "admin user sees file upload input", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + html = render(view) + + # Check for file input element + assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload" + end + + test "file upload has CSV-only restriction", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + html = render(view) + + # Check for CSV file type restriction in help text or accept attribute + assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i + end + + test "non-admin user sees permission error", %{conn: conn} do + # Member (own_data) user + member_user = Mv.Fixtures.user_with_role_fixture("own_data") + conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user) + + # Router plug redirects non-admin users before LiveView loads + assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} = + live(conn, ~p"/admin/import-export") + + # Should redirect to user profile page + assert redirect_path =~ "/users/" + # Should show permission error in flash + assert error_message =~ "don't have permission" + end + end + + describe "CSV Import - Import" do + setup %{conn: conn} do + # Ensure admin user + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + + # Read valid CSV fixture + csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) + |> File.read!() + + {:ok, conn: conn, admin_user: admin_user, csv_content: csv_content} + end + + test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Simulate file upload using helper function + upload_csv_file(view, csv_content) + + # Trigger start_import event via form submit + assert view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Check that import has started or shows appropriate message + html = render(view) + # Either import started successfully OR we see a specific error (not admin error) + import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress" + no_admin_error = not (html =~ "Only administrators can import") + # If import failed, it should be a CSV parsing error, not an admin error + if html =~ "Failed to prepare CSV import" do + # This is acceptable - CSV might have issues, but admin check passed + assert no_admin_error + else + # Import should have started + assert import_started or html =~ "CSV File" + end + end + + test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Simulate file upload using helper function + upload_csv_file(view, csv_content) + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Check that import has started or shows appropriate message + html = render(view) + # Either import started successfully OR we see a specific error (not admin error) + import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress" + no_admin_error = not (html =~ "Only administrators can import") + # If import failed, it should be a CSV parsing error, not an admin error + if html =~ "Failed to prepare CSV import" do + # This is acceptable - CSV might have issues, but admin check passed + assert no_admin_error + else + # Import should have started + assert import_started or html =~ "CSV File" + end + end + + test "non-admin cannot start import", %{conn: conn} do + # Member (own_data) user + member_user = Mv.Fixtures.user_with_role_fixture("own_data") + conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user) + + # Router plug redirects non-admin users before LiveView loads + assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} = + live(conn, ~p"/admin/import-export") + + # Should redirect to user profile page + assert redirect_path =~ "/users/" + # Should show permission error in flash + assert error_message =~ "don't have permission" + end + + test "invalid CSV shows user-friendly error", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Create invalid CSV (missing required fields) + invalid_csv = "invalid_header\nincomplete_row" + + # Simulate file upload using helper function + upload_csv_file(view, invalid_csv, "invalid.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Check for error message (flash) + html = render(view) + assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare" + end + + @tag :skip + test "empty CSV shows error", %{conn: conn} do + # Skip this test - Phoenix LiveView has issues with empty file uploads in tests + # The error is handled correctly in production, but test framework has limitations + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + empty_csv = " " + csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"]) + File.write!(csv_path, empty_csv) + + view + |> file_input("#csv-upload-form", :csv_file, [ + %{ + last_modified: System.system_time(:second), + name: "empty.csv", + content: empty_csv, + size: byte_size(empty_csv), + type: "text/csv" + } + ]) + |> render_upload("empty.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Check for error message + html = render(view) + assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare" + end + end + + describe "CSV Import - Step 3: Chunk Processing" do + setup %{conn: conn} do + # Ensure admin user + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + + # Read valid CSV fixture + valid_csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) + |> File.read!() + + # Read invalid CSV fixture + invalid_csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"]) + |> File.read!() + + {:ok, + conn: conn, + admin_user: admin_user, + valid_csv_content: valid_csv_content, + invalid_csv_content: invalid_csv_content} + end + + test "happy path: valid CSV processes all chunks and shows done status", %{ + conn: conn, + valid_csv_content: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Simulate file upload using helper function + upload_csv_file(view, csv_content) + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for processing to complete + # In test mode, chunks are processed synchronously and messages are sent via send/2 + # render(view) processes handle_info messages, so we call it multiple times + # to ensure all messages are processed + # Use the same approach as "success rendering" test which works + Process.sleep(1000) + + html = render(view) + # Should show success count (inserted count) + assert html =~ "Inserted" or html =~ "inserted" or html =~ "2" + # Should show completed status + assert html =~ "completed" or html =~ "done" or html =~ "Import completed" or + has_element?(view, "[data-testid='import-results-panel']") + end + + test "error handling: invalid CSV shows errors with line numbers", %{ + conn: conn, + invalid_csv_content: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Simulate file upload using helper function + upload_csv_file(view, csv_content, "invalid_import.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for chunk processing + Process.sleep(500) + + html = render(view) + # Should show failure count > 0 + assert html =~ "failed" or html =~ "error" or html =~ "Failed" + + # Should show line numbers in errors (from service, not recalculated) + # Line numbers should be 2, 3 (header is line 1) + assert html =~ "2" or html =~ "3" or html =~ "line" + end + + test "error cap: many failing rows caps errors at 50", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Generate CSV with 100 invalid rows (all missing email) + header = "first_name;last_name;email;street;postal_code;city\n" + invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n" + large_invalid_csv = header <> Enum.join(invalid_rows) + + # Simulate file upload using helper function + upload_csv_file(view, large_invalid_csv, "large_invalid.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for chunk processing + Process.sleep(1000) + + html = render(view) + # Should show failed count == 100 + assert html =~ "100" or html =~ "failed" + + # Errors should be capped at 50 (but we can't easily check exact count in HTML) + # The important thing is that processing completes without crashing + assert html =~ "done" or html =~ "complete" or html =~ "finished" + end + + test "chunk scheduling: progress updates show chunk processing", %{ + conn: conn, + valid_csv_content: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Simulate file upload using helper function + upload_csv_file(view, csv_content) + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait a bit for processing to start + Process.sleep(200) + + # Check that status area exists (with aria-live for accessibility) + html = render(view) + + assert html =~ "aria-live" or html =~ "status" or html =~ "progress" or + html =~ "Processing" or html =~ "chunk" + + # Final state should be :done + Process.sleep(500) + final_html = render(view) + assert final_html =~ "done" or final_html =~ "complete" or final_html =~ "finished" + end + end + + describe "CSV Import - Step 4: Results UI" do + setup %{conn: conn} do + # Ensure admin user + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + + # Read valid CSV fixture + valid_csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) + |> File.read!() + + # Read invalid CSV fixture + invalid_csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"]) + |> File.read!() + + # Read CSV with unknown custom field + unknown_custom_field_csv = + Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"]) + |> File.read!() + + {:ok, + conn: conn, + admin_user: admin_user, + valid_csv_content: valid_csv_content, + invalid_csv_content: invalid_csv_content, + unknown_custom_field_csv: unknown_custom_field_csv} + end + + test "success rendering: valid CSV shows success count", %{ + conn: conn, + valid_csv_content: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Simulate file upload using helper function + upload_csv_file(view, csv_content) + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for processing to complete + Process.sleep(1000) + + html = render(view) + # Should show success count (inserted count) + assert html =~ "Inserted" or html =~ "inserted" or html =~ "2" + # Should show completed status + assert html =~ "completed" or html =~ "done" or html =~ "Import completed" + end + + test "error rendering: invalid CSV shows failure count and error list with line numbers", %{ + conn: conn, + invalid_csv_content: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Simulate file upload using helper function + upload_csv_file(view, csv_content, "invalid_import.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for processing + Process.sleep(1000) + + html = render(view) + # Should show failure count + assert html =~ "Failed" or html =~ "failed" + + # Should show error list with line numbers (from service, not recalculated) + assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3" + # Should show error messages + assert html =~ "error" or html =~ "Error" or html =~ "Errors" + end + + test "warning rendering: CSV with unknown custom field shows warnings block", %{ + conn: conn, + unknown_custom_field_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + csv_path = + Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"]) + + File.write!(csv_path, csv_content) + + view + |> file_input("#csv-upload-form", :csv_file, [ + %{ + last_modified: System.system_time(:second), + name: "unknown_custom.csv", + content: csv_content, + size: byte_size(csv_content), + type: "text/csv" + } + ]) + |> render_upload("unknown_custom.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for processing + Process.sleep(1000) + + html = render(view) + # Should show warnings block (if warnings were generated) + # Warnings are generated when unknown custom field columns are detected + # Check if warnings section exists OR if import completed successfully + has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings" + import_completed = html =~ "completed" or html =~ "done" or html =~ "Import Results" + + # If warnings exist, they should contain the column name + if has_warnings do + assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or + html =~ "will be ignored" + end + + # Import should complete (either with or without warnings) + assert import_completed + end + + test "A11y: file input has label", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + # Check for label associated with file input + assert html =~ ~r/]*for=["']csv_file["']/i or + html =~ ~r/]*>.*CSV File/i + end + + test "A11y: status/progress container has aria-live", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + html = render(view) + # Check for aria-live attribute in status area + assert html =~ ~r/aria-live=["']polite["']/i + end + + test "A11y: links have descriptive text", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + # Check that links have descriptive text (not just "click here") + # Template links should have text like "English Template" or "German Template" + assert html =~ "English Template" or html =~ "German Template" or + html =~ "English" or html =~ "German" + + # Custom Fields section should have descriptive text (Data Field button) + # The component uses "New Data Field" button, not a link + assert html =~ "Data Field" or html =~ "New Data Field" or html =~ "Manage Memberdata" + end + end + + describe "CSV Import - Step 5: Edge Cases" do + setup %{conn: conn} do + # Ensure admin user + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + + {:ok, conn: conn, admin_user: admin_user} + end + + test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Read CSV with BOM + csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"]) + |> File.read!() + + # Simulate file upload using helper function + upload_csv_file(view, csv_content, "bom_import.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for processing + Process.sleep(1000) + + html = render(view) + # Should succeed (BOM is stripped automatically) + assert html =~ "completed" or html =~ "done" or html =~ "Inserted" + # Should not show error about BOM + refute html =~ "BOM" or html =~ "encoding" + end + + test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4) + csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"]) + |> File.read!() + + # Simulate file upload using helper function + upload_csv_file(view, csv_content, "empty_lines.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + # Wait for processing + Process.sleep(1000) + + html = render(view) + # Should show error with correct line number (line 4, not line 3) + # The error should be on the line with invalid email, which is after the empty line + assert html =~ "Line 4" or html =~ "line 4" or html =~ "4" + # Should show error message + assert html =~ "error" or html =~ "Error" or html =~ "invalid" + end + + test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Generate CSV with 1001 rows dynamically + header = "first_name;last_name;email;street;postal_code;city\n" + + rows = + for i <- 1..1001 do + "Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n" + end + + large_csv = header <> Enum.join(rows) + + # Simulate file upload using helper function + upload_csv_file(view, large_csv, "too_many_rows.csv") + + view + |> form("#csv-upload-form", %{}) + |> render_submit() + + html = render(view) + # Should show user-friendly error about row limit + assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or + html =~ "Failed to prepare" + end + + test "wrong file type (.txt): upload shows error", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + + # Create .txt file (not .csv) + txt_content = "This is not a CSV file\nJust some text\n" + txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"]) + File.write!(txt_path, txt_content) + + # Try to upload .txt file + # Note: allow_upload is configured to accept only .csv, so this should fail + # In tests, we can't easily simulate file type rejection, but we can check + # that the UI shows appropriate help text + html = render(view) + # Should show CSV-only restriction in help text + assert html =~ "CSV" or html =~ "csv" or html =~ ".csv" + end + + test "file input has correct accept attribute for CSV only", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import-export") + + # Check that file input has accept attribute for CSV + assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only" + end + end +end