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.Fixtures alias Mv.Membership.{CustomField, Member, MemberExport.Build} alias Mv.Constants @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: [], 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: [], 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: [], 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], 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 assert custom_col.label == "Label" # 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], 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: [], 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: [], 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], 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 # Create a user with limited permissions user = Fixtures.password_user_with_role_fixture(%{permission_set_name: "own_data"}) parsed = %{ selected_ids: [m1.id], member_fields: ["first_name"], selectable_member_fields: ["first_name"], computed_fields: [], custom_field_ids: [], query: nil, sort_field: nil, sort_order: nil, show_current_cycle: false } result = Build.build(user, parsed, fn _key -> "Label" end) assert {:error, :forbidden} = result end end end