483 lines
16 KiB
Elixir
483 lines
16 KiB
Elixir
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 confirm_import(view),
|
|
do: view |> element("[data-testid='confirm-import-button']") |> render_click()
|
|
|
|
# Full flow: upload, enter preview (start), then confirm to begin processing.
|
|
defp run_full_import(view, csv_content, filename \\ "test_import.csv") do
|
|
upload_csv_file(view, csv_content, filename)
|
|
submit_import(view)
|
|
confirm_import(view)
|
|
end
|
|
|
|
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")
|
|
run_full_import(view, csv_content)
|
|
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")
|
|
run_full_import(view, csv_content, "invalid_import.csv")
|
|
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;country;city;street;postal_code\n"
|
|
|
|
invalid_rows =
|
|
for i <- 1..100, do: "Row#{i};Last#{i};;Country#{i};City#{i};Street#{i};12345\n"
|
|
|
|
run_full_import(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
|
|
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;country;city;street;postal_code\n"
|
|
|
|
rows =
|
|
for i <- 1..1001 do
|
|
"Row#{i};Last#{i};email#{i}@example.com;Country#{i};City#{i};Street#{i};12345\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!()
|
|
|
|
run_full_import(view, csv_content, "bom_import.csv")
|
|
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!()
|
|
|
|
run_full_import(view, csv_content, "empty_lines.csv")
|
|
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")
|
|
run_full_import(view, csv_content, "unknown_custom.csv")
|
|
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='import-page']")
|
|
assert has_element?(view, "[data-testid='csv-upload-form']")
|
|
assert has_element?(view, "[data-testid='start-import-button']")
|
|
end
|
|
|
|
test "template links point to the dynamic import template routes", %{conn: conn} do
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
assert has_element?(view, "a[href='/admin/import/template/en']")
|
|
assert has_element?(view, "a[href='/admin/import/template/de']")
|
|
refute has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
|
end
|
|
|
|
test "file input is present", %{conn: conn} do
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
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 "custom fields notice lists accepted groups and fee-type column names", %{conn: conn} do
|
|
{:ok, _view, html} = live(conn, ~p"/admin/import")
|
|
|
|
# Groups column variants (both EN and DE)
|
|
assert html =~ "Groups"
|
|
assert html =~ "Gruppen"
|
|
# Fee type column variants (both EN and DE)
|
|
assert html =~ "Beitragsart"
|
|
assert html =~ "Fee Type"
|
|
assert html =~ "fee type"
|
|
# Fee status is always ignored (named explicitly)
|
|
assert html =~ "Bezahlstatus"
|
|
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")
|
|
run_full_import(view, csv_content)
|
|
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
|
|
|
|
describe "preview state machine" 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!()
|
|
|
|
{:ok, conn: conn, valid_csv: valid_csv}
|
|
end
|
|
|
|
test "start_import transitions to preview without processing", %{
|
|
conn: conn,
|
|
valid_csv: csv_content
|
|
} do
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv_content)
|
|
submit_import(view)
|
|
|
|
# Preview is shown; no results panel yet because nothing was processed.
|
|
assert has_element?(view, "[data-testid='import-preview']")
|
|
refute has_element?(view, "[data-testid='import-results-panel']")
|
|
|
|
# No member was created during preview (read-only step).
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
{:ok, members} = Membership.list_members(actor: system_actor)
|
|
|
|
refute Enum.any?(
|
|
members,
|
|
&(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"])
|
|
)
|
|
end
|
|
|
|
test "confirm_import starts processing and creates members", %{
|
|
conn: conn,
|
|
valid_csv: csv_content
|
|
} do
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
run_full_import(view, csv_content)
|
|
wait_for_import_completion()
|
|
|
|
assert has_element?(view, "[data-testid='import-results-panel']")
|
|
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
{:ok, members} = Membership.list_members(actor: system_actor)
|
|
|
|
imported =
|
|
Enum.filter(
|
|
members,
|
|
&(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"])
|
|
)
|
|
|
|
assert length(imported) == 2
|
|
end
|
|
|
|
test "cancel_import returns to idle and hides the preview", %{
|
|
conn: conn,
|
|
valid_csv: csv_content
|
|
} do
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv_content)
|
|
submit_import(view)
|
|
assert has_element?(view, "[data-testid='import-preview']")
|
|
|
|
view |> element("[data-testid='cancel-import-button']") |> render_click()
|
|
|
|
refute has_element?(view, "[data-testid='import-preview']")
|
|
refute has_element?(view, "[data-testid='import-results-panel']")
|
|
end
|
|
end
|
|
|
|
describe "preview contents" 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()
|
|
|
|
{:ok, conn: conn}
|
|
end
|
|
|
|
test "shows the column mapping table with roles for each column", %{conn: conn} do
|
|
csv = "email;Gruppen;Beitragsart;Bezahlstatus;UnknownCol\na@e.com;Chor;Premium;paid;x"
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv)
|
|
submit_import(view)
|
|
|
|
assert has_element?(view, "[data-testid='preview-mapping-table']")
|
|
html = render(view)
|
|
|
|
assert html =~ "email"
|
|
assert html =~ "Gruppen"
|
|
assert html =~ "Beitragsart"
|
|
assert html =~ "Bezahlstatus"
|
|
assert html =~ "UnknownCol"
|
|
end
|
|
|
|
test "lists every CSV column exactly once in the mapping table", %{conn: conn} do
|
|
headers = ["email", "Gruppen", "Beitragsart", "Bezahlstatus", "UnknownCol"]
|
|
csv = Enum.join(headers, ";") <> "\na@e.com;Chor;Premium;paid;x"
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv)
|
|
submit_import(view)
|
|
|
|
# Count the data rows via their stable testid so the assertion is independent
|
|
# of how Phoenix renders class attributes or tr tags (§1.15).
|
|
html = render(view)
|
|
|
|
row_count =
|
|
html |> String.split(~s(data-testid="preview-column-row")) |> length() |> Kernel.-(1)
|
|
|
|
assert row_count == length(headers)
|
|
end
|
|
|
|
test "shows up to 3 sample data rows", %{conn: conn} do
|
|
csv = "email\nr1@e.com\nr2@e.com\nr3@e.com\nr4@e.com"
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv)
|
|
submit_import(view)
|
|
|
|
html = render(view)
|
|
assert html =~ "r1@e.com"
|
|
assert html =~ "r2@e.com"
|
|
assert html =~ "r3@e.com"
|
|
refute html =~ "r4@e.com"
|
|
end
|
|
|
|
test "shows an auto-create notice for unknown group names", %{conn: conn} do
|
|
csv = "email;Gruppen\na@e.com;Ganz Neue Gruppe"
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv)
|
|
submit_import(view)
|
|
|
|
assert has_element?(view, "[data-testid='preview-groups-notice']")
|
|
assert render(view) =~ "Ganz Neue Gruppe"
|
|
end
|
|
|
|
test "shows a warning and link for unknown fee-type names", %{conn: conn} do
|
|
csv = "email;Beitragsart\na@e.com;Phantom Tarif"
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv)
|
|
submit_import(view)
|
|
|
|
assert has_element?(view, "[data-testid='preview-fee-type-warning']")
|
|
html = render(view)
|
|
assert html =~ "Phantom Tarif"
|
|
assert html =~ "/membership_fee_settings"
|
|
end
|
|
|
|
test "shows an info notice when fee-type cells are empty", %{conn: conn} do
|
|
csv = "email;Beitragsart\na@e.com;\nb@e.com;"
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv)
|
|
submit_import(view)
|
|
|
|
assert has_element?(view, "[data-testid='preview-fee-type-info']")
|
|
end
|
|
|
|
test "shows a warning for unknown custom-field columns", %{conn: conn} do
|
|
csv = "email;TotallyUnknown\na@e.com;value"
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
|
upload_csv_file(view, csv)
|
|
submit_import(view)
|
|
|
|
assert has_element?(view, "[data-testid='preview-unknown-warning']")
|
|
assert render(view) =~ "TotallyUnknown"
|
|
end
|
|
end
|
|
end
|