diff --git a/test/membership/group_test.exs b/test/membership/group_test.exs index 724d930..72db874 100644 --- a/test/membership/group_test.exs +++ b/test/membership/group_test.exs @@ -1,8 +1,9 @@ defmodule Mv.Membership.GroupTest do @moduledoc """ Tests for Group resource validations, CRUD operations, and relationships. + Uses async: true; no shared DB state or sandbox constraints. """ - use Mv.DataCase, async: false + use Mv.DataCase, async: true alias Mv.Membership diff --git a/test/membership/member_group_test.exs b/test/membership/member_group_test.exs index 4dd4ae8..430ae7b 100644 --- a/test/membership/member_group_test.exs +++ b/test/membership/member_group_test.exs @@ -1,8 +1,9 @@ defmodule Mv.Membership.MemberGroupTest do @moduledoc """ Tests for MemberGroup join table resource - validations and cascade delete behavior. + Uses async: true; no shared DB state or sandbox constraints. """ - use Mv.DataCase, async: false + use Mv.DataCase, async: true alias Mv.Membership diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs index a8688bf..6b0a300 100644 --- a/test/mv/membership/members_csv_test.exs +++ b/test/mv/membership/members_csv_test.exs @@ -199,22 +199,6 @@ defmodule Mv.Membership.MembersCSVTest do assert csv =~ "M,m@m.com,Paid" end - test "computed column with payment_status key exports same value (alias)" do - columns = [ - %{header: "First Name", kind: :member_field, key: "first_name"}, - %{header: "Membership Fee Status", kind: :computed, key: :payment_status} - ] - - member = %{first_name: "X", payment_status: "Unpaid"} - - iodata = MembersCSV.export([member], columns) - csv = IO.iodata_to_binary(iodata) - - assert csv =~ "Membership Fee Status" - assert csv =~ "Unpaid" - assert csv =~ "X,Unpaid" - end - test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do member = %{ first_name: "=SUM(A1:A10)", diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs index 34f5a75..737288d 100644 --- a/test/mv_web/controllers/member_export_controller_test.exs +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -14,6 +14,11 @@ defmodule MvWeb.MemberExportControllerTest do end end + # Export uses humanize_field (e.g. "first_name" -> "First name"); normalize \r\n line endings + defp export_lines(body) do + body |> String.split(~r/\r?\n/, trim: true) + end + describe "POST /members/export.csv" do setup %{conn: conn} do # Create 3 members for export tests @@ -41,7 +46,7 @@ defmodule MvWeb.MemberExportControllerTest do %{member1: m1, member2: m2, member3: m3, conn: conn} end - test "selected export: returns 200, text/csv, header + exactly 2 data rows", %{ + test "exports selected members with specified fields", %{ conn: conn, member1: m1, member2: m2 @@ -68,18 +73,18 @@ defmodule MvWeb.MemberExportControllerTest do assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv" body = response(conn, 200) - lines = String.split(body, "\n", trim: true) + lines = export_lines(body) + header = hd(lines) - # Header + 2 data rows (headers are localized labels) + # Header + 2 data rows (controller uses humanize_field: "first_name" -> "First name") assert length(lines) == 3 - assert hd(lines) =~ "First Name" - assert hd(lines) =~ "Email" + assert header =~ "First Name,Last Name,Email" assert body =~ "Alice" assert body =~ "Bob" refute body =~ "Carol" end - test "all export: selected_ids=[] returns all members (at least 3 data rows)", %{ + test "exports all members when selected_ids is empty", %{ conn: conn, member1: _m1, member2: _m2, @@ -105,17 +110,16 @@ defmodule MvWeb.MemberExportControllerTest do assert conn.status == 200 body = response(conn, 200) - lines = String.split(body, "\n", trim: true) + lines = export_lines(body) - # Header + at least 3 data rows (headers are localized labels) + # Header + at least 3 data rows (controller uses humanize_field) assert length(lines) >= 4 - assert hd(lines) =~ "First Name" assert body =~ "Alice" assert body =~ "Bob" assert body =~ "Carol" end - test "whitelist: unknown member_fields are not in header", %{conn: conn, member1: m1} do + test "filters out unknown member fields from export", %{conn: conn, member1: m1} do payload = %{ "selected_ids" => [m1.id], "member_fields" => ["first_name", "unknown_field", "email"], @@ -136,20 +140,20 @@ defmodule MvWeb.MemberExportControllerTest do assert conn.status == 200 body = response(conn, 200) - header = body |> String.split("\n", trim: true) |> hd() + header = body |> export_lines() |> hd() - assert header =~ "First Name" - assert header =~ "Email" + assert header =~ "First Name,Email" refute header =~ "unknown_field" end - test "export includes membership_fee_status column when requested", %{ + test "export includes membership_fee_status computed field when requested", %{ conn: conn, member1: m1 } do payload = %{ "selected_ids" => [m1.id], - "member_fields" => ["first_name", "membership_fee_status"], + "member_fields" => ["first_name"], + "computed_fields" => ["membership_fee_status"], "custom_field_ids" => [], "query" => nil, "sort_field" => nil, @@ -167,44 +171,13 @@ defmodule MvWeb.MemberExportControllerTest do assert conn.status == 200 body = response(conn, 200) - header = body |> String.split("\n", trim: true) |> hd() + header = body |> export_lines() |> hd() - assert header =~ "First Name" - assert header =~ "Membership Fee Status" + assert header =~ "First Name,Membership Fee Status" assert body =~ "Alice" end - test "export with payment_status alias: header shows Membership Fee Status", %{ - conn: conn, - member1: m1 - } do - payload = %{ - "selected_ids" => [m1.id], - "member_fields" => ["first_name", "payment_status"], - "custom_field_ids" => [], - "query" => nil, - "sort_field" => nil, - "sort_order" => nil - } - - conn = get(conn, "/members") - csrf_token = csrf_token_from_conn(conn) - - conn = - post(conn, "/members/export.csv", %{ - "payload" => Jason.encode!(payload), - "_csrf_token" => csrf_token - }) - - assert conn.status == 200 - body = response(conn, 200) - header = body |> String.split("\n", trim: true) |> hd() - - assert header =~ "Membership Fee Status" - assert body =~ "Alice" - end - - test "export with show_current_cycle: membership fee status column exists stably", %{ + test "exports membership fee status computed field with show_current_cycle option", %{ conn: conn, member1: _m1, member2: _m2, @@ -212,7 +185,8 @@ defmodule MvWeb.MemberExportControllerTest do } do payload = %{ "selected_ids" => [], - "member_fields" => ["first_name", "email", "membership_fee_status"], + "member_fields" => [], + "computed_fields" => ["membership_fee_status"], "custom_field_ids" => [], "query" => nil, "sort_field" => nil, @@ -231,13 +205,300 @@ defmodule MvWeb.MemberExportControllerTest do assert conn.status == 200 body = response(conn, 200) - lines = String.split(body, "\n", trim: true) - - assert length(lines) >= 4 + lines = export_lines(body) header = hd(lines) - assert header =~ "First Name" - assert header =~ "Email" + assert header =~ "Membership Fee Status" end + + setup %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create custom fields for different types + {:ok, string_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Phone Number", + value_type: :string + }) + |> Ash.create(actor: system_actor) + + {:ok, integer_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Membership Number", + value_type: :integer + }) + |> Ash.create(actor: system_actor) + + {:ok, boolean_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Active Member", + value_type: :boolean + }) + |> Ash.create(actor: system_actor) + + # Create members with custom field values + {:ok, member_with_string} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "String", + email: "test.string@example.com" + }, + actor: system_actor + ) + + {:ok, _cfv_string} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_string.id, + custom_field_id: string_field.id, + value: "+49 123 456789" + }) + |> Ash.create(actor: system_actor) + + {:ok, member_with_integer} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "Integer", + email: "test.integer@example.com" + }, + actor: system_actor + ) + + {:ok, _cfv_integer} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_integer.id, + custom_field_id: integer_field.id, + value: 12345 + }) + |> Ash.create(actor: system_actor) + + {:ok, member_with_boolean} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "Boolean", + email: "test.boolean@example.com" + }, + actor: system_actor + ) + + {:ok, _cfv_boolean} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_boolean.id, + custom_field_id: boolean_field.id, + value: true + }) + |> Ash.create(actor: system_actor) + + {:ok, member_without_value} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "NoValue", + email: "test.novalue@example.com" + }, + actor: system_actor + ) + + %{ + conn: conn, + string_field: string_field, + integer_field: integer_field, + boolean_field: boolean_field, + member_with_string: member_with_string, + member_with_integer: member_with_integer, + member_with_boolean: member_with_boolean, + member_without_value: member_without_value + } + end + + test "export includes custom field column with string value", %{ + conn: conn, + string_field: string_field, + member_with_string: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name", "last_name"], + "custom_field_ids" => [string_field.id], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + lines = export_lines(body) + header = hd(lines) + + assert header =~ "First Name" + assert header =~ "Last Name" + assert header =~ "Phone Number" + assert body =~ "Test" + assert body =~ "String" + assert body =~ "+49 123 456789" + end + + test "export includes custom field column with integer value", %{ + conn: conn, + integer_field: integer_field, + member_with_integer: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name"], + "custom_field_ids" => [integer_field.id], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> export_lines() |> hd() + + assert header =~ "First Name" + assert header =~ "Membership Number" + assert body =~ "Test" + assert body =~ "12345" + end + + test "export includes custom field column with boolean value", %{ + conn: conn, + boolean_field: boolean_field, + member_with_boolean: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name"], + "custom_field_ids" => [boolean_field.id], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> export_lines() |> hd() + + assert header =~ "First Name" + assert header =~ "Active Member" + assert body =~ "Test" + # Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter + assert body =~ "Yes" + end + + test "export shows empty cell for member without custom field value", %{ + conn: conn, + string_field: string_field, + member_without_value: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name", "last_name"], + "custom_field_ids" => [string_field.id], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + lines = export_lines(body) + header = hd(lines) + data_line = Enum.at(lines, 1) + + assert header =~ "Phone Number" + # Empty custom field value should result in empty cell (two consecutive commas) + assert data_line =~ "Test,NoValue," + end + + test "export includes multiple custom fields in correct order", %{ + conn: conn, + string_field: string_field, + integer_field: integer_field, + boolean_field: boolean_field, + member_with_string: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name"], + "custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> export_lines() |> hd() + + assert header =~ "First Name" + assert header =~ "Phone Number" + assert header =~ "Membership Number" + assert header =~ "Active Member" + # Verify order: member fields first, then custom fields in the order specified + header_parts = String.split(header, ",") + first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name")) + phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number")) + membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number")) + active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member")) + + assert first_name_idx < phone_idx + assert phone_idx < membership_idx + assert membership_idx < active_idx + 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 index 653cd8d..d0d20e1 100644 --- a/test/mv_web/live/import_export_live_test.exs +++ b/test/mv_web/live/import_export_live_test.exs @@ -1,9 +1,16 @@ defmodule MvWeb.ImportExportLiveTest do + @moduledoc """ + Tests for Import/Export LiveView: authorization (business rule), CSV import integration, + and minimal UI smoke tests. CSV parsing/validation logic is covered by + Mv.Membership.Import.MemberCSVTest; here we verify access control and end-to-end outcomes. + """ use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest - # Helper function to upload CSV file in tests - # Reduces code duplication across multiple test cases + alias Mv.Membership + + defp put_locale_en(conn), do: Plug.Conn.put_session(conn, "locale", "en") + defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do view |> file_input("#csv-upload-form", :csv_file, [ @@ -18,613 +25,135 @@ defmodule MvWeb.ImportExportLiveTest do |> render_upload(filename) end - describe "Import/Export LiveView" do - @describetag :ui - 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 + defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit() - test "renders the import/export page", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/admin/import-export") + defp wait_for_import_completion, do: Process.sleep(1000) - 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 - @describetag :ui - 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 + # ---------- Business logic: Authorization ---------- + describe "Authorization" do + test "non-admin user cannot access import/export page and sees permission error", %{ + conn: conn + } do 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}}}} = + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(member_user) + |> put_locale_en() + + assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => msg}}}} = 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" + assert msg =~ "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) + test "admin user can access page and run import", %{conn: conn} do + conn = put_locale_en(conn) - # 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) + submit_import(view) + wait_for_import_completion() - # Trigger start_import event via form submit - assert view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Check that import has started using data-testid - # Either import-progress-container exists (import started) OR we see a CSV error + assert has_element?(view, "[data-testid='import-results-panel']") + assert has_element?(view, "[data-testid='import-summary']") html = render(view) - import_started = has_element?(view, "[data-testid='import-progress-container']") - no_admin_error = not (html =~ "Only administrators can import") + refute html =~ "Import aborted" + assert html =~ "Successfully inserted" - # 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 - check for progress container - assert import_started - end - end + # Business outcome: two members from fixture were created + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, members} = Membership.list_members(actor: system_actor) - test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do - {:ok, view, _html} = live(conn, ~p"/admin/import-export") + imported = + Enum.filter(members, fn m -> + m.email in ["alice.smith@example.com", "bob.johnson@example.com"] + end) - # Simulate file upload using helper function - upload_csv_file(view, csv_content) - - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Check that import has started using data-testid - html = render(view) - import_started = has_element?(view, "[data-testid='import-progress-container']") - 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 - check for progress container - assert import_started - 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" + assert length(imported) == 2 end end - describe "CSV Import - Step 3: Chunk Processing" do + # ---------- Business logic: Import behaviour (integration) ---------- + describe "CSV Import - integration" 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 = + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(admin_user) + |> put_locale_en() + + valid_csv = Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) |> File.read!() - # Read invalid CSV fixture - invalid_csv_content = + invalid_csv = 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 - Process.sleep(1000) - - # Check that import-results-panel exists (import completed) - assert has_element?(view, "[data-testid='import-results-panel']") - - # Verify success count is shown - html = render(view) - assert html =~ "Successfully inserted" or html =~ "inserted" - 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(1000) - - # Check that import-results-panel exists (import completed with errors) - assert has_element?(view, "[data-testid='import-results-panel']") - - # Check that error list exists - assert has_element?(view, "[data-testid='import-error-list']") - - 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) - - # Check that import-results-panel exists (import completed) - assert has_element?(view, "[data-testid='import-results-panel']") - - 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 - # Import is done when import-results-panel exists - 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() - - # In test mode chunks run synchronously, so we may already be :done when we check. - # Accept either progress container (if we caught :running) or results panel (if already :done). - _html = render(view) - - assert has_element?(view, "[data-testid='import-progress-container']") or - has_element?(view, "[data-testid='import-results-panel']") - - # Wait for final state and assert results panel is shown - Process.sleep(500) - assert has_element?(view, "[data-testid='import-results-panel']") - 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 = + unknown_cf_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} + valid_csv: valid_csv, + invalid_csv: invalid_csv, + unknown_custom_field_csv: unknown_cf_csv} end - test "success rendering: valid CSV shows success count", %{ - conn: conn, - valid_csv_content: csv_content - } do + test "invalid CSV shows user-friendly prepare error", %{conn: conn} 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) - - # Check that import-results-panel exists (import completed) - assert has_element?(view, "[data-testid='import-results-panel']") - - # Verify success count is shown + upload_csv_file(view, "invalid_header\nincomplete_row", "invalid.csv") + submit_import(view) html = render(view) - assert html =~ "Successfully inserted" or html =~ "inserted" + assert html =~ "Failed to prepare CSV import" end - test "error rendering: invalid CSV shows failure count and error list with line numbers", %{ + test "invalid rows show errors with correct CSV line numbers", %{ conn: conn, - invalid_csv_content: csv_content + invalid_csv: 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") + submit_import(view) + wait_for_import_completion() - view - |> form("#csv-upload-form", %{}) - |> render_submit() - - # Wait for processing - Process.sleep(1000) - - # Check that import-results-panel exists (import completed with errors) assert has_element?(view, "[data-testid='import-results-panel']") - - # Check that error list exists assert has_element?(view, "[data-testid='import-error-list']") - 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" + assert html =~ "Failed" + # Fixture has invalid email on line 2 and missing email on line 3 + assert html =~ "Line 2" + assert html =~ "Line 3" end - test "warning rendering: CSV with unknown custom field shows warnings block", %{ - conn: conn, - unknown_custom_field_csv: csv_content - } do + test "error list is capped and truncation message is shown", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import-export") + header = "first_name;last_name;email;street;postal_code;city\n" - csv_path = - Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"]) + invalid_rows = + for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n" - File.write!(csv_path, csv_content) + upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv") + submit_import(view) + wait_for_import_completion() - 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) - - # Check that import-results-panel exists (import completed) assert has_element?(view, "[data-testid='import-results-panel']") - + assert has_element?(view, "[data-testid='import-error-list']") html = render(view) - # Should show warnings block (if warnings were generated) - # Warnings are generated when unknown custom field columns are detected - has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings" - - # 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) - # Verified by import-results-panel existence above + assert html =~ "100" + assert html =~ "Error list truncated" end - @tag :ui - 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 - - @tag :ui - test "A11y: status/progress container has aria-live", %{conn: conn} do + test "row limit is enforced (1001 rows rejected)", %{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 - - @tag :ui - 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" - - # Import page has link "Manage Member Data" and info text about "data field" - assert html =~ "Manage Member Data" or html =~ "data field" or html =~ "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"/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) - - # Check that import-results-panel exists (import completed successfully) - assert has_element?(view, "[data-testid='import-results-panel']") - - html = render(view) - # Should succeed (BOM is stripped automatically) - assert html =~ "Successfully inserted" 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 = @@ -632,45 +161,122 @@ defmodule MvWeb.ImportExportLiveTest 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() - + upload_csv_file(view, header <> Enum.join(rows), "too_many_rows.csv") + submit_import(view) 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" + assert html =~ "exceeds" end - @tag :ui - test "wrong file type (.txt): upload shows error", %{conn: conn} do + test "BOM and semicolon delimiter are accepted", %{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) + csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"]) + |> File.read!() - # 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 + upload_csv_file(view, csv_content, "bom_import.csv") + submit_import(view) + wait_for_import_completion() + + assert has_element?(view, "[data-testid='import-results-panel']") html = render(view) - # Should show CSV-only restriction in help text - assert html =~ "CSV" or html =~ "csv" or html =~ ".csv" + assert html =~ "Successfully inserted" + refute html =~ "BOM" end - @tag :ui - test "file input has correct accept attribute for CSV only", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/admin/import-export") + test "physical line numbers in errors (empty line does not shift numbering)", %{ + 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" + csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"]) + |> File.read!() + + upload_csv_file(view, csv_content, "empty_lines.csv") + submit_import(view) + wait_for_import_completion() + + assert has_element?(view, "[data-testid='import-error-list']") + html = render(view) + # Invalid row is on physical line 4 (header, valid row, empty line, then invalid) + assert html =~ "Line 4" + end + + test "unknown custom field column produces warnings", %{ + conn: conn, + unknown_custom_field_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + upload_csv_file(view, csv_content, "unknown_custom.csv") + submit_import(view) + wait_for_import_completion() + + assert has_element?(view, "[data-testid='import-results-panel']") + assert has_element?(view, "[data-testid='import-warnings']") + html = render(view) + assert html =~ "Warnings" end end + + # ---------- UI (smoke / framework): tagged for exclusion from fast CI ---------- + describe "Import/Export page UI" do + @describetag :ui + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(admin_user) + |> put_locale_en() + + {:ok, conn: conn} + end + + test "page loads and shows import form and export placeholder", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + assert has_element?(view, "[data-testid='csv-upload-form']") + assert has_element?(view, "[data-testid='start-import-button']") + assert has_element?(view, "[data-testid='custom-fields-link']") + html = render(view) + assert html =~ "Import Members (CSV)" + assert html =~ "Export Members (CSV)" + assert html =~ "Export functionality will be available" + end + + test "template links and file input are present", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + assert has_element?(view, "a[href*='/templates/member_import_en.csv']") + assert has_element?(view, "a[href*='/templates/member_import_de.csv']") + assert has_element?(view, "label[for='csv_file']") + assert has_element?(view, "#csv_file_help") + assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']") + end + + test "after successful import, progress container has aria-live", %{conn: conn} do + csv_content = + Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) + |> File.read!() + + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + upload_csv_file(view, csv_content) + submit_import(view) + wait_for_import_completion() + assert has_element?(view, "[data-testid='import-progress-container']") + html = render(view) + assert html =~ "aria-live" + end + end + + # Skip: LiveView test harness does not reliably support empty/minimal file uploads. + # See docs/csv-member-import-v1.md (Issue #9). + @tag :skip + test "empty CSV shows error", %{conn: conn} do + conn = put_locale_en(conn) + {:ok, view, _html} = live(conn, ~p"/admin/import-export") + upload_csv_file(view, " ", "empty.csv") + submit_import(view) + html = render(view) + assert html =~ "Failed to prepare" + end end diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index 99e15ea..c8201fd 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -225,7 +225,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> element("[data-testid='custom_field_#{field.id}']") |> render_click() - assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + # Patch URL may include fields param (current field selection); assert sort outcome instead + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']") end test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do