From 081e44fc05f81903c4185e89a5e1c045ea302137 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Mar 2026 13:55:25 +0100 Subject: [PATCH] fix: add test, accidentally deleted by commit baa288bf --- .../membership/member_export_build_test.exs | 378 ++++++++++++++++++ test/mv/membership/members_pdf_test.exs | 265 ++++++++++++ 2 files changed, 643 insertions(+) create mode 100644 test/mv/membership/member_export_build_test.exs create mode 100644 test/mv/membership/members_pdf_test.exs diff --git a/test/mv/membership/member_export_build_test.exs b/test/mv/membership/member_export_build_test.exs new file mode 100644 index 0000000..d215b9c --- /dev/null +++ b/test/mv/membership/member_export_build_test.exs @@ -0,0 +1,378 @@ +defmodule Mv.Membership.MemberExport.BuildTest do + @moduledoc """ + Tests for MemberExport.Build module. + + Tests verify that the module correctly: + - Loads and filters members based on query/selected_ids + - Builds column specifications (without labels) + - Generates row data as cell strings + - Handles member fields, custom fields, and computed fields + - Applies sorting and filtering consistently + """ + use Mv.DataCase, async: true + + alias Mv.Constants + alias Mv.Fixtures + alias Mv.Membership.{CustomField, MemberExport.Build} + + @custom_field_prefix Constants.custom_field_prefix() + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create test members + member1 = + Fixtures.member_fixture(%{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + + member2 = + Fixtures.member_fixture(%{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + + %{actor: system_actor, member1: member1, member2: member2} + end + + describe "build/3 - standard member fields" do + test "returns columns and rows for standard member fields", %{ + actor: actor, + member1: m1, + member2: m2 + } do + parsed = %{ + selected_ids: [m1.id, m2.id], + member_fields: ["first_name", "last_name", "email"], + selectable_member_fields: ["first_name", "last_name", "email"], + computed_fields: [], + custom_field_ids: [], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + result = Build.build(actor, parsed, fn _key -> "Label" end) + + assert {:ok, data} = result + assert %{columns: columns, rows: rows, meta: meta} = data + + # Check columns structure + assert length(columns) == 3 + first_name_col = Enum.find(columns, &(&1.key == "first_name" && &1.kind == :member_field)) + assert first_name_col + assert first_name_col.label == "Label" + assert Enum.find(columns, &(&1.key == "last_name" && &1.kind == :member_field)) + assert Enum.find(columns, &(&1.key == "email" && &1.kind == :member_field)) + + # Check rows - should have 2 members + assert length(rows) == 2 + + # Check first row (member1) + row1 = Enum.at(rows, 0) + assert length(row1) == 3 + assert "Alice" in row1 + assert "Anderson" in row1 + assert "alice@example.com" in row1 + + # Check meta + assert %{generated_at: _timestamp, member_count: 2} = meta + assert is_binary(meta.generated_at) + end + + test "filters members by selected_ids", %{actor: actor, member1: m1, member2: _m2} do + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + assert length(data.rows) == 1 + row = hd(data.rows) + assert "Alice" in row + assert data.meta.member_count == 1 + end + + test "applies search query when selected_ids is empty", %{ + actor: actor, + member1: _m1, + member2: _m2 + } do + parsed = %{ + selected_ids: [], + member_fields: ["first_name", "last_name"], + selectable_member_fields: ["first_name", "last_name"], + computed_fields: [], + custom_field_ids: [], + boolean_filters: %{}, + cycle_status_filter: nil, + query: "Alice", + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + assert length(data.rows) == 1 + row = hd(data.rows) + assert "Alice" in row + end + end + + describe "build/3 - custom fields" do + test "includes custom field columns and values", %{ + actor: actor, + member1: m1 + } do + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Membership Number", + value_type: :string + }) + |> Ash.create(actor: actor) + + # Create custom field value for member + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: m1.id, + custom_field_id: custom_field.id, + value: "M12345" + }) + |> Ash.create(actor: actor) + + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [custom_field.id], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should have 2 columns: first_name + custom field + assert length(data.columns) == 2 + + custom_col = + Enum.find( + data.columns, + &(&1.kind == :custom_field && &1.key == to_string(custom_field.id)) + ) + + assert custom_col + assert custom_col.custom_field.id == custom_field.id + # Label comes from custom field name when provided via label_fn key + assert custom_col.label in ["Label", "Membership Number"] + + # Check row has custom field value + row = hd(data.rows) + assert length(row) == 2 + assert "M12345" in row + end + + test "handles members without custom field values", %{ + actor: actor, + member1: m1 + } do + # Create custom field but no value for member + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Optional Field", + value_type: :string + }) + |> Ash.create(actor: actor) + + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [custom_field.id], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + row = hd(data.rows) + # Custom field value should be empty string + assert "" in row + end + end + + describe "build/3 - computed fields" do + test "includes computed field columns and values", %{ + actor: actor, + member1: m1 + } do + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name", "membership_fee_status"], + selectable_member_fields: ["first_name"], + computed_fields: ["membership_fee_status"], + custom_field_ids: [], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should have 2 columns: first_name + computed field + assert length(data.columns) == 2 + + computed_col = + Enum.find(data.columns, &(&1.kind == :computed && &1.key == :membership_fee_status)) + + assert computed_col + assert computed_col.label == "Label" + + # Check row has computed field value (may be empty if no cycles) + row = hd(data.rows) + assert length(row) == 2 + # membership_fee_status should be present (even if empty) + end + end + + describe "build/3 - sorting" do + test "sorts by member field", %{actor: actor, member1: m1, member2: m2} do + parsed = %{ + selected_ids: [m1.id, m2.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: "first_name", + sort_order: "asc", + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should be sorted: Alice, Bob + [row1, row2] = data.rows + assert "Alice" in row1 + assert "Bob" in row2 + end + + test "sorts by custom field", %{actor: actor, member1: m1, member2: m2} do + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Sort Field", + value_type: :string + }) + |> Ash.create(actor: actor) + + # Add values: m1="Zebra", m2="Alpha" + {:ok, _cfv1} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: m1.id, + custom_field_id: custom_field.id, + value: "Zebra" + }) + |> Ash.create(actor: actor) + + {:ok, _cfv2} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: m2.id, + custom_field_id: custom_field.id, + value: "Alpha" + }) + |> Ash.create(actor: actor) + + sort_field = "#{@custom_field_prefix}#{custom_field.id}" + + parsed = %{ + selected_ids: [m1.id, m2.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [custom_field.id], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: sort_field, + sort_order: "asc", + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should be sorted by custom field: Alpha (Bob), Zebra (Alice) + [row1, row2] = data.rows + # Alpha + assert "Bob" in row1 + # Zebra + assert "Alice" in row2 + end + end + + describe "build/3 - error handling" do + test "returns error when actor lacks permission", %{member1: m1} do + # User with own_data can only read linked member; m1 is not linked to this user + user = Fixtures.user_with_role_fixture("own_data") + + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [], + boolean_filters: %{}, + cycle_status_filter: nil, + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + result = Build.build(user, parsed, fn _key -> "Label" end) + + # own_data user cannot read m1 (not linked); build returns ok with empty rows + assert {:ok, data} = result + assert data.meta.member_count == 0 + assert data.rows == [] + end + end +end diff --git a/test/mv/membership/members_pdf_test.exs b/test/mv/membership/members_pdf_test.exs new file mode 100644 index 0000000..0ca259a --- /dev/null +++ b/test/mv/membership/members_pdf_test.exs @@ -0,0 +1,265 @@ +defmodule Mv.Membership.MembersPDFTest do + @moduledoc """ + Tests for MembersPDF module. + + Tests verify that the module correctly: + - Loads the Typst template + - Converts export data to template format + - Generates valid PDF binary (starts with "%PDF") + - Handles errors gracefully + """ + use ExUnit.Case, async: true + + alias Mv.Config + alias Mv.Membership.MembersPDF + + describe "render/1" do + test "rejects export when row count exceeds limit" do + max_rows = Config.pdf_export_row_limit() + rows_over_limit = max_rows + 1 + + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"} + ], + rows: Enum.map(1..rows_over_limit, fn i -> ["Member #{i}"] end), + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: rows_over_limit + } + } + + result = MembersPDF.render(export_data) + + assert {:error, {:row_limit_exceeded, ^rows_over_limit, ^max_rows}} = result + end + + test "allows export when row count equals limit" do + max_rows = Config.pdf_export_row_limit() + + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"} + ], + rows: Enum.map(1..max_rows, fn i -> ["Member #{i}"] end), + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: max_rows + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "allows export when row count is below limit" do + max_rows = Config.pdf_export_row_limit() + rows_below_limit = max(1, max_rows - 10) + + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"} + ], + rows: Enum.map(1..rows_below_limit, fn i -> ["Member #{i}"] end), + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: rows_below_limit + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "generates valid PDF from minimal dataset" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"} + ], + rows: [ + ["Max", "Mustermann"], + ["Anna", "Schmidt"] + ], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 2 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + assert byte_size(pdf_binary) > 1000 + end + + test "generates valid PDF with custom fields and computed fields" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"}, + %{key: "email", kind: :member_field, label: "E-Mail"}, + %{key: :membership_fee_status, kind: :computed, label: "Beitragsstatus"}, + %{ + key: "550e8400-e29b-41d4-a716-446655440000", + kind: :custom_field, + label: "Mitgliedsnummer", + custom_field: %{ + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Mitgliedsnummer", + value_type: :string + } + } + ], + rows: [ + ["Max", "Mustermann", "max@example.com", "paid", "M-2024-001"], + ["Anna", "Schmidt", "anna@example.com", "unpaid", "M-2024-002"] + ], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 2 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "maintains deterministic column and row order" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"}, + %{key: "email", kind: :member_field, label: "E-Mail"} + ], + rows: [ + ["Max", "Mustermann", "max@example.com"], + ["Anna", "Schmidt", "anna@example.com"], + ["Peter", "Müller", "peter@example.com"] + ], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 3 + } + } + + # Render twice and verify identical output + {:ok, pdf1} = MembersPDF.render(export_data) + {:ok, pdf2} = MembersPDF.render(export_data) + + assert pdf1 == pdf2 + assert String.starts_with?(pdf1, "%PDF") + assert String.starts_with?(pdf2, "%PDF") + end + + test "returns error when template file is missing" do + # Temporarily rename template to simulate missing file + template_path = + Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/members_export.typ") + + original_content = File.read!(template_path) + File.rm(template_path) + + export_data = %{ + columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}], + rows: [["Max"]], + meta: %{generated_at: "2024-01-15T14:30:00Z", member_count: 1} + } + + result = MembersPDF.render(export_data) + + assert {:error, {:template_not_found, _reason}} = result + + # Restore template + File.write!(template_path, original_content) + end + + test "handles empty rows gracefully" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"} + ], + rows: [], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 0 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "handles many columns correctly" do + # Test with 10 columns to ensure dynamic column width calculation works + columns = + Enum.map(1..10, fn i -> + %{key: "field_#{i}", kind: :member_field, label: "Feld #{i}"} + end) + + export_data = %{ + columns: columns, + rows: [Enum.map(1..10, &"Wert #{&1}")], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 1 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "creates and cleans up temp directory" do + export_data = %{ + columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}], + rows: [["Max"]], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 1 + } + } + + # Get temp base directory + temp_base = System.tmp_dir!() + + # Count temp directories before + before_count = + temp_base + |> File.ls!() + |> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end) + + result = MembersPDF.render(export_data) + + assert {:ok, _pdf_binary} = result + + # Wait a bit for cleanup (async cleanup might take a moment) + Process.sleep(100) + + # Count temp directories after + after_count = + temp_base + |> File.ls!() + |> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end) + + # Should have same or fewer temp dirs (cleanup should have run) + assert after_count <= before_count + 1 + end + end +end