This commit is contained in:
carla 2026-02-13 17:21:14 +01:00
parent 4fb5b12ea7
commit baa288bff3
11 changed files with 401 additions and 780 deletions

View file

@ -1,356 +1 @@
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

View file

@ -1,265 +1 @@
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