From b429a4dbb6b2e9318fed6c22eb7c13f9d4cc6634 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 4 Feb 2026 16:43:12 +0100 Subject: [PATCH] test: adds tests --- test/mv/membership/members_csv_test.exs | 124 +++++++++++++ .../member_export_controller_test.exs | 146 +++++++++++++++ test/mv_web/live/import_export_live_test.exs | 7 + test/mv_web/live/profile_navigation_test.exs | 2 +- .../member_live/form_error_handling_test.exs | 1 + test/mv_web/member_live/index_test.exs | 170 ++++++++++++------ 6 files changed, 391 insertions(+), 59 deletions(-) create mode 100644 test/mv/membership/members_csv_test.exs create mode 100644 test/mv_web/controllers/member_export_controller_test.exs diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs new file mode 100644 index 0000000..a2228aa --- /dev/null +++ b/test/mv/membership/members_csv_test.exs @@ -0,0 +1,124 @@ +defmodule Mv.Membership.MembersCSVTest do + use ExUnit.Case, async: true + + alias Mv.Membership.MembersCSV + + describe "export/3" do + test "returns CSV with header and one data row (member fields only)" do + member = %{first_name: "Jane", email: "jane@example.com"} + member_fields = ["first_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "first_name" + assert csv =~ "email" + assert csv =~ "Jane" + assert csv =~ "jane@example.com" + # One header line, one data line + lines = String.split(csv, "\n", trim: true) + assert length(lines) == 2 + end + + test "escapes cell containing comma (RFC 4180 quoted)" do + member = %{first_name: "Doe, John", email: "john@example.com"} + member_fields = ["first_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + # Comma inside value must be quoted so the cell is one field + assert csv =~ ~s("Doe, John") + assert csv =~ "john@example.com" + end + + test "escapes cell containing double-quote (RFC 4180 doubled and quoted)" do + member = %{first_name: ~s(He said "Hi"), email: "a@b.com"} + member_fields = ["first_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + # Double-quote inside value must be doubled and cell quoted + assert csv =~ ~s("He said ""Hi""") + assert csv =~ "a@b.com" + end + + test "formats date as ISO8601 for member fields" do + member = %{first_name: "D", email: "d@d.com", join_date: ~D[2024-03-15]} + iodata = MembersCSV.export([member], ["first_name", "email", "join_date"], %{}) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "2024-03-15" + assert csv =~ "join_date" + end + + test "formats nil as empty string" do + member = %{first_name: "Only", last_name: nil, email: "x@y.com"} + member_fields = ["first_name", "last_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "first_name" + assert csv =~ "Only" + assert csv =~ "x@y.com" + # Nil becomes empty; between Only and x@y we have empty (e.g. Only,,x@y.com) + assert csv =~ "Only,,x@y" + end + + test "formats boolean as true/false" do + # Use a field we can set to boolean via a custom-like struct - member has no boolean field. + # So we test via custom field instead. + custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean} + custom_fields_by_id = %{"cf-1" => custom_cf} + + member_with_cfv = %{ + first_name: "Test", + email: "e@e.com", + custom_field_values: [ + %{custom_field_id: "cf-1", value: true, custom_field: custom_cf} + ] + } + + iodata = + MembersCSV.export( + [member_with_cfv], + ["first_name", "email"], + custom_fields_by_id + ) + + csv = IO.iodata_to_binary(iodata) + assert csv =~ "Active" + # Formatter yields "Yes" for true (gettext) + assert csv =~ "Yes" + end + + test "includes custom field columns in header and rows (order from map)" do + cf1 = %{id: "a", name: "Custom1", value_type: :string} + cf2 = %{id: "b", name: "Custom2", value_type: :string} + # Map order: a then b + custom_fields_by_id = %{"a" => cf1, "b" => cf2} + + member = %{ + first_name: "M", + email: "m@m.com", + custom_field_values: [ + %{custom_field_id: "a", value: "v1", custom_field: cf1}, + %{custom_field_id: "b", value: "v2", custom_field: cf2} + ] + } + + iodata = MembersCSV.export([member], ["first_name", "email"], custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "first_name,email,Custom1,Custom2" + assert csv =~ "v1" + assert csv =~ "v2" + end + end +end diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs new file mode 100644 index 0000000..122011b --- /dev/null +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -0,0 +1,146 @@ +defmodule MvWeb.MemberExportControllerTest do + use MvWeb.ConnCase, async: true + + alias Mv.Fixtures + + defp csrf_token_from_conn(conn) do + get_session(conn, "_csrf_token") || csrf_token_from_html(response(conn, 200)) + end + + defp csrf_token_from_html(html) when is_binary(html) do + case Regex.run(~r/name="csrf-token"\s+content="([^"]+)"/, html) do + [_, token] -> token + _ -> nil + end + end + + describe "POST /members/export.csv" do + setup %{conn: conn} do + # Create 3 members for export tests + m1 = + Fixtures.member_fixture(%{ + first_name: "Alice", + last_name: "One", + email: "alice.one@example.com" + }) + + m2 = + Fixtures.member_fixture(%{ + first_name: "Bob", + last_name: "Two", + email: "bob.two@example.com" + }) + + m3 = + Fixtures.member_fixture(%{ + first_name: "Carol", + last_name: "Three", + email: "carol.three@example.com" + }) + + %{member1: m1, member2: m2, member3: m3, conn: conn} + end + + test "selected export: returns 200, text/csv, header + exactly 2 data rows", %{ + conn: conn, + member1: m1, + member2: m2 + } do + payload = %{ + "selected_ids" => [m1.id, m2.id], + "member_fields" => ["first_name", "last_name", "email"], + "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 + assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv" + + body = response(conn, 200) + lines = String.split(body, "\n", trim: true) + + # Header + 2 data rows + assert length(lines) == 3 + assert hd(lines) =~ "first_name" + assert hd(lines) =~ "email" + assert body =~ "Alice" + assert body =~ "Bob" + refute body =~ "Carol" + end + + test "all export: selected_ids=[] returns all members (at least 3 data rows)", %{ + conn: conn, + member1: _m1, + member2: _m2, + member3: _m3 + } do + payload = %{ + "selected_ids" => [], + "member_fields" => ["first_name", "email"], + "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) + lines = String.split(body, "\n", trim: true) + + # Header + at least 3 data rows + 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 + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "unknown_field", "email"], + "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 =~ "first_name" + assert header =~ "email" + refute header =~ "unknown_field" + 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 a165ea6..653cd8d 100644 --- a/test/mv_web/live/import_export_live_test.exs +++ b/test/mv_web/live/import_export_live_test.exs @@ -19,6 +19,7 @@ defmodule MvWeb.ImportExportLiveTest do 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) @@ -45,6 +46,7 @@ defmodule MvWeb.ImportExportLiveTest do 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) @@ -524,6 +526,7 @@ defmodule MvWeb.ImportExportLiveTest do # Verified by import-results-panel existence above end + @tag :ui test "A11y: file input has label", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/import-export") @@ -532,6 +535,7 @@ defmodule MvWeb.ImportExportLiveTest do html =~ ~r/]*>.*CSV File/i end + @tag :ui test "A11y: status/progress container has aria-live", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import-export") @@ -540,6 +544,7 @@ defmodule MvWeb.ImportExportLiveTest do 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") @@ -642,6 +647,7 @@ defmodule MvWeb.ImportExportLiveTest do html =~ "Failed to prepare" end + @tag :ui test "wrong file type (.txt): upload shows error", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import-export") @@ -659,6 +665,7 @@ defmodule MvWeb.ImportExportLiveTest do assert html =~ "CSV" or html =~ "csv" or html =~ ".csv" 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") diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs index b8562cd..72d974c 100644 --- a/test/mv_web/live/profile_navigation_test.exs +++ b/test/mv_web/live/profile_navigation_test.exs @@ -61,7 +61,7 @@ defmodule MvWeb.ProfileNavigationTest do end @tag :skip - # TODO: Implement user initials in navbar avatar - see issue #170 + # Note: User initials in navbar avatar - see issue #170 test "shows user initials in avatar", %{conn: conn} do # Setup: Create and login a user user = create_test_user(%{email: "test.user@example.com"}) diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index b2bf804..9e55cd8 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -9,6 +9,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do require Ash.Query describe "error handling - flash messages" do + @describetag :ui test "shows flash message when member creation fails with validation error", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 3234761..e560c92 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -46,78 +46,82 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: actor) end - test "shows translated title in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members") - # Expected German title - assert html =~ "Mitglieder" - end + describe "translations" do + @describetag :ui + test "shows translated title in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, _view, html} = live(conn, "/members") + # Expected German title + assert html =~ "Mitglieder" + end - test "shows translated title in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members") - # Expected English title - assert html =~ "Members" - end + test "shows translated title in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members") + # Expected English title + assert html =~ "Members" + end - test "shows translated button text in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Speichern" - end + test "shows translated button text in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, _view, html} = live(conn, "/members/new") + assert html =~ "Speichern" + end - test "shows translated button text in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Save" - end + test "shows translated button text in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members/new") + assert html =~ "Save" + end - test "shows translated flash message after creating a member in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, form_view, _html} = live(conn, "/members/new") + test "shows translated flash message after creating a member in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, form_view, _html} = live(conn, "/members/new") - form_data = %{ - "member[first_name]" => "Max", - "member[last_name]" => "Mustermann", - "member[email]" => "max@example.com" - } + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } - # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = - form_view - |> form("#member-form", form_data) - |> render_submit() - |> follow_redirect(conn, "/members") + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") - assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt") - end + assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt") + end - test "shows translated flash message after creating a member in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, form_view, _html} = live(conn, "/members/new") + test "shows translated flash message after creating a member in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, form_view, _html} = live(conn, "/members/new") - form_data = %{ - "member[first_name]" => "Max", - "member[last_name]" => "Mustermann", - "member[email]" => "max@example.com" - } + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } - # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = - form_view - |> form("#member-form", form_data) - |> render_submit() - |> follow_redirect(conn, "/members") + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") - assert has_element?(index_view, "#flash-group", "Member created successfully") + assert has_element?(index_view, "#flash-group", "Member created successfully") + end end describe "sorting integration" do + @describetag :ui test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -200,6 +204,7 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "URL param handling" do + @describetag :ui test "handle_params reads sort query and applies it", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") @@ -226,6 +231,7 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "search and sort integration" do + @describetag :ui test "search maintains sort state", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") @@ -253,6 +259,7 @@ defmodule MvWeb.MemberLive.IndexTest do end end + @tag :ui test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -521,6 +528,50 @@ defmodule MvWeb.MemberLive.IndexTest do end end + describe "export to CSV" do + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, m1} = + Mv.Membership.create_member( + %{first_name: "Export", last_name: "One", email: "export1@example.com"}, + actor: system_actor + ) + + %{member1: m1} + end + + test "export button is rendered when no selection and shows (all)", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, "/members") + + # Button text shows "all" when 0 selected (locale-dependent) + assert html =~ "Export to CSV" + assert html =~ "all" or html =~ "All" + end + + test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + render_click(view, "select_member", %{"id" => member1.id}) + + html = render(view) + assert html =~ "Export to CSV" + assert html =~ "(1)" + end + + test "form has correct action and payload hidden input", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "/members/export.csv" + assert html =~ ~s(name="payload") + assert html =~ ~s(type="hidden") + assert html =~ ~s(name="_csrf_token") + end + end + describe "cycle status filter" do # Helper to create a member (only used in this describe block) defp create_member(attrs, actor) do @@ -780,6 +831,7 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: system_actor) end + @tag :ui test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -788,6 +840,7 @@ defmodule MvWeb.MemberLive.IndexTest do assert state.socket.assigns.boolean_custom_field_filters == %{} end + @tag :ui test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{ conn: conn } do @@ -1762,6 +1815,7 @@ defmodule MvWeb.MemberLive.IndexTest do refute html_false =~ "NoValue" end + @tag :ui test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do conn = conn_with_oidc_user(conn)