Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
Simon 2026-02-12 15:16:35 +01:00
commit 2f8a6a2768
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
136 changed files with 9999 additions and 3601 deletions

View file

@ -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)

View file

@ -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/<label[^>]*for=["']csv_file["']/i or
html =~ ~r/<label[^>]*>.*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

View file

@ -0,0 +1,282 @@
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
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/export 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-export")
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-export")
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-export")
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-export")
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-export")
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-export")
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-export")
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-export")
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

View file

@ -0,0 +1,102 @@
defmodule MvWeb.MemberLiveAuthorizationTest do
@moduledoc """
Tests for UI authorization on Member LiveViews (Index and Show).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Fixtures
describe "Member Index - Vorstand (read_only)" do
@tag role: :read_only
test "sees member list but not New Member button", %{conn: conn} do
_member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "[data-testid=member-new]")
end
@tag role: :read_only
test "does not see Edit or Delete buttons in table", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
end
describe "Member Index - Kassenwart (normal_user)" do
@tag role: :normal_user
test "sees New Member and Edit buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
end
@tag role: :normal_user
test "does not see Delete button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
end
describe "Member Index - Admin" do
@tag role: :admin
test "sees New Member, Edit and Delete buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
end
describe "Member Index - Mitglied (own_data)" do
@tag role: :member
test "is redirected when accessing /members", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
assert to == "/users/#{user.id}"
end
end
describe "Member Show - Edit button visibility" do
@tag role: :admin
test "admin sees Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
assert has_element?(view, "[data-testid=member-edit]")
end
@tag role: :read_only
test "read_only does not see Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
refute has_element?(view, "[data-testid=member-edit]")
end
@tag role: :normal_user
test "normal_user sees Edit button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
assert has_element?(view, "[data-testid=member-edit]")
end
end
end

View file

@ -50,7 +50,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
end
describe "create form" do
test "creates new membership fee type", %{conn: conn} do
test "creates new membership fee type", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
form_data = %{
@ -67,12 +67,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
assert to == "/membership_fee_types"
# Verify type was created
# Verify type was created (use actor so read is authorized)
type =
MembershipFeeType
|> Ash.Query.filter(name == "New Type")
|> Ash.read_one!()
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
assert type != nil, "Expected membership fee type to be created"
assert type.amount == Decimal.new("75.00")
assert type.interval == :yearly
end
@ -140,7 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
end
test "amount change can be confirmed", %{conn: conn} do
test "amount change can be confirmed", %{conn: conn, user: user} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
@ -159,12 +160,17 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|> render_submit()
# Amount should be updated
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
# Amount should be updated (use actor so read is authorized)
updated_type =
MembershipFeeType
|> Ash.Query.filter(id == ^fee_type.id)
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
assert updated_type != nil
assert updated_type.amount == Decimal.new("75.00")
end
test "amount change can be cancelled", %{conn: conn} do
test "amount change can be cancelled", %{conn: conn, user: user} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
@ -178,8 +184,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|> element("button[phx-click='cancel_amount_change']")
|> render_click()
# Amount should remain unchanged
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
# Amount should remain unchanged (use actor so read is authorized)
updated_type =
MembershipFeeType
|> Ash.Query.filter(id == ^fee_type.id)
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
assert updated_type != nil
assert updated_type.amount == Decimal.new("50.00")
end

View file

@ -61,6 +61,7 @@ defmodule MvWeb.ProfileNavigationTest do
end
@tag :skip
# credo:disable-for-next-line Credo.Check.Design.TagTODO
# TODO: Implement user initials in navbar avatar - see issue #170
test "shows user initials in avatar", %{conn: conn} do
# Setup: Create and login a user

View file

@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.ShowTest do
alias Mv.Authorization
alias Mv.Authorization.Role
# Helper to create a role
# Helper to create a role (authorize?: false for test data setup)
defp create_role(attrs \\ %{}) do
default_attrs = %{
name: "Test Role #{System.unique_integer([:positive])}",
@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.ShowTest do
attrs = Map.merge(default_attrs, attrs)
case Authorization.create_role(attrs) do
case Authorization.create_role(attrs, authorize?: false) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
@ -38,7 +38,7 @@ defmodule MvWeb.RoleLive.ShowTest do
defp create_admin_user(conn, actor) do
# Create admin role
admin_role =
case Authorization.list_roles() do
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
case Enum.find(roles, &(&1.name == "Admin")) do
nil ->

View file

@ -9,7 +9,7 @@ defmodule MvWeb.RoleLiveTest do
alias Mv.Authorization
alias Mv.Authorization.Role
# Helper to create a role
# Helper to create a role (authorize?: false for test data setup)
defp create_role(attrs \\ %{}) do
default_attrs = %{
name: "Test Role #{System.unique_integer([:positive])}",
@ -19,7 +19,7 @@ defmodule MvWeb.RoleLiveTest do
attrs = Map.merge(default_attrs, attrs)
case Authorization.create_role(attrs) do
case Authorization.create_role(attrs, authorize?: false) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
@ -29,7 +29,7 @@ defmodule MvWeb.RoleLiveTest do
defp create_admin_user(conn, actor) do
# Create admin role
admin_role =
case Authorization.list_roles() do
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
case Enum.find(roles, &(&1.name == "Admin")) do
nil ->
@ -332,7 +332,7 @@ defmodule MvWeb.RoleLiveTest do
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
end
test "updates role name", %{conn: conn, role: role} do
test "updates role name", %{conn: conn, role: role, actor: actor} do
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show")
attrs = %{
@ -348,7 +348,7 @@ defmodule MvWeb.RoleLiveTest do
assert_redirect(view, "/admin/roles/#{role.id}")
# Verify update
{:ok, updated_role} = Authorization.get_role(role.id)
{:ok, updated_role} = Authorization.get_role(role.id, actor: actor)
assert updated_role.name == "Updated Role Name"
end
@ -377,7 +377,7 @@ defmodule MvWeb.RoleLiveTest do
assert_redirect(view, "/admin/roles/#{system_role.id}")
# Verify update
{:ok, updated_role} = Authorization.get_role(system_role.id)
{:ok, updated_role} = Authorization.get_role(system_role.id, actor: actor)
assert updated_role.permission_set_name == "read_only"
end
end
@ -390,7 +390,7 @@ defmodule MvWeb.RoleLiveTest do
end
@tag :slow
test "deletes non-system role", %{conn: conn} do
test "deletes non-system role", %{conn: conn, actor: actor} do
role = create_role()
{:ok, view, html} = live(conn, "/admin/roles")
@ -404,7 +404,7 @@ defmodule MvWeb.RoleLiveTest do
# Verify deletion by checking database
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
Authorization.get_role(role.id)
Authorization.get_role(role.id, actor: actor)
end
test "fails to delete system role with error message", %{conn: conn, actor: actor} do
@ -430,7 +430,7 @@ defmodule MvWeb.RoleLiveTest do
assert render(view) =~ "System roles cannot be deleted"
# Role should still exist
{:ok, _role} = Authorization.get_role(system_role.id)
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
end
end

View file

@ -0,0 +1,81 @@
defmodule MvWeb.UserLiveAuthorizationTest do
@moduledoc """
Tests for UI authorization on User LiveViews (Index and Show).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Fixtures
describe "User Index - Admin" do
@tag role: :admin
test "sees New User, Edit and Delete buttons", %{conn: conn} do
user = Fixtures.user_with_role_fixture("admin")
{:ok, view, _html} = live(conn, "/users")
assert has_element?(view, "[data-testid=user-new]")
assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
end
end
describe "User Index - Non-Admin is redirected" do
@tag role: :read_only
test "read_only is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
@tag role: :member
test "member is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
@tag role: :normal_user
test "normal_user is redirected when accessing /users", %{conn: conn, current_user: user} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
assert to == "/users/#{user.id}"
end
end
describe "User Show - own profile" do
@tag role: :member
test "member sees Edit button on own profile", %{conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/users/#{user.id}")
assert has_element?(view, "[data-testid=user-edit]")
end
@tag role: :read_only
test "read_only sees Edit button on own profile", %{conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/users/#{user.id}")
assert has_element?(view, "[data-testid=user-edit]")
end
@tag role: :admin
test "admin sees Edit button on user show", %{conn: conn} do
user = Fixtures.user_with_role_fixture("read_only")
{:ok, view, _html} = live(conn, "/users/#{user.id}")
assert has_element?(view, "[data-testid=user-edit]")
end
end
describe "User Show - other user (non-admin redirected)" do
@tag role: :member
test "member is redirected when accessing other user's profile", %{
conn: conn,
current_user: current_user
} do
other_user = Fixtures.user_with_role_fixture("admin")
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users/#{other_user.id}")
assert to == "/users/#{current_user.id}"
end
end
end