defmodule MvWeb.ImportLiveTest do @moduledoc """ Tests for Import 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 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, [ %{ last_modified: System.system_time(:second), name: filename, content: csv_content, size: byte_size(csv_content), type: "text/csv" } ]) |> render_upload(filename) end defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit() defp wait_for_import_completion, do: Process.sleep(1000) # ---------- Business logic: Authorization ---------- describe "Authorization" do test "non-admin user cannot access import page and sees permission error", %{ conn: conn } do member_user = Mv.Fixtures.user_with_role_fixture("own_data") 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") assert redirect_path =~ "/users/" assert msg =~ "don't have permission" end test "admin user can access page and run import", %{conn: conn} do conn = put_locale_en(conn) csv_content = Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) |> File.read!() {:ok, view, _html} = live(conn, ~p"/admin/import") upload_csv_file(view, csv_content) submit_import(view) wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") assert has_element?(view, "[data-testid='import-summary']") html = render(view) refute html =~ "Import aborted" assert html =~ "Successfully inserted" # Business outcome: two members from fixture were created system_actor = Mv.Helpers.SystemActor.get_system_actor() {:ok, members} = Membership.list_members(actor: system_actor) imported = Enum.filter(members, fn m -> m.email in ["alice.smith@example.com", "bob.johnson@example.com"] end) assert length(imported) == 2 end end # ---------- Business logic: Import behaviour (integration) ---------- describe "CSV Import - integration" do 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() valid_csv = Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) |> File.read!() invalid_csv = Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"]) |> File.read!() unknown_cf_csv = Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"]) |> File.read!() {:ok, conn: conn, valid_csv: valid_csv, invalid_csv: invalid_csv, unknown_custom_field_csv: unknown_cf_csv} end test "invalid CSV shows user-friendly prepare error", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") upload_csv_file(view, "invalid_header\nincomplete_row", "invalid.csv") submit_import(view) html = render(view) assert html =~ "Failed to prepare CSV import" end test "invalid rows show errors with correct CSV line numbers", %{ conn: conn, invalid_csv: csv_content } do {:ok, view, _html} = live(conn, ~p"/admin/import") upload_csv_file(view, csv_content, "invalid_import.csv") submit_import(view) wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") assert has_element?(view, "[data-testid='import-error-list']") html = render(view) 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 "error list is capped and truncation message is shown", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") 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" upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv") submit_import(view) wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") assert has_element?(view, "[data-testid='import-error-list']") html = render(view) assert html =~ "100" assert html =~ "Error list truncated" end test "row limit is enforced (1001 rows rejected)", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") 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 upload_csv_file(view, header <> Enum.join(rows), "too_many_rows.csv") submit_import(view) html = render(view) assert html =~ "exceeds" end test "BOM and semicolon delimiter are accepted", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") csv_content = Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"]) |> File.read!() 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) assert html =~ "Successfully inserted" refute html =~ "BOM" end test "physical line numbers in errors (empty line does not shift numbering)", %{ conn: conn } do {:ok, view, _html} = live(conn, ~p"/admin/import") 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") 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 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", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") 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)" end test "template links and file input are present", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") 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") 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") upload_csv_file(view, " ", "empty.csv") submit_import(view) html = render(view) assert html =~ "Failed to prepare" end end