Merge remote-tracking branch 'origin/main' into sidebar
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-06 10:52:24 +01:00
commit ff625c91c5
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
113 changed files with 19602 additions and 2699 deletions

View file

@ -69,7 +69,7 @@ defmodule Mv.Membership.FuzzySearchTest do
ids = Enum.map(result, & &1.id)
assert thomas.id in ids
refute jane.id in ids
assert length(ids) >= 1
assert not Enum.empty?(ids)
end
test "empty query returns all members" do

View file

@ -0,0 +1,360 @@
defmodule Mv.Membership.MemberCycleCalculationsTest do
@moduledoc """
Tests for Member cycle status calculations.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "current_cycle_status" do
test "returns status of current cycle for member with active cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle that is active today (2024-01-01 to 2024-12-31)
# Assuming today is in 2024
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :paid
end
test "returns nil for member without current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle in the past (not current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :paid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns status of current cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle that is active today (current month)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :unpaid
end
end
describe "last_cycle_status" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create cycles: 2022 (completed), 2023 (completed), 2024 (current)
today = Date.utc_today()
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid
})
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :last_cycle_status)
# Should return status of 2023 (last completed)
assert member.last_cycle_status == :unpaid
end
test "returns nil for member without completed cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Only create current cycle (not completed yet)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns status of last completed cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: last month (completed), current month (not completed)
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
member = Ash.load!(member, :last_cycle_status)
# Should return status of last month (last completed)
assert member.last_cycle_status == :paid
end
end
describe "overdue_count" do
test "counts only unpaid cycles that have ended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles:
# 2022: unpaid, ended (overdue)
# 2023: paid, ended (not overdue)
# 2024: unpaid, current (not overdue)
# 2025: unpaid, future (not overdue)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
# Future cycle (if we're not at the end of the year)
next_year = today.year + 1
if today.month < 12 or today.day < 31 do
next_year_start = Date.new!(next_year, 1, 1)
create_cycle(member, fee_type, %{
cycle_start: next_year_start,
status: :unpaid
})
end
member = Ash.load!(member, :overdue_count)
# Should only count 2022 (unpaid and ended)
assert member.overdue_count == 1
end
test "returns 0 when no overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create only paid cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "returns 0 for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "counts overdue cycles for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: two months ago (unpaid, ended), last month (paid, ended), current month (unpaid, not ended)
two_months_ago_start =
Date.add(today, -65) |> CalendarCycles.calculate_cycle_start(:monthly)
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: two_months_ago_start,
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
member = Ash.load!(member, :overdue_count)
# Should only count two_months_ago (unpaid and ended)
assert member.overdue_count == 1
end
test "counts multiple overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create multiple unpaid, ended cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2021-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 3
end
end
describe "calculations with multiple cycles" do
test "all calculations work correctly with multiple cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: 2022 (unpaid, ended), 2023 (paid, ended), 2024 (unpaid, current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
member =
Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count])
assert member.current_cycle_status == :unpaid
assert member.last_cycle_status == :paid
assert member.overdue_count == 1
end
end
end

View file

@ -0,0 +1,635 @@
defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
@moduledoc """
Tests for required custom fields validation.
Tests cover:
- Member creation without required custom field error
- Member creation with empty required custom field (nil/empty string) error
- Member creation with valid required custom field success
- Member update: removing a required custom field value error
- Boolean required custom field: false is valid, nil is invalid
"""
use Mv.DataCase, async: true
alias Mv.Membership
setup do
# Create required custom fields for different types
{:ok, required_string_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_string",
value_type: :string,
required: true
})
|> Ash.create()
{:ok, required_integer_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_integer",
value_type: :integer,
required: true
})
|> Ash.create()
{:ok, required_boolean_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_boolean",
value_type: :boolean,
required: true
})
|> Ash.create()
{:ok, required_date_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_date",
value_type: :date,
required: true
})
|> Ash.create()
{:ok, required_email_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_email",
value_type: :email,
required: true
})
|> Ash.create()
{:ok, optional_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "optional_string",
value_type: :string,
required: false
})
|> Ash.create()
%{
required_string_field: required_string_field,
required_integer_field: required_integer_field,
required_boolean_field: required_boolean_field,
required_date_field: required_date_field,
required_email_field: required_email_field,
optional_field: optional_field
}
end
# Helper function to create all required custom fields with valid default values
defp all_required_custom_fields_with_defaults(%{
required_string_field: string_field,
required_integer_field: integer_field,
required_boolean_field: boolean_field,
required_date_field: date_field,
required_email_field: email_field
}) do
[
%{
"custom_field_id" => string_field.id,
"value" => %{"_union_type" => "string", "_union_value" => "default"}
},
%{
"custom_field_id" => integer_field.id,
"value" => %{"_union_type" => "integer", "_union_value" => 0}
},
%{
"custom_field_id" => boolean_field.id,
"value" => %{"_union_type" => "boolean", "_union_value" => false}
},
%{
"custom_field_id" => date_field.id,
"value" => %{"_union_type" => "date", "_union_value" => ~D[2020-01-01]}
},
%{
"custom_field_id" => email_field.id,
"value" => %{"_union_type" => "email", "_union_value" => "test@example.com"}
}
]
end
describe "create_member with required custom fields" do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
test "fails when required custom field is missing", %{required_string_field: field} do
attrs = Map.put(@valid_attrs, :custom_field_values, [])
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has nil value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has empty string value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has whitespace-only value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => " "}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required string custom field has valid value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values, then update the string field
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test value"}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required integer custom field has nil value",
%{
required_integer_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required integer custom field has empty string value",
%{
required_integer_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required integer custom field has zero value",
%{
required_integer_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when required integer custom field has positive value",
%{
required_integer_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required boolean custom field has nil value",
%{
required_boolean_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required boolean custom field has false value",
%{
required_boolean_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when required boolean custom field has true value",
%{
required_boolean_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required date custom field has nil value",
%{
required_date_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "date", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required date custom field has empty string value",
%{
required_date_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "date", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required date custom field has valid date value",
%{
required_date_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required email custom field has nil value",
%{
required_email_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "email", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required email custom field has empty string value",
%{
required_email_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "email", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required email custom field has valid email value",
%{
required_email_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when multiple required custom fields are provided",
%{
required_string_field: string_field,
required_integer_field: integer_field,
required_boolean_field: boolean_field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
cond do
cfv["custom_field_id"] == string_field.id ->
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
cfv["custom_field_id"] == integer_field.id ->
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
cfv["custom_field_id"] == boolean_field.id ->
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
true ->
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when one of multiple required custom fields is missing",
%{
required_string_field: string_field,
required_integer_field: integer_field
} = context do
# Provide only string field, missing integer, boolean, and date
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.filter(fn cfv ->
cfv["custom_field_id"] == string_field.id
end)
|> Enum.map(fn cfv ->
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ integer_field.name
end
test "succeeds when optional custom field is missing", %{} = context do
# Provide all required fields, but no optional field
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when optional custom field has nil value",
%{optional_field: field} = context do
# Provide all required fields plus optional field with nil
custom_field_values =
all_required_custom_fields_with_defaults(context) ++
[
%{
"custom_field_id" => field.id,
"value" => %{"_union_type" => "string", "_union_value" => nil}
}
]
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
end
describe "update_member with required custom fields" do
test "fails when removing a required custom field value",
%{
required_string_field: field
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
# Try to update without the required custom field
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_member(member, %{custom_field_values: []})
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when setting required custom field value to empty",
%{
required_string_field: field
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
# Try to update with empty value for the string field
updated_custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
else
cfv
end
end)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_member(member, %{
custom_field_values: updated_custom_field_values
})
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when updating required custom field to valid value",
%{
required_string_field: field
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
# Load existing custom field values to get their IDs
{:ok, member_with_cfvs} = Ash.load(member, :custom_field_values)
# Update with new valid value for the string field, using existing IDs
updated_custom_field_values =
member_with_cfvs.custom_field_values
|> Enum.map(fn cfv ->
if cfv.custom_field_id == field.id do
%{
"id" => cfv.id,
"custom_field_id" => cfv.custom_field_id,
"value" => %{"_union_type" => "string", "_union_value" => "new value"}
}
else
# Keep other fields as they are
value_type = Atom.to_string(cfv.value.type)
actual_value = cfv.value.value
%{
"id" => cfv.id,
"custom_field_id" => cfv.custom_field_id,
"value" => %{"_union_type" => value_type, "_union_value" => actual_value}
}
end
end)
assert {:ok, _updated_member} =
Membership.update_member(member, %{
custom_field_values: updated_custom_field_values
})
end
end
# Helper function for error evaluation
defp error_message(errors, field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|> Enum.map_join(" ", &Map.get(&1, :message, ""))
end
end

View file

@ -0,0 +1,702 @@
defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
@moduledoc """
Tests for full-text search including custom_field_values.
Tests verify that custom field values are included in the search_vector
and can be found through the fuzzy_search functionality.
"""
use Mv.DataCase, async: false
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
{:ok, member3} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Charlie",
last_name: "Clark",
email: "charlie@example.com"
})
|> Ash.create()
# Create custom fields for different types
{:ok, string_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string
})
|> Ash.create()
{:ok, integer_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "member_id_number",
value_type: :integer
})
|> Ash.create()
{:ok, email_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "secondary_email",
value_type: :email
})
|> Ash.create()
{:ok, date_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "birthday",
value_type: :date
})
|> Ash.create()
{:ok, boolean_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter",
value_type: :boolean
})
|> Ash.create()
%{
member1: member1,
member2: member2,
member3: member3,
string_field: string_field,
integer_field: integer_field,
email_field: email_field,
date_field: date_field,
boolean_field: boolean_field
}
end
describe "search with custom field values" do
test "finds member by string custom field value", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"}
})
|> Ash.create()
# Force search_vector update by reloading member
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "MEMBER12345"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by integer custom field value", %{
member1: member1,
integer_field: integer_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 42_424}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "42424"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by email custom field value", %{
member1: member1,
email_field: email_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for partial custom field value (should work via FTS or custom field filter)
results =
Member
|> Member.fuzzy_search(%{query: "alice.secondary"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
# Search for full email address (should work via custom field filter LIKE)
results_full =
Member
|> Member.fuzzy_search(%{query: "alice.secondary@example.com"})
|> Ash.read!()
assert length(results_full) == 1
assert List.first(results_full).id == member1.id
# Search for domain part (should work via FTS or custom field filter)
# Note: May return multiple results if other members have same domain
results_domain =
Member
|> Member.fuzzy_search(%{query: "example.com"})
|> Ash.read!()
# Verify that member1 is in the results (may have other members too)
ids = Enum.map(results_domain, & &1.id)
assert member1.id in ids
end
test "finds member by date custom field value", %{
member1: member1,
date_field: date_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: date_field.id,
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value (date is stored as text in search_vector)
results =
Member
|> Member.fuzzy_search(%{query: "1990-05-15"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by boolean custom field value", %{
member1: member1,
boolean_field: boolean_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value (boolean is stored as "true" or "false" text)
results =
Member
|> Member.fuzzy_search(%{query: "true"})
|> Ash.read!()
# Note: "true" might match other things, so we check that member1 is in results
assert Enum.any?(results, fn m -> m.id == member1.id end)
end
test "custom field value update triggers search_vector update", %{
member1: member1,
string_field: string_field
} do
# Create initial custom field value
{:ok, cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Update custom field value
{:ok, _updated_cfv} =
cfv
|> Ash.Changeset.for_update(:update, %{
value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"}
})
|> Ash.update()
# Search for the new value
results =
Member
|> Member.fuzzy_search(%{query: "NEWVALUE123"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
# Old value should not be found
old_results =
Member
|> Member.fuzzy_search(%{query: "OLDVALUE"})
|> Ash.read!()
refute Enum.any?(old_results, fn m -> m.id == member1.id end)
end
test "custom field value delete triggers search_vector update", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Verify it's searchable
results =
Member
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
# Delete custom field value
assert :ok = Ash.destroy(cfv)
# Value should no longer be found
deleted_results =
Member
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|> Ash.read!()
refute Enum.any?(deleted_results, fn m -> m.id == member1.id end)
end
test "custom field value create triggers search_vector update", %{
member1: member1,
string_field: string_field
} do
# Create custom field value (trigger should update search_vector automatically)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "AUTOUPDATE"}
})
|> Ash.create()
# Search should find it immediately (trigger should have updated search_vector)
results =
Member
|> Member.fuzzy_search(%{query: "AUTOUPDATE"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "member update includes custom field values in search_vector", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"}
})
|> Ash.create()
# Update member (should trigger search_vector update including custom fields)
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|> Ash.update()
# Search should find the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "MEMBERUPDATE"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "multiple custom field values are all searchable", %{
member1: member1,
string_field: string_field,
integer_field: integer_field,
email_field: email_field
} do
# Create multiple custom field values
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MULTI1"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 99_999}
})
|> Ash.create()
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "multi@test.com"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# All values should be searchable
results1 =
Member
|> Member.fuzzy_search(%{query: "MULTI1"})
|> Ash.read!()
assert Enum.any?(results1, fn m -> m.id == member1.id end)
results2 =
Member
|> Member.fuzzy_search(%{query: "99999"})
|> Ash.read!()
assert Enum.any?(results2, fn m -> m.id == member1.id end)
results3 =
Member
|> Member.fuzzy_search(%{query: "multi@test.com"})
|> Ash.read!()
assert Enum.any?(results3, fn m -> m.id == member1.id end)
end
test "finds member by custom field value with numbers in text field (e.g. phone number)", %{
member1: member1,
string_field: string_field
} do
# Create custom field value with numbers and text (like phone number or ID)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "M-123-456"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for full value (should work via search_vector)
results_full =
Member
|> Member.fuzzy_search(%{query: "M-123-456"})
|> Ash.read!()
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
"Full value search should find member via search_vector"
# Note: Partial substring search may require additional implementation
# For now, we test that the full value is searchable, which is the primary use case
# Substring matching for custom fields may need to be implemented separately
end
test "finds member by phone number in Emergency Contact custom field", %{
member1: member1
} do
# Create Emergency Contact custom field
{:ok, emergency_contact_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Emergency Contact",
value_type: :string
})
|> Ash.create()
# Create custom field value with phone number
phone_number = "+49 123 456789"
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: emergency_contact_field.id,
value: %{"_union_type" => "string", "_union_value" => phone_number}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for full phone number (should work via search_vector)
results_full =
Member
|> Member.fuzzy_search(%{query: phone_number})
|> Ash.read!()
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
"Full phone number search should find member via search_vector"
# Note: Partial substring search may require additional implementation
# For now, we test that the full phone number is searchable, which is the primary use case
# Substring matching for custom fields may need to be implemented separately
end
end
describe "custom field substring search (ILIKE)" do
test "finds member by prefix of custom field value", %{
member1: member1,
string_field: string_field
} do
# Create custom field value with a distinct word
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Premium"}
})
|> Ash.create()
# Test prefix searches - should all find the member
for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do
results =
Member
|> Member.fuzzy_search(%{query: prefix})
|> Ash.read!()
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Prefix '#{prefix}' should find member with custom field 'Premium'"
end
end
test "custom field search is case-insensitive", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "GoldMember"}
})
|> Ash.create()
# Test case variations - should all find the member
for variant <- [
"GoldMember",
"goldmember",
"GOLDMEMBER",
"GoLdMeMbEr",
"gold",
"GOLD",
"Gold"
] do
results =
Member
|> Member.fuzzy_search(%{query: variant})
|> Ash.read!()
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Case variant '#{variant}' should find member with custom field 'GoldMember'"
end
end
test "finds member by suffix/middle of custom field value", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "ActiveMember"}
})
|> Ash.create()
# Test suffix and middle substring searches
for substring <- ["Member", "ember", "tiveMem", "ctive"] do
results =
Member
|> Member.fuzzy_search(%{query: substring})
|> Ash.read!()
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Substring '#{substring}' should find member with custom field 'ActiveMember'"
end
end
test "finds correct member among multiple with different custom field values", %{
member1: member1,
member2: member2,
member3: member3,
string_field: string_field
} do
# Create different custom field values for each member
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Beginner"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member2.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Advanced"}
})
|> Ash.create()
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member3.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Expert"}
})
|> Ash.create()
# Search for "Begin" - should only find member1
results_begin =
Member
|> Member.fuzzy_search(%{query: "Begin"})
|> Ash.read!()
assert length(results_begin) == 1
assert List.first(results_begin).id == member1.id
# Search for "Advan" - should only find member2
results_advan =
Member
|> Member.fuzzy_search(%{query: "Advan"})
|> Ash.read!()
assert length(results_advan) == 1
assert List.first(results_advan).id == member2.id
# Search for "Exper" - should only find member3
results_exper =
Member
|> Member.fuzzy_search(%{query: "Exper"})
|> Ash.read!()
assert length(results_exper) == 1
assert List.first(results_exper).id == member3.id
end
# Note: Legacy format (type/value) is supported via the SQL ILIKE query on value->>'value'
# This is tested implicitly by the migration trigger which handles both formats.
# The Ash union type only accepts the new format (_union_type/_union_value) for creation,
# but the search works on existing legacy data.
end
end

View file

@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
paid: true,
email: "john@example.com",
phone_number: "+49123456789",
join_date: ~D[2020-01-01],
@ -42,14 +41,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email"
end
test "Paid is optional but must be boolean if specified" do
attrs = Map.put(@valid_attrs, :paid, nil)
attrs2 = Map.put(@valid_attrs, :paid, "yes")
assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2)
assert error_message(errors, :paid) =~ "is invalid"
end
test "Phone number is optional but must have a valid format if specified" do
attrs = Map.put(@valid_attrs, :phone_number, "abc")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
@ -58,12 +49,12 @@ defmodule Mv.Membership.MemberTest do
assert {:ok, _member} = Membership.create_member(attrs2)
end
test "Join date is optional but must not be in the future" do
test "Join date cannot be in the future" do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :join_date) =~ "cannot be in the future"
attrs2 = Map.delete(@valid_attrs, :join_date)
assert {:ok, _member} = Membership.create_member(attrs2)
assert {:error,
%Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
Membership.create_member(attrs)
end
test "Exit date is optional but must not be before join date if both are specified" do

View file

@ -0,0 +1,453 @@
defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
@moduledoc """
Integration tests for membership fee type changes and cycle regeneration.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-15]
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "type change cycle regeneration" do
test "future unpaid cycles are regenerated with new amount" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Create cycles: one in the past (paid), one current (unpaid)
# Note: Future cycles are not automatically generated by CycleGenerator,
# so we only test with current cycle
past_cycle_start = CalendarCycles.calculate_cycle_start(~D[2023-01-01], :yearly)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (paid) - should remain unchanged
# Check if it already exists (from auto-generation), if not create it
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to paid
existing_cycle
|> Ash.Changeset.for_update(:update, %{status: :paid})
|> Ash.update!()
_ ->
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :paid,
amount: Decimal.new("100.00")
})
end
# Current cycle (unpaid) - should be regenerated
# Delete if exists (from auto-generation), then create with old amount
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type (same interval, different amount)
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify past cycle is unchanged
past_cycle_after =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one!()
assert past_cycle_after.status == :paid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was deleted and regenerated
# Check that cycle with new type exists (regenerated)
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
# Verify it has the new type and amount
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
assert new_current_cycle.status == :unpaid
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "paid cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get the current cycle and mark it as paid
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as paid
paid_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_paid)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify paid cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id)
assert cycle_after.status == :paid
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "suspended cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get the current cycle and mark it as suspended
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as suspended
suspended_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_suspended)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify suspended cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id)
assert cycle_after.status == :suspended
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "only cycles that haven't ended yet are deleted" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended)
past_cycle_start =
CalendarCycles.calculate_cycle_start(
Date.add(today, -365),
:yearly
)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (unpaid) - should remain unchanged (cycle_start < today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
past_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify past cycle is unchanged
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id)
assert past_cycle_after.status == :unpaid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was regenerated
# Check that cycle with new type exists
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "member calculations update after type change" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member with join_date = today to avoid past cycles
# This ensures no overdue cycles exist
member = create_member(%{join_date: today})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get current cycle start
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Ensure only one cycle exists (the current one)
# Delete all cycles except the current one
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle ->
if cycle.cycle_start != current_cycle_start do
Ash.destroy!(cycle)
end
end)
# Ensure current cycle exists and is unpaid
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to unpaid if it's not
if existing_cycle.status != :unpaid do
existing_cycle
|> Ash.Changeset.for_update(:mark_as_unpaid)
|> Ash.update!()
end
_ ->
# Create if it doesn't exist
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
end
# Load calculations before change
member = Ash.load!(member, [:current_cycle_status, :overdue_count])
assert member.current_cycle_status == :unpaid
assert member.overdue_count == 0
# Change membership fee type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Reload member with calculations
updated_member = Ash.load!(updated_member, [:current_cycle_status, :overdue_count])
# Calculations should still work (cycle was regenerated)
assert updated_member.current_cycle_status == :unpaid
assert updated_member.overdue_count == 0
end
end
end

View file

@ -0,0 +1,98 @@
defmodule Mv.Membership.MembershipFeeSettingsTest do
@moduledoc """
Tests for membership fee settings in the Settings resource.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Setting
alias Mv.MembershipFees.MembershipFeeType
describe "membership fee settings" do
test "default values are correct" do
{:ok, settings} = Mv.Membership.get_settings()
assert settings.include_joining_cycle == true
end
test "settings can be read" do
{:ok, settings} = Mv.Membership.get_settings()
assert %Setting{} = settings
end
test "settings can be written via update_membership_fee_settings" do
{:ok, settings} = Mv.Membership.get_settings()
{:ok, updated} =
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
include_joining_cycle: false
})
|> Ash.update()
assert updated.include_joining_cycle == false
end
test "default_membership_fee_type_id can be nil (optional)" do
{:ok, settings} = Mv.Membership.get_settings()
{:ok, updated} =
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: nil
})
|> Ash.update()
assert updated.default_membership_fee_type_id == nil
end
test "default_membership_fee_type_id validation: must exist if set" do
{:ok, settings} = Mv.Membership.get_settings()
# Create a valid fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
# Setting a valid fee type should work
{:ok, updated} =
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update()
assert updated.default_membership_fee_type_id == fee_type.id
end
test "default_membership_fee_type_id validation: fails if not found" do
{:ok, settings} = Mv.Membership.get_settings()
# Use a non-existent UUID
fake_uuid = Ecto.UUID.generate()
assert {:error, error} =
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fake_uuid
})
|> Ash.update()
assert error_on_field?(error, :default_membership_fee_type_id)
end
end
# Helper to check if an error occurred on a specific field
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
Enum.any?(error.errors, fn e ->
case e do
%{field: ^field} -> true
%{fields: fields} when is_list(fields) -> field in fields
_ -> false
end
end)
end
defp error_on_field?(_, _), do: false
end

View file

@ -0,0 +1,268 @@
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
@moduledoc """
Tests for the SetMembershipFeeStartDate change module.
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
# Helper to set up settings with specific include_joining_cycle value
defp setup_settings(include_joining_cycle) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
end
describe "calculate_start_date/3" do
test "include_joining_cycle = true: start date is first day of joining cycle (yearly)" do
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, true)
assert result == ~D[2024-01-01]
end
test "include_joining_cycle = false: start date is first day of next cycle (yearly)" do
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, false)
assert result == ~D[2025-01-01]
end
test "include_joining_cycle = true: start date is first day of joining cycle (quarterly)" do
# Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec
# March is in Q1
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, true)
assert result == ~D[2024-01-01]
# May is in Q2
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-05-15], :quarterly, true)
assert result == ~D[2024-04-01]
# August is in Q3
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-08-15], :quarterly, true)
assert result == ~D[2024-07-01]
# November is in Q4
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-11-15], :quarterly, true)
assert result == ~D[2024-10-01]
end
test "include_joining_cycle = false: start date is first day of next cycle (quarterly)" do
# March is in Q1, next is Q2
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, false)
assert result == ~D[2024-04-01]
# June is in Q2, next is Q3
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-06-15], :quarterly, false)
assert result == ~D[2024-07-01]
# September is in Q3, next is Q4
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :quarterly, false)
assert result == ~D[2024-10-01]
# December is in Q4, next is Q1 of next year
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :quarterly, false)
assert result == ~D[2025-01-01]
end
test "include_joining_cycle = true: start date is first day of joining cycle (half_yearly)" do
# H1: Jan-Jun, H2: Jul-Dec
# March is in H1
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, true)
assert result == ~D[2024-01-01]
# September is in H2
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, true)
assert result == ~D[2024-07-01]
end
test "include_joining_cycle = false: start date is first day of next cycle (half_yearly)" do
# March is in H1, next is H2
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, false)
assert result == ~D[2024-07-01]
# September is in H2, next is H1 of next year
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, false)
assert result == ~D[2025-01-01]
end
test "include_joining_cycle = true: start date is first day of joining cycle (monthly)" do
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, true)
assert result == ~D[2024-03-01]
end
test "include_joining_cycle = false: start date is first day of next cycle (monthly)" do
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, false)
assert result == ~D[2024-04-01]
# December goes to next year
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :monthly, false)
assert result == ~D[2025-01-01]
end
test "joining on first day of cycle with include_joining_cycle = true" do
# When joining exactly on cycle start, should return that date
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, true)
assert result == ~D[2024-01-01]
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, true)
assert result == ~D[2024-04-01]
end
test "joining on first day of cycle with include_joining_cycle = false" do
# When joining exactly on cycle start and include=false, should return next cycle
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, false)
assert result == ~D[2025-01-01]
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, false)
assert result == ~D[2024-07-01]
end
test "joining on last day of cycle" do
# Joining on Dec 31 with yearly cycle
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, true)
assert result == ~D[2024-01-01]
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, false)
assert result == ~D[2025-01-01]
end
end
describe "change/3 integration" do
test "sets membership_fee_start_date automatically on member creation" do
setup_settings(true)
# Create a fee type
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
# Create member with join_date and fee type but no explicit start date
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
# Should have auto-calculated start date (2024-01-01 for yearly with include_joining_cycle=true)
assert member.membership_fee_start_date == ~D[2024-01-01]
end
test "does not override manually set membership_fee_start_date" do
setup_settings(true)
# Create a fee type
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
# Create member with explicit start date
manual_start_date = ~D[2024-07-01]
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: manual_start_date
})
|> Ash.create!()
# Should keep the manually set date
assert member.membership_fee_start_date == manual_start_date
end
test "respects include_joining_cycle = false setting" do
setup_settings(false)
# Create a fee type
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
# Create member
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
# Should have next cycle start date (2025-01-01 for yearly with include_joining_cycle=false)
assert member.membership_fee_start_date == ~D[2025-01-01]
end
test "does not set start date without join_date" do
setup_settings(true)
# Create a fee type
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
# Create member without join_date
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
# No join_date
})
|> Ash.create!()
# Should not have auto-calculated start date
assert is_nil(member.membership_fee_start_date)
end
test "does not set start date without membership_fee_type_id" do
setup_settings(true)
# Create member without fee type
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15]
# No membership_fee_type_id
})
|> Ash.create!()
# Should not have auto-calculated start date
assert is_nil(member.membership_fee_start_date)
end
end
end

View file

@ -0,0 +1,227 @@
defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
@moduledoc """
Tests for ValidateSameInterval change module.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.Changes.ValidateSameInterval
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "validate_interval_match/1" do
test "allows change to type with same interval" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "prevents change to type with different interval" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
assert %{errors: errors} = changeset
assert Enum.any?(errors, fn error ->
error.field == :membership_fee_type_id and
error.message =~ "yearly" and
error.message =~ "monthly"
end)
end
test "allows first assignment of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
# No fee type assigned
member = create_member(%{})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "prevents removal of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: nil
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
assert %{errors: errors} = changeset
assert Enum.any?(errors, fn error ->
error.field == :membership_fee_type_id and
error.message =~ "Cannot remove membership fee type"
end)
end
test "does nothing when membership_fee_type_id is not changed" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
first_name: "New Name"
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "error message is clear and helpful" do
yearly_type = create_fee_type(%{interval: :yearly})
quarterly_type = create_fee_type(%{interval: :quarterly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: quarterly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
assert error.message =~ "yearly"
assert error.message =~ "quarterly"
assert error.message =~ "same-interval"
end
test "handles all interval types correctly" do
intervals = [:monthly, :quarterly, :half_yearly, :yearly]
for interval1 <- intervals,
interval2 <- intervals,
interval1 != interval2 do
type1 =
create_fee_type(%{
interval: interval1,
name: "Type #{interval1} #{System.unique_integer([:positive])}"
})
type2 =
create_fee_type(%{
interval: interval2,
name: "Type #{interval2} #{System.unique_integer([:positive])}"
})
member = create_member(%{membership_fee_type_id: type1.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: type2.id
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?,
"Should prevent change from #{interval1} to #{interval2}"
end
end
end
describe "integration with update_member action" do
test "validation works when updating member via update_member action" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
# Try to update member with different interval type
assert {:error, %Ash.Error.Invalid{} = error} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> Ash.update()
# Check that error is about interval mismatch
error_message = extract_error_message(error)
assert error_message =~ "yearly"
assert error_message =~ "monthly"
assert error_message =~ "same-interval"
end
test "allows update when interval matches" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
# Update member with same-interval type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
assert updated_member.membership_fee_type_id == yearly_type2.id
end
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) do
errors
|> Enum.filter(&(&1.field == :membership_fee_type_id))
|> Enum.map_join(" ", & &1.message)
end
end
end

View file

@ -0,0 +1,220 @@
defmodule Mv.MembershipFees.ForeignKeyTest do
@moduledoc """
Tests for foreign key behaviors (CASCADE and RESTRICT).
"""
use Mv.DataCase, async: true
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
describe "CASCADE behavior" do
test "deleting member deletes associated membership_fee_cycles" do
# Create member
{:ok, member} =
Ash.create(Member, %{
first_name: "Cascade",
last_name: "Test",
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
})
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Create multiple cycles for this member
{:ok, cycle1} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
{:ok, cycle2} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-02-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
# Verify cycles exist
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id)
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id)
# Delete member
assert :ok = Ash.destroy(member)
# Verify cycles are also deleted (CASCADE)
# NotFound is wrapped in Ash.Error.Invalid
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle1.id)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id)
end
end
describe "RESTRICT behavior" do
test "cannot delete membership_fee_type if cycles reference it" do
# Create member
{:ok, member} =
Ash.create(Member, %{
first_name: "Restrict",
last_name: "Test",
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
})
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Create a cycle referencing this fee type
{:ok, _cycle} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
# Try to delete fee type - should fail due to RESTRICT
assert {:error, error} = Ash.destroy(fee_type)
# Check that it's a foreign key violation error
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
end
test "can delete membership_fee_type if no cycles reference it" do
# Create fee type without any cycles
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Deletable Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Should be able to delete
assert :ok = Ash.destroy(fee_type)
# Verify it's gone (NotFound is wrapped in Ash.Error.Invalid)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeType, fee_type.id)
end
test "cannot delete membership_fee_type if members reference it" do
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Member Ref Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Create member with this fee type
{:ok, _member} =
Ash.create(Member, %{
first_name: "FeeType",
last_name: "Reference",
email: "feetype.ref.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
# Try to delete fee type - should fail due to RESTRICT
assert {:error, error} = Ash.destroy(fee_type)
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
end
end
describe "member extensions" do
test "member can be created with membership_fee_type_id" do
# Create fee type first
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Create Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
# Create member with fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "With",
last_name: "FeeType",
email: "with.feetype.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
assert member.membership_fee_type_id == fee_type.id
end
test "member can be created with membership_fee_start_date" do
{:ok, member} =
Ash.create(Member, %{
first_name: "With",
last_name: "StartDate",
email: "with.startdate.#{System.unique_integer([:positive])}@example.com",
membership_fee_start_date: ~D[2025-01-01]
})
assert member.membership_fee_start_date == ~D[2025-01-01]
end
test "member can be created without membership fee fields" do
{:ok, member} =
Ash.create(Member, %{
first_name: "No",
last_name: "FeeFields",
email: "no.feefields.#{System.unique_integer([:positive])}@example.com"
})
assert member.membership_fee_type_id == nil
assert member.membership_fee_start_date == nil
end
test "member can be updated with membership_fee_type_id" do
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Update Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
# Create member without fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "Update",
last_name: "Test",
email: "update.test.#{System.unique_integer([:positive])}@example.com"
})
assert member.membership_fee_type_id == nil
# Update member with fee type
{:ok, updated_member} = Ash.update(member, %{membership_fee_type_id: fee_type.id})
assert updated_member.membership_fee_type_id == fee_type.id
end
test "member can be updated with membership_fee_start_date" do
{:ok, member} =
Ash.create(Member, %{
first_name: "Start",
last_name: "Date",
email: "start.date.#{System.unique_integer([:positive])}@example.com"
})
assert member.membership_fee_start_date == nil
{:ok, updated_member} = Ash.update(member, %{membership_fee_start_date: ~D[2025-06-01]})
assert updated_member.membership_fee_start_date == ~D[2025-06-01]
end
end
end

View file

@ -0,0 +1,211 @@
defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
@moduledoc """
Integration tests for membership fee cycle generation triggered by member actions.
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to set up settings
defp setup_settings(include_joining_cycle) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
end
# Helper to get cycles for a member
defp get_member_cycles(member_id) do
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member_id)
|> Ash.Query.sort(cycle_start: :asc)
|> Ash.read!()
end
describe "member creation triggers cycle generation" do
test "creates cycles when member is created with fee type and join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
cycles = get_member_cycles(member.id)
# Should have cycles for 2023 and 2024 (and possibly current year)
assert length(cycles) >= 2
# Verify cycles have correct data
Enum.each(cycles, fn cycle ->
assert cycle.member_id == member.id
assert cycle.membership_fee_type_id == fee_type.id
assert Decimal.equal?(cycle.amount, fee_type.amount)
assert cycle.status == :unpaid
end)
end
test "does not create cycles when member has no fee type" do
setup_settings(true)
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
# No membership_fee_type_id
})
|> Ash.create!()
cycles = get_member_cycles(member.id)
assert cycles == []
end
test "does not create cycles when member has no join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
# No join_date
})
|> Ash.create!()
cycles = get_member_cycles(member.id)
assert cycles == []
end
end
describe "member update triggers cycle generation" do
test "generates cycles when fee type is assigned to existing member" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
})
|> Ash.create!()
# Verify no cycles yet
assert get_member_cycles(member.id) == []
# Update to assign fee type
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
cycles = get_member_cycles(member.id)
# Should have generated cycles
assert length(cycles) >= 2
end
end
describe "concurrent cycle generation" do
test "handles multiple members being created concurrently" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create multiple members concurrently
tasks =
Enum.map(1..5, fn i ->
Task.async(fn ->
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test#{i}",
last_name: "User#{i}",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
end)
end)
members = Enum.map(tasks, &Task.await/1)
# Each member should have cycles
Enum.each(members, fn member ->
cycles = get_member_cycles(member.id)
assert length(cycles) >= 2, "Member #{member.id} should have at least 2 cycles"
end)
end
end
describe "idempotent cycle generation" do
test "running generation multiple times does not create duplicate cycles" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
initial_cycles = get_member_cycles(member.id)
initial_count = length(initial_cycles)
# Use a fixed "today" date to avoid date dependency
# Use a date far enough in the future to ensure all cycles are generated
today = ~D[2025-12-31]
# Manually trigger generation again with fixed "today" date
{:ok, _, _} =
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today)
final_cycles = get_member_cycles(member.id)
final_count = length(final_cycles)
# Should have same number of cycles (idempotent)
assert final_count == initial_count
end
end
end

View file

@ -0,0 +1,207 @@
defmodule Mv.MembershipFees.MembershipFeeCycleTest do
@moduledoc """
Tests for MembershipFeeCycle resource, focusing on status management actions.
"""
use Mv.DataCase, async: true
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "status defaults" do
test "status defaults to :unpaid when creating a cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
assert cycle.status == :unpaid
end
end
describe "mark_as_paid" do
test "sets status to :paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert updated.status == :paid
end
test "can set notes when marking as paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment received via bank transfer"},
action: :mark_as_paid
)
assert updated.status == :paid
assert updated.notes == "Payment received via bank transfer"
end
test "can change from suspended to paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert updated.status == :paid
end
end
describe "mark_as_suspended" do
test "sets status to :suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert updated.status == :suspended
end
test "can set notes when marking as suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Waived due to special circumstances"},
action: :mark_as_suspended
)
assert updated.status == :suspended
assert updated.notes == "Waived due to special circumstances"
end
test "can change from paid to suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert updated.status == :suspended
end
end
describe "mark_as_unpaid" do
test "sets status to :unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
test "can set notes when marking as unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment was reversed"}, action: :mark_as_unpaid)
assert updated.status == :unpaid
assert updated.notes == "Payment was reversed"
end
test "can change from suspended to unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
end
describe "status transitions" do
test "all status transitions are allowed" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# unpaid -> paid
cycle1 = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, c1} = Ash.update(cycle1, %{}, action: :mark_as_paid)
assert c1.status == :paid
# paid -> suspended
assert {:ok, c2} = Ash.update(c1, %{}, action: :mark_as_suspended)
assert c2.status == :suspended
# suspended -> unpaid
assert {:ok, c3} = Ash.update(c2, %{}, action: :mark_as_unpaid)
assert c3.status == :unpaid
# unpaid -> suspended
assert {:ok, c4} = Ash.update(c3, %{}, action: :mark_as_suspended)
assert c4.status == :suspended
# suspended -> paid
assert {:ok, c5} = Ash.update(c4, %{}, action: :mark_as_paid)
assert c5.status == :paid
# paid -> unpaid
assert {:ok, c6} = Ash.update(c5, %{}, action: :mark_as_unpaid)
assert c6.status == :unpaid
end
end
end

View file

@ -0,0 +1,221 @@
defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
@moduledoc """
Integration tests for MembershipFeeType CRUD operations.
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "admin can create membership fee type" do
test "creates type with all fields" do
attrs = %{
name: "Standard Membership",
amount: Decimal.new("120.00"),
interval: :yearly,
description: "Standard yearly membership fee"
}
assert {:ok, %MembershipFeeType{} = fee_type} = Ash.create(MembershipFeeType, attrs)
assert fee_type.name == "Standard Membership"
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
assert fee_type.interval == :yearly
assert fee_type.description == "Standard yearly membership fee"
end
end
describe "admin can update membership fee type" do
setup do
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Original Name",
amount: Decimal.new("100.00"),
interval: :yearly,
description: "Original description"
})
%{fee_type: fee_type}
end
test "can update name", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
assert updated.name == "Updated Name"
end
test "can update amount", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
end
test "can update description", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
assert updated.description == "Updated description"
end
test "cannot update interval", %{fee_type: fee_type} do
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
# After implementing validation, it should return a validation error
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly})
# For now, check that it's an error (either NoSuchInput or validation error)
assert %Ash.Error.Invalid{} = error
end
end
describe "admin cannot delete membership fee type when in use" do
test "cannot delete when members are assigned" do
fee_type = create_fee_type(%{interval: :yearly})
# Create a member with this fee type
{:ok, _member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
assert {:error, error} = Ash.destroy(fee_type)
error_message = extract_error_message(error)
assert error_message =~ "member(s) are assigned"
end
test "cannot delete when cycles exist" do
fee_type = create_fee_type(%{interval: :yearly})
# Create a member with this fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
# Create a cycle for this fee type
{:ok, _cycle} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
assert {:error, error} = Ash.destroy(fee_type)
error_message = extract_error_message(error)
assert error_message =~ "cycle(s) reference"
end
test "cannot delete when used as default in settings" do
fee_type = create_fee_type(%{interval: :yearly})
# Set as default in settings
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
# Try to delete
assert {:error, error} = Ash.destroy(fee_type)
error_message = extract_error_message(error)
assert error_message =~ "used as default in settings"
end
end
describe "settings integration" do
test "default_membership_fee_type_id is used during member creation" do
# Create a fee type
fee_type = create_fee_type(%{interval: :yearly})
# Set it as default in settings
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
# Create a member without explicitly setting membership_fee_type_id
# Note: This test assumes that the Member resource automatically assigns
# the default_membership_fee_type_id during creation. If this is not yet
# implemented, this test will fail initially (which is expected in TDD).
# For now, we skip this test as the auto-assignment feature is not yet implemented.
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
# TODO: When auto-assignment is implemented, uncomment this assertion
# assert member.membership_fee_type_id == fee_type.id
# For now, we just verify the member was created successfully
assert %Member{} = member
end
test "include_joining_cycle is used during cycle generation" do
# This test verifies that the include_joining_cycle setting affects
# cycle generation. The actual cycle generation logic is tested in
# CycleGeneratorTest, but this integration test ensures the setting
# is properly used.
fee_type = create_fee_type(%{interval: :yearly})
# Set include_joining_cycle to false
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
include_joining_cycle: false
})
|> Ash.update!()
# Create a member with join_date in the middle of a year
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
# Verify that membership_fee_start_date was calculated correctly
# (should be 2024-01-01, not 2023-01-01, because include_joining_cycle = false)
assert member.membership_fee_start_date == ~D[2024-01-01]
end
end
# Helper to extract error message from various error types
defp extract_error_message(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, " ", fn
%{message: message} -> message
%{detail: detail} -> detail
_ -> ""
end)
end
defp extract_error_message(_), do: ""
end

View file

@ -0,0 +1,272 @@
defmodule Mv.MembershipFees.MembershipFeeTypeTest do
@moduledoc """
Tests for MembershipFeeType resource.
"""
use Mv.DataCase, async: true
alias Mv.MembershipFees.MembershipFeeType
describe "create MembershipFeeType" do
test "can create membership fee type with valid attributes" do
attrs = %{
name: "Standard Membership",
amount: Decimal.new("120.00"),
interval: :yearly,
description: "Standard yearly membership fee"
}
assert {:ok, %MembershipFeeType{} = fee_type} =
Ash.create(MembershipFeeType, attrs)
assert fee_type.name == "Standard Membership"
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
assert fee_type.interval == :yearly
assert fee_type.description == "Standard yearly membership fee"
end
test "can create membership fee type without description" do
attrs = %{
name: "Basic",
amount: Decimal.new("60.00"),
interval: :monthly
}
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs)
end
test "requires name" do
attrs = %{
amount: Decimal.new("100.00"),
interval: :yearly
}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert error_on_field?(error, :name)
end
test "requires amount" do
attrs = %{
name: "Test Fee",
interval: :yearly
}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert error_on_field?(error, :amount)
end
test "requires interval" do
attrs = %{
name: "Test Fee",
amount: Decimal.new("100.00")
}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert error_on_field?(error, :interval)
end
test "validates interval enum values - monthly" do
attrs = %{name: "Monthly", amount: Decimal.new("10.00"), interval: :monthly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert fee_type.interval == :monthly
end
test "validates interval enum values - quarterly" do
attrs = %{name: "Quarterly", amount: Decimal.new("30.00"), interval: :quarterly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert fee_type.interval == :quarterly
end
test "validates interval enum values - half_yearly" do
attrs = %{name: "Half Yearly", amount: Decimal.new("60.00"), interval: :half_yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert fee_type.interval == :half_yearly
end
test "validates interval enum values - yearly" do
attrs = %{name: "Yearly", amount: Decimal.new("120.00"), interval: :yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert fee_type.interval == :yearly
end
test "rejects invalid interval values" do
attrs = %{name: "Invalid", amount: Decimal.new("100.00"), interval: :weekly}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert error_on_field?(error, :interval)
end
test "name must be unique" do
attrs = %{name: "Unique Name", amount: Decimal.new("100.00"), interval: :yearly}
assert {:ok, _} = Ash.create(MembershipFeeType, attrs)
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
# Check for uniqueness error
assert error_on_field?(error, :name)
end
test "rejects negative amount" do
attrs = %{name: "Negative Test", amount: Decimal.new("-10.00"), interval: :yearly}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert error_on_field?(error, :amount)
end
test "accepts zero amount" do
attrs = %{name: "Zero Amount", amount: Decimal.new("0.00"), interval: :yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert Decimal.equal?(fee_type.amount, Decimal.new("0.00"))
end
test "amount respects scale of 2 decimal places" do
attrs = %{name: "Scale Test", amount: Decimal.new("100.50"), interval: :yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert Decimal.equal?(fee_type.amount, Decimal.new("100.50"))
end
end
describe "update MembershipFeeType" do
setup do
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Original Name",
amount: Decimal.new("100.00"),
interval: :yearly,
description: "Original description"
})
%{fee_type: fee_type}
end
test "can update name", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
assert updated.name == "Updated Name"
end
test "can update amount", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
end
test "can update description", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
assert updated.description == "Updated description"
end
test "can clear description", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
assert updated.description == nil
end
test "interval immutability: update fails when interval is changed", %{fee_type: fee_type} do
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
# After implementing validation, it should return a validation error
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly})
# For now, check that it's an error (either NoSuchInput or validation error)
assert %Ash.Error.Invalid{} = error
end
end
describe "delete MembershipFeeType" do
setup do
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
%{fee_type: fee_type}
end
test "can delete when not in use", %{fee_type: fee_type} do
result = Ash.destroy(fee_type)
# Ash.destroy returns :ok or {:ok, _} depending on version
assert result == :ok or match?({:ok, _}, result)
end
test "cannot delete when members are assigned", %{fee_type: fee_type} do
alias Mv.Membership.Member
# Create a member with this fee type
{:ok, _member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
assert {:error, error} = Ash.destroy(fee_type)
# Check for either validation error message or DB constraint error
error_message = extract_error_message(error)
assert error_message =~ "member" or error_message =~ "referenced"
end
test "cannot delete when cycles exist", %{fee_type: fee_type} do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
# Create a member with this fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
# Create a cycle for this fee type
{:ok, _cycle} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
assert {:error, error} = Ash.destroy(fee_type)
# Check for either validation error message or DB constraint error
error_message = extract_error_message(error)
assert error_message =~ "cycle" or error_message =~ "referenced"
end
test "cannot delete when used as default in settings", %{fee_type: fee_type} do
# Set as default in settings
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
# Try to delete
assert {:error, error} = Ash.destroy(fee_type)
error_message = extract_error_message(error)
assert error_message =~ "used as default in settings"
end
end
# Helper to check if an error occurred on a specific field
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
Enum.any?(error.errors, fn e ->
case e do
%{field: ^field} -> true
%{fields: fields} when is_list(fields) -> field in fields
_ -> false
end
end)
end
defp error_on_field?(_, _), do: false
# Helper to extract error message from various error types
defp extract_error_message(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, " ", fn
%{message: message} -> message
%{detail: detail} -> detail
_ -> ""
end)
end
defp extract_error_message(_), do: ""
end

View file

@ -0,0 +1,181 @@
defmodule Mv.MembershipFees.CalendarCyclesTest do
@moduledoc """
Tests for CalendarCycles module.
"""
use ExUnit.Case, async: true
alias Mv.MembershipFees.CalendarCycles
doctest Mv.MembershipFees.CalendarCycles
describe "calculate_cycle_start/3" do
test "uses reference_date when provided" do
date = ~D[2024-03-15]
reference = ~D[2024-05-20]
assert CalendarCycles.calculate_cycle_start(date, :monthly, reference) == ~D[2024-05-01]
assert CalendarCycles.calculate_cycle_start(date, :quarterly, reference) == ~D[2024-04-01]
end
end
describe "current_cycle?/3" do
# Basic examples are covered by doctests
test "works for all interval types" do
today = ~D[2024-03-15]
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
cycle_start = CalendarCycles.calculate_cycle_start(today, interval)
result = CalendarCycles.current_cycle?(cycle_start, interval, today)
assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}"
end
end
end
describe "current_cycle?/2 wrapper" do
test "calls current_cycle?/3 with Date.utc_today()" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
# This test verifies the wrapper works, but uses actual today
# The real testing happens in current_cycle?/3 tests above
result = CalendarCycles.current_cycle?(cycle_start, :monthly)
assert result == true
end
end
describe "last_completed_cycle?/3" do
# Basic examples are covered by doctests
test "returns false when next cycle has also ended" do
# Two cycles ago: cycle ended, but next cycle also ended
today = ~D[2024-05-15]
cycle_start = ~D[2024-03-01]
# Cycle ended 2024-03-31, next cycle ended 2024-04-30, today is 2024-05-15
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
end
test "works correctly for quarterly intervals" do
# Q1 2024 ended on 2024-03-31
# Q2 2024 ends on 2024-06-30
# Today is 2024-04-15 (after Q1 ended, before Q2 ended)
today = ~D[2024-04-15]
past_quarter_start = ~D[2024-01-01]
assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly, today) == true
end
test "returns false when cycle ended on the given date" do
# Cycle ends on today, so it's still current, not completed
today = ~D[2024-03-31]
cycle_start = ~D[2024-03-01]
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
end
end
describe "last_completed_cycle?/2 wrapper" do
test "calls last_completed_cycle?/3 with Date.utc_today()" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
# This test verifies the wrapper works, but uses actual today
# The real testing happens in last_completed_cycle?/3 tests above
result = CalendarCycles.last_completed_cycle?(cycle_start, :monthly)
# Result depends on actual today, so we just verify it's a boolean
assert is_boolean(result)
end
end
describe "edge cases" do
test "leap year: February has 29 days" do
# 2024 is a leap year
assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29]
# 2023 is not a leap year
assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28]
end
test "year boundary: December 31 to January 1" do
# Yearly cycle
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
# Monthly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01]
# Half-yearly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01]
# Quarterly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01]
end
test "month boundary: different month lengths" do
# 31-day months
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :monthly) == ~D[2024-01-31]
assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31]
assert CalendarCycles.calculate_cycle_end(~D[2024-05-01], :monthly) == ~D[2024-05-31]
# 30-day months
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-06-01], :monthly) == ~D[2024-06-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-09-01], :monthly) == ~D[2024-09-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-11-01], :monthly) == ~D[2024-11-30]
end
test "date in middle of cycle: all functions work correctly" do
middle_date = ~D[2024-03-15]
# calculate_cycle_start
assert CalendarCycles.calculate_cycle_start(middle_date, :monthly) == ~D[2024-03-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :yearly) == ~D[2024-01-01]
# calculate_cycle_end
monthly_start = CalendarCycles.calculate_cycle_start(middle_date, :monthly)
assert CalendarCycles.calculate_cycle_end(monthly_start, :monthly) == ~D[2024-03-31]
# next_cycle_start
assert CalendarCycles.next_cycle_start(monthly_start, :monthly) == ~D[2024-04-01]
end
test "quarterly: all quarter boundaries correct" do
# Q1 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31]
# Q2 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30]
# Q3 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30]
# Q4 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31]
end
test "half_yearly: both half boundaries correct" do
# First half boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30]
# Second half boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31]
end
test "yearly: full year boundaries" do
assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31]
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
end
end
end

View file

@ -0,0 +1,644 @@
defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
@moduledoc """
Edge case tests for the CycleGenerator module.
Tests cover:
- Member joins today
- Member left yesterday
- Year boundary handling
- Leap year handling
- Members with no existing cycles
- Members with existing cycles
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member. Note: If membership_fee_type_id is provided,
# cycles will be auto-generated during creation in test environment.
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a member and explicitly generate cycles with a fixed "today" date.
# This avoids date dependency issues in tests.
#
# Note: We first create the member without fee_type_id, then assign it via update,
# which triggers the after_action hook. However, we then explicitly regenerate
# cycles with the fixed "today" date to ensure consistency.
defp create_member_with_cycles(attrs, today) do
# Extract membership_fee_type_id if present
fee_type_id = Map.get(attrs, :membership_fee_type_id)
# Create member WITHOUT fee type first to avoid auto-generation with real today
attrs_without_fee_type = Map.delete(attrs, :membership_fee_type_id)
member =
create_member(attrs_without_fee_type)
# Assign fee type if provided (this will trigger auto-generation with real today)
member =
if fee_type_id do
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|> Ash.update!()
else
member
end
# Explicitly regenerate cycles with fixed "today" date to override any auto-generated cycles
# This ensures the test uses the fixed date, not the real current date
if fee_type_id && member.join_date do
# Delete any existing cycles first to ensure clean state
existing_cycles = get_member_cycles(member.id)
Enum.each(existing_cycles, &Ash.destroy!(&1))
# Generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
end
member
end
# Helper to get cycles for a member
defp get_member_cycles(member_id) do
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member_id)
|> Ash.Query.sort(cycle_start: :asc)
|> Ash.read!()
end
# Helper to set up settings
defp setup_settings(include_joining_cycle) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
end
describe "member joins today" do
test "current cycle is generated (yearly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-06-15]
# Create member WITHOUT fee type first to avoid auto-generation with real today
member =
create_member(%{
join_date: today,
membership_fee_start_date: ~D[2024-01-01]
})
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Explicitly generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles
cycles = get_member_cycles(member.id)
# Should have the current year's cycle
cycle_years = Enum.map(cycles, & &1.cycle_start.year)
assert 2024 in cycle_years
end
test "current cycle is generated (monthly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
today = ~D[2024-06-15]
# Create member WITHOUT fee type first to avoid auto-generation with real today
member =
create_member(%{
join_date: today,
membership_fee_start_date: ~D[2024-06-01]
})
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Explicitly generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles
cycles = get_member_cycles(member.id)
# Should have June 2024 cycle
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end)
end
test "current cycle is generated (quarterly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :quarterly})
today = ~D[2024-05-15]
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: today,
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-04-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
# Should have Q2 2024 cycle
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end)
end
end
describe "member left yesterday" do
test "no future cycles are generated" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-06-15]
yesterday = Date.add(today, -1)
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2022-03-15],
exit_date: yesterday,
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# 2024 should be included because the member was still active during that cycle
assert 2022 in cycle_years
assert 2023 in cycle_years
assert 2024 in cycle_years
# 2025 should NOT be included
refute 2025 in cycle_years
end
test "exit during first month of year stops at that year (monthly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
# Create member - cycles will be auto-generated
member =
create_member(%{
join_date: ~D[2024-01-15],
exit_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
# Check all cycles
cycles = get_member_cycles(member.id)
cycle_months = Enum.map(cycles, & &1.cycle_start.month) |> Enum.sort()
assert 1 in cycle_months
assert 2 in cycle_months
assert 3 in cycle_months
# April and beyond should NOT be included
refute 4 in cycle_months
refute 5 in cycle_months
end
end
describe "member has no cycles initially" do
test "returns error when fee type is not assigned" do
setup_settings(true)
# Create member WITHOUT fee type (no auto-generation)
member =
create_member(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
# Verify no cycles exist initially
initial_cycles = get_member_cycles(member.id)
assert initial_cycles == []
# Trying to generate cycles without fee type should return error
result = CycleGenerator.generate_cycles_for_member(member.id)
assert result == {:error, :no_membership_fee_type}
end
test "generates all cycles when member is created with fee type" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-06-15]
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2022-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
# Should have generated all cycles from 2022 to 2024 (3 cycles)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
assert 2022 in cycle_years
assert 2023 in cycle_years
assert 2024 in cycle_years
# Should NOT have 2025 (today is 2024-06-15)
refute 2025 in cycle_years
end
end
describe "member has existing cycles" do
test "generates from last cycle (not duplicating existing)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member WITHOUT fee type first
member =
create_member(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
# Manually create an existing cycle for 2022
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2022-01-01],
member_id: member.id,
membership_fee_type_id: fee_type.id,
amount: fee_type.amount,
status: :paid
})
|> Ash.create!()
# Now assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Explicitly generate cycles with fixed "today" date
today = ~D[2024-06-15]
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles
all_cycles = get_member_cycles(member.id)
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
# Should have 2022 (manually created), 2023 and 2024 (auto-generated)
assert 2022 in all_cycle_years
assert 2023 in all_cycle_years
assert 2024 in all_cycle_years
# Verify no duplicates
assert length(all_cycles) == length(all_cycle_years)
end
end
describe "year boundary handling" do
test "cycles span across year boundaries correctly (yearly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-06-15]
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2023-11-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-01-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should have 2023 and 2024
assert 2023 in cycle_years
assert 2024 in cycle_years
end
test "cycles span across year boundaries correctly (quarterly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :quarterly})
today = ~D[2024-12-15]
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2024-10-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-10-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
# Should have Q4 2024
assert ~D[2024-10-01] in cycle_starts
end
test "December to January transition (monthly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
today = ~D[2024-12-31]
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2024-12-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-12-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
# Should have Dec 2024
assert ~D[2024-12-01] in cycle_starts
end
end
describe "leap year handling" do
test "February cycles in leap year" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
today = ~D[2024-03-15]
# 2024 is a leap year
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-02-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
# Should have February 2024 cycle
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-02-01] end)
assert feb_cycle != nil
end
test "February cycles in non-leap year" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
today = ~D[2023-03-15]
# 2023 is NOT a leap year
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2023-02-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-02-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
# Should have February 2023 cycle
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2023-02-01] end)
assert feb_cycle != nil
end
test "yearly cycle in leap year" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-12-31]
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2024-02-29],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
# Should have 2024 cycle
cycle_2024 = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-01-01] end)
assert cycle_2024 != nil
end
end
describe "include_joining_cycle variations" do
test "include_joining_cycle = true starts from joining cycle" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-06-15]
# Member joins mid-2023, should get 2023 cycle with include_joining_cycle=true
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2023-06-15],
membership_fee_type_id: fee_type.id
# membership_fee_start_date will be auto-calculated
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should include 2023 (joining year)
assert 2023 in cycle_years
end
test "include_joining_cycle = false starts from next cycle" do
setup_settings(false)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-06-15]
# Member joins mid-2023, should start from 2024 with include_joining_cycle=false
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2023-06-15],
membership_fee_type_id: fee_type.id
# membership_fee_start_date will be auto-calculated
},
today
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should NOT include 2023 (joining year)
refute 2023 in cycle_years
# Should start from 2024
assert 2024 in cycle_years
end
end
describe "inactive member processing" do
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create an inactive member (left in 2023) WITHOUT fee type initially
# This simulates a member that was created before the fee system existed
member =
create_member(%{
join_date: ~D[2021-03-15],
exit_date: ~D[2023-06-15]
})
# Now assign fee type (simulating a retroactive assignment)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2021-01-01]
})
|> Ash.update!()
# Run batch generation with a "today" date after the member left
today = ~D[2024-06-15]
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
# The inactive member should have been processed
assert results.total >= 1
# Check the member's cycles
cycles = get_member_cycles(member.id)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
# Should have 2021, 2022, 2023 (exit year included)
assert 2021 in cycle_years
assert 2022 in cycle_years
assert 2023 in cycle_years
# Should NOT have 2024 (after exit)
refute 2024 in cycle_years
end
test "exit_date on cycle_start still generates that cycle" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
today = ~D[2024-12-31]
# Member exits exactly on cycle start (2024-01-01)
# Create member and generate cycles with fixed "today" date
member =
create_member_with_cycles(
%{
join_date: ~D[2022-03-15],
exit_date: ~D[2024-01-01],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
today
)
# Check cycles
cycles = get_member_cycles(member.id)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# 2024 should be included because exit_date == cycle_start means
# the member was still a member on that day
assert 2022 in cycle_years
assert 2023 in cycle_years
assert 2024 in cycle_years
# 2025 should NOT be included
refute 2025 in cycle_years
end
end
end

View file

@ -0,0 +1,428 @@
defmodule Mv.MembershipFees.CycleGeneratorTest do
@moduledoc """
Tests for the CycleGenerator module.
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member without triggering cycle generation
defp create_member_without_cycles(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to set up settings with specific include_joining_cycle value
defp setup_settings(include_joining_cycle) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
end
# Helper to get cycles for a member
defp get_member_cycles(member_id) do
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member_id)
|> Ash.Query.sort(cycle_start: :asc)
|> Ash.read!()
end
describe "generate_cycles_for_member/2" do
test "generates cycles from start date to today" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member WITHOUT fee type first to avoid auto-generation
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Explicitly generate cycles with fixed "today" date to avoid date dependency
today = ~D[2024-06-15]
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Verify cycles were generated
all_cycles = get_member_cycles(member.id)
cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
# With include_joining_cycle=true and join_date=2022-03-15,
# start_date should be 2022-01-01
# Should have cycles for 2022, 2023, 2024
assert 2022 in cycle_years
assert 2023 in cycle_years
assert 2024 in cycle_years
end
test "generates cycles from last existing cycle" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type first to avoid auto-generation
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
# Manually create a cycle for 2022
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2022-01-01],
member_id: member.id,
membership_fee_type_id: fee_type.id,
amount: fee_type.amount,
status: :paid
})
|> Ash.create!()
# Now assign fee type to member
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Generate cycles with specific "today" date
today = ~D[2024-06-15]
{:ok, new_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Should generate only 2023 and 2024 (2022 already exists)
new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort()
assert 2022 not in new_cycle_years
end
test "respects left_at boundary (stops generation)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
exit_date: ~D[2023-06-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
})
# Generate cycles with specific "today" date far in the future
today = ~D[2025-06-15]
{:ok, cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# With exit_date in 2023, should only generate 2022 and 2023 cycles
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should not have 2024 or 2025 cycles
assert 2024 not in cycle_years
assert 2025 not in cycle_years
end
test "skips existing cycles (idempotent)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-01-01]
})
today = ~D[2024-06-15]
# First generation
{:ok, _first_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Second generation (should be idempotent)
{:ok, second_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Second call should return empty list (no new cycles)
assert second_cycles == []
end
test "does not fill gaps when cycles were deleted" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type first to control which cycles exist
member =
create_member_without_cycles(%{
join_date: ~D[2020-03-15],
membership_fee_start_date: ~D[2020-01-01]
})
# Manually create cycles for 2020, 2021, 2022, 2023
for year <- [2020, 2021, 2022, 2023] do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: Date.new!(year, 1, 1),
member_id: member.id,
membership_fee_type_id: fee_type.id,
amount: fee_type.amount,
status: :unpaid
})
|> Ash.create!()
end
# Delete the 2021 cycle (create a gap)
cycle_2021 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|> Ash.read_one!()
Ash.destroy!(cycle_2021)
# Now assign fee type to member (this triggers generation)
# Since cycles already exist (2020, 2022, 2023), the generator will
# start from the last existing cycle (2023) and go forward
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Verify gap was NOT filled and new cycles were generated from last existing
all_cycles = get_member_cycles(member.id)
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
# 2021 should NOT exist (gap was not filled)
refute 2021 in all_cycle_years, "Gap at 2021 should NOT be filled"
# 2020, 2022, 2023 should exist (original cycles)
assert 2020 in all_cycle_years
assert 2022 in all_cycle_years
assert 2023 in all_cycle_years
# 2024 and 2025 should exist (generated after last existing cycle 2023)
assert 2024 in all_cycle_years
assert 2025 in all_cycle_years
end
test "sets correct amount from membership fee type" do
setup_settings(true)
amount = Decimal.new("75.50")
fee_type = create_fee_type(%{interval: :yearly, amount: amount})
member =
create_member_without_cycles(%{
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
# Verify cycles were generated with correct amount
all_cycles = get_member_cycles(member.id)
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
# All cycles should have the correct amount
Enum.each(all_cycles, fn cycle ->
assert Decimal.equal?(cycle.amount, amount)
end)
end
test "handles NULL membership_fee_start_date by calculating from join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :quarterly})
# Create member without membership_fee_start_date - it will be auto-calculated
# and cycles will be auto-generated
member =
create_member_without_cycles(%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id
# No membership_fee_start_date - should be calculated
})
# Verify cycles were auto-generated
all_cycles = get_member_cycles(member.id)
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
# start_date should be 2024-01-01 (Q1 start)
# Should have Q1, Q2, Q3, Q4 2024 cycles (based on current date)
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
cycle_starts = Enum.map(all_cycles, & &1.cycle_start) |> Enum.sort(Date)
first_cycle_start = List.first(cycle_starts)
# First cycle should start in Q1 2024 (2024-01-01)
assert first_cycle_start == ~D[2024-01-01]
end
test "returns error when member has no membership_fee_type" do
# Create member without fee type - no auto-generation will occur
member =
create_member_without_cycles(%{
join_date: ~D[2024-03-15]
# No membership_fee_type_id
})
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
assert reason == :no_membership_fee_type
end
test "returns error when member has no join_date" do
fee_type = create_fee_type(%{interval: :yearly})
# Create member without join_date - no auto-generation will occur
# (after_action hook checks for join_date)
member =
create_member_without_cycles(%{
membership_fee_type_id: fee_type.id
# No join_date
})
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
assert reason == :no_join_date
end
test "returns error when member not found" do
fake_id = Ash.UUID.generate()
{:error, reason} = CycleGenerator.generate_cycles_for_member(fake_id)
assert reason == :member_not_found
end
end
describe "generate_cycle_starts/3" do
test "generates correct cycle starts for yearly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2022-01-01], ~D[2024-06-15], :yearly)
assert starts == [~D[2022-01-01], ~D[2023-01-01], ~D[2024-01-01]]
end
test "generates correct cycle starts for quarterly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-09-15], :quarterly)
assert starts == [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01]]
end
test "generates correct cycle starts for monthly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-03-15], :monthly)
assert starts == [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01]]
end
test "generates correct cycle starts for half_yearly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2023-01-01], ~D[2024-09-15], :half_yearly)
assert starts == [~D[2023-01-01], ~D[2023-07-01], ~D[2024-01-01], ~D[2024-07-01]]
end
test "returns empty list when start_date is after end_date" do
starts = CycleGenerator.generate_cycle_starts(~D[2025-01-01], ~D[2024-06-15], :yearly)
assert starts == []
end
test "includes cycle when end_date is on cycle start" do
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-01-01], :yearly)
assert starts == [~D[2024-01-01]]
end
end
describe "generate_cycles_for_all_members/1" do
test "generates cycles for multiple members" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create multiple members
_member1 =
create_member_without_cycles(%{
join_date: ~D[2024-01-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
_member2 =
create_member_without_cycles(%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
today = ~D[2024-06-15]
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
assert is_map(results)
assert Map.has_key?(results, :success)
assert Map.has_key?(results, :failed)
assert Map.has_key?(results, :total)
end
end
describe "lock mechanism" do
test "prevents concurrent generation for same member" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
})
today = ~D[2024-06-15]
# Run two concurrent generations
task1 =
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
task2 =
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
result1 = Task.await(task1)
result2 = Task.await(task2)
# Both should succeed
assert match?({:ok, _, _}, result1)
assert match?({:ok, _, _}, result2)
# One should have created cycles, the other should have empty list (idempotent)
{:ok, cycles1, _} = result1
{:ok, cycles2, _} = result2
# Combined should not have duplicates
all_cycles = cycles1 ++ cycles2
unique_starts = all_cycles |> Enum.map(& &1.cycle_start) |> Enum.uniq()
assert length(all_cycles) == length(unique_starts)
end
end
end

View file

@ -3,7 +3,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- Rendering in all 3 filter states (nil, :paid, :unpaid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
@ -25,15 +25,15 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with not_paid filter active", %{conn: conn} do
test "renders with unpaid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
@ -82,7 +82,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view
@ -94,7 +94,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain paid_filter param - wait for patch
# URL should not contain cycle_status_filter param - wait for patch
assert_patch(view)
end
@ -112,12 +112,12 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=paid
# Wait for patch and check URL contains cycle_status_filter=paid
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "cycle_status_filter=paid"
end
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
test "selecting 'Unpaid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
@ -126,14 +126,14 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Not paid" option
# Select "Unpaid" option
view
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> element("#payment-filter button[phx-value-filter='unpaid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=not_paid
# Wait for patch and check URL contains cycle_status_filter=unpaid
path = assert_patch(view)
assert path =~ "paid_filter=not_paid"
assert path =~ "cycle_status_filter=unpaid"
end
end
@ -166,7 +166,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view

View file

@ -0,0 +1,260 @@
defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
@moduledoc """
Tests for MembershipFeeHelpers module.
"""
use Mv.DataCase, async: true
require Ash.Query
alias MvWeb.Helpers.MembershipFeeHelpers
alias Mv.MembershipFees.CalendarCycles
describe "format_currency/1" do
test "formats decimal amount correctly" do
assert MembershipFeeHelpers.format_currency(Decimal.new("60.00")) == "60,00 €"
assert MembershipFeeHelpers.format_currency(Decimal.new("5.5")) == "5,50 €"
assert MembershipFeeHelpers.format_currency(Decimal.new("100")) == "100,00 €"
assert MembershipFeeHelpers.format_currency(Decimal.new("0.99")) == "0,99 €"
end
end
describe "format_interval/1" do
test "formats all interval types correctly" do
assert MembershipFeeHelpers.format_interval(:monthly) == "Monthly"
assert MembershipFeeHelpers.format_interval(:quarterly) == "Quarterly"
assert MembershipFeeHelpers.format_interval(:half_yearly) == "Half-yearly"
assert MembershipFeeHelpers.format_interval(:yearly) == "Yearly"
end
end
describe "format_cycle_range/2" do
test "formats yearly cycle range correctly" do
cycle_start = ~D[2024-01-01]
interval = :yearly
_cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
assert result =~ "2024"
assert result =~ "01.01"
assert result =~ "31.12"
end
test "formats quarterly cycle range correctly" do
cycle_start = ~D[2024-01-01]
interval = :quarterly
_cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
assert result =~ "2024"
assert result =~ "01.01"
assert result =~ "31.03"
end
test "formats monthly cycle range correctly" do
cycle_start = ~D[2024-03-01]
interval = :monthly
_cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
assert result =~ "2024"
assert result =~ "01.03"
assert result =~ "31.03"
end
end
describe "get_last_completed_cycle/2" do
test "returns last completed cycle for member" do
# Create test data
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Type",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
# Create member without fee type first to avoid auto-generation
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2022-01-01]
})
|> Ash.create!()
# Assign fee type after member creation (this may generate cycles, but we'll create our own)
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Delete any auto-generated cycles first
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
# Create cycles manually
_cycle_2022 =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2022-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :paid
})
|> Ash.create!()
cycle_2023 =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :paid
})
|> Ash.create!()
# Load cycles with membership_fee_type relationship
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
# Use a fixed date in 2024 to ensure 2023 is last completed
today = ~D[2024-06-15]
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, today)
assert last_cycle.id == cycle_2023.id
end
test "returns nil if no cycles exist" do
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Type",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
# Create member without fee type first
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!()
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
# Load cycles and fee type (will be empty)
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
assert last_cycle == nil
end
end
describe "get_current_cycle/2" do
test "returns current cycle for member" do
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Type",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
# Create member without fee type first
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-01]
})
|> Ash.create!()
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
current_cycle =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: current_year_start,
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
# Load cycles with membership_fee_type relationship
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
result = MembershipFeeHelpers.get_current_cycle(member, today)
assert result.id == current_cycle.id
end
end
describe "status_color/1" do
test "returns correct color classes for statuses" do
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
assert MembershipFeeHelpers.status_color(:unpaid) == "badge-error"
assert MembershipFeeHelpers.status_color(:suspended) == "badge-ghost"
end
end
describe "status_icon/1" do
test "returns correct icon names for statuses" do
assert MembershipFeeHelpers.status_icon(:paid) == "hero-check-circle"
assert MembershipFeeHelpers.status_icon(:unpaid) == "hero-x-circle"
assert MembershipFeeHelpers.status_icon(:suspended) == "hero-pause-circle"
end
end
end

View file

@ -0,0 +1,218 @@
defmodule MvWeb.MembershipFeeTypeLive.FormTest do
@moduledoc """
Tests for membership fee types create/edit form.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "create form" do
test "creates new membership fee type", %{conn: conn} do
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
form_data = %{
"membership_fee_type[name]" => "New Type",
"membership_fee_type[amount]" => "75.00",
"membership_fee_type[interval]" => "yearly",
"membership_fee_type[description]" => "Test description"
}
{:error, {:live_redirect, %{to: to}}} =
view
|> form("#membership-fee-type-form", form_data)
|> render_submit()
assert to == "/membership_fee_types"
# Verify type was created
type =
MembershipFeeType
|> Ash.Query.filter(name == "New Type")
|> Ash.read_one!()
assert type.amount == Decimal.new("75.00")
assert type.interval == :yearly
end
test "interval field is editable on create", %{conn: conn} do
{:ok, _view, html} = live(conn, "/membership_fee_types/new")
# Interval field should be editable (not disabled)
refute html =~ "disabled" || html =~ "readonly"
end
end
describe "edit form" do
test "loads existing type data", %{conn: conn} do
fee_type = create_fee_type(%{name: "Existing Type", amount: Decimal.new("60.00")})
{:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
assert html =~ "Existing Type"
assert html =~ "60" || html =~ "60,00"
end
test "interval field is grayed out on edit", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
{:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
# Interval field should be disabled
assert html =~ "disabled" || html =~ "readonly"
end
test "amount change warning displays on edit", %{conn: conn} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
create_member(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
# Change amount
view
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|> render_change()
# Should show warning in rendered view
html = render(view)
assert html =~ "affect" || html =~ "Change Amount"
end
test "amount change warning shows correct affected member count", %{conn: conn} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
# Create 3 members
Enum.each(1..3, fn _ ->
create_member(%{membership_fee_type_id: fee_type.id})
end)
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
# Change amount
html =
view
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|> render_change()
# Should show affected count
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
end
test "amount change can be confirmed", %{conn: conn} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
# Change amount and confirm
view
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|> render_change()
view
|> element("button[phx-click='confirm_amount_change']")
|> render_click()
# Submit the form to actually save the change
view
|> 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))
assert updated_type.amount == Decimal.new("75.00")
end
test "amount change can be cancelled", %{conn: conn} do
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
# Change amount and cancel
view
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|> render_change()
view
|> 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))
assert updated_type.amount == Decimal.new("50.00")
end
test "validation errors display correctly", %{conn: conn} do
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
# Submit with invalid data
html =
view
|> form("#membership-fee-type-form", %{
"membership_fee_type[name]" => "",
"membership_fee_type[amount]" => ""
})
|> render_submit()
# Should show validation errors
assert html =~ "can't be blank" || html =~ "darf nicht leer sein" || html =~ "required"
end
end
describe "permissions" do
test "only admin can access", %{conn: conn} do
# This test assumes non-admin users cannot access
{:ok, _view, html} = live(conn, "/membership_fee_types/new")
# Should show the form (admin user in setup)
assert html =~ "Membership Fee Type" || html =~ "Beitragsart"
end
end
end

View file

@ -0,0 +1,151 @@
defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
@moduledoc """
Tests for membership fee types list view.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "list display" do
test "displays all membership fee types with correct data", %{conn: conn} do
_fee_type1 =
create_fee_type(%{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly})
_fee_type2 =
create_fee_type(%{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly})
{:ok, _view, html} = live(conn, "/membership_fee_types")
assert html =~ "Regular"
assert html =~ "Reduced"
assert html =~ "60" || html =~ "60,00"
assert html =~ "30" || html =~ "30,00"
assert html =~ "Yearly" || html =~ "Jährlich"
end
test "member count column shows correct count", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# Create 3 members with this fee type
Enum.each(1..3, fn _ ->
create_member(%{membership_fee_type_id: fee_type.id})
end)
{:ok, _view, html} = live(conn, "/membership_fee_types")
assert html =~ "3" || html =~ "Members" || html =~ "Mitglieder"
end
test "create button navigates to form", %{conn: conn} do
{:ok, view, _html} = live(conn, "/membership_fee_types")
{:error, {:live_redirect, %{to: to}}} =
view
|> element("a[href='/membership_fee_types/new']")
|> render_click()
assert to == "/membership_fee_types/new"
end
test "edit button per row navigates to edit form", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
{:ok, view, _html} = live(conn, "/membership_fee_types")
{:error, {:live_redirect, %{to: to}}} =
view
|> element("a[href='/membership_fee_types/#{fee_type.id}/edit']")
|> render_click()
assert to == "/membership_fee_types/#{fee_type.id}/edit"
end
end
describe "delete functionality" do
test "delete button disabled if type is in use", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
create_member(%{membership_fee_type_id: fee_type.id})
{:ok, _view, html} = live(conn, "/membership_fee_types")
# Delete button should be disabled
assert html =~ "disabled" || html =~ "cursor-not-allowed"
end
test "delete button works if type is not in use", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# No members assigned
{:ok, view, _html} = live(conn, "/membership_fee_types")
# Delete button should be enabled
view
|> element("button[phx-click='delete'][phx-value-id='#{fee_type.id}']")
|> render_click()
# Type should be deleted
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
Ash.get(MembershipFeeType, fee_type.id, domain: Mv.MembershipFees)
end
end
describe "permissions" do
test "only admin can access", %{conn: conn} do
# This test assumes non-admin users cannot access
# Adjust based on actual permission implementation
{:ok, _view, html} = live(conn, "/membership_fee_types")
# Should show the page (admin user in setup)
assert html =~ "Membership Fee Types" || html =~ "Beitragsarten"
end
end
end

View file

@ -0,0 +1,167 @@
defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
@moduledoc """
Tests for membership fee type dropdown in member form.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "membership fee type dropdown" do
test "displays in form", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members/new")
# Should show membership fee type dropdown
assert html =~ "membership_fee_type_id" || html =~ "Membership Fee Type" ||
html =~ "Beitragsart"
end
test "shows available types", %{conn: conn} do
_fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
_fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
{:ok, _view, html} = live(conn, "/members/new")
assert html =~ "Type 1"
assert html =~ "Type 2"
end
test "filters to same interval types if member has type", %{conn: conn} do
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
_monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
# Should show yearly type but not monthly
assert html =~ "Yearly Type"
refute html =~ "Monthly Type"
end
test "shows warning if different interval selected", %{conn: conn} do
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
# Monthly type should not be in the dropdown (filtered by interval)
refute html =~ monthly_type.id
# Only yearly types should be available
assert html =~ yearly_type.id
end
test "warning cleared if same interval selected", %{conn: conn} do
yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly})
yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Select another yearly type (should not show warning)
html =
view
|> form("#member-form", %{"member[membership_fee_type_id]" => yearly_type2.id})
|> render_change()
refute html =~ "Warning" || html =~ "Warnung"
end
test "form saves with selected membership fee type", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
{:ok, view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "Test",
"member[last_name]" => "Member",
"member[email]" => "test#{System.unique_integer([:positive])}@example.com",
"member[membership_fee_type_id]" => fee_type.id
}
{:error, {:live_redirect, %{to: _to}}} =
view
|> form("#member-form", form_data)
|> render_submit()
# Verify member was created with fee type
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!()
assert member.membership_fee_type_id == fee_type.id
end
test "new members get default membership fee type", %{conn: conn} do
# Set default fee type in settings
fee_type = create_fee_type(%{interval: :yearly})
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
{:ok, view, _html} = live(conn, "/members/new")
# Form should have default fee type selected
html = render(view)
assert html =~ fee_type.name || html =~ "selected"
end
end
end

View file

@ -0,0 +1,362 @@
defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
@moduledoc """
Tests for MembershipFeeStatus helper module.
"""
use Mv.DataCase, async: false
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
# Note: Does not delete existing cycles - tests should manage their own test data
# If cleanup is needed, it should be done in setup or explicitly in the test
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "load_cycles_for_members/2" do
test "efficiently loads cycles for members" do
fee_type = create_fee_type(%{interval: :yearly})
member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
query =
Member
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|> MembershipFeeStatus.load_cycles_for_members()
members = Ash.read!(query)
assert length(members) == 2
# Verify cycles are loaded
member1_loaded = Enum.find(members, &(&1.id == member1.id))
member2_loaded = Enum.find(members, &(&1.id == member2.id))
assert member1_loaded.membership_fee_cycles != nil
assert member2_loaded.membership_fee_cycles != nil
end
end
describe "get_cycle_status_for_member/2" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type to avoid auto-generation
member = create_member(%{})
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
# Create cycles with dates that ensure 2023 is last completed
# Use a fixed "today" date in 2024 to make 2023 the last completed
create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# Load cycles with membership_fee_type relationship
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
# Use fixed date in 2024 to ensure 2023 is last completed
# We need to manually set the date for the helper function
# Since get_cycle_status_for_member doesn't take a date, we need to ensure
# the cycles are properly loaded with their fee_type relationship
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
# The status depends on what Date.utc_today() returns
# If we're in 2024 or later, 2023 should be last completed
# If we're still in 2023, 2022 would be last completed
# For this test, we'll just verify it returns a valid status
assert status in [:paid, :unpaid, :suspended, nil]
end
test "returns status of current cycle when show_current is true" do
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type to avoid auto-generation
member = create_member(%{})
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
# Create cycles - use current year for current cycle
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
last_year_start = %{current_year_start | year: current_year_start.year - 1}
create_cycle(member, fee_type, %{cycle_start: last_year_start, status: :paid})
create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
# Load cycles with membership_fee_type relationship
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
# Should return status of current cycle
assert status == :suspended
end
test "returns nil if no cycles exist" do
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type to avoid auto-generation
member = create_member(%{})
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
# Load cycles and fee type first (will be empty)
member =
member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
assert status == nil
end
end
describe "format_cycle_status_badge/1" do
test "returns badge component for paid status" do
result = MembershipFeeStatus.format_cycle_status_badge(:paid)
assert result.color == "badge-success"
assert result.icon == "hero-check-circle"
assert result.label == "Paid" || result.label == "Bezahlt"
end
test "returns badge component for unpaid status" do
result = MembershipFeeStatus.format_cycle_status_badge(:unpaid)
assert result.color == "badge-error"
assert result.icon == "hero-x-circle"
assert result.label == "Unpaid" || result.label == "Unbezahlt"
end
test "returns badge component for suspended status" do
result = MembershipFeeStatus.format_cycle_status_badge(:suspended)
assert result.color == "badge-ghost"
assert result.icon == "hero-pause-circle"
assert result.label == "Suspended" || result.label == "Ausgesetzt"
end
test "handles nil status gracefully" do
result = MembershipFeeStatus.format_cycle_status_badge(nil)
assert result == nil
end
end
describe "filter_members_by_cycle_status/3" do
test "filters paid members in last cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
assert length(filtered) == 1
assert List.first(filtered).id == member1.id
end
test "filters unpaid members in last cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
end
test "filters paid members in current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
assert length(filtered) == 1
assert List.first(filtered).id == member1.id
end
test "filters unpaid members in current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
end
test "returns all members when filter is nil" do
fee_type = create_fee_type(%{interval: :yearly})
member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = create_member(%{membership_fee_type_id: fee_type.id})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
# filter_unpaid_members should still work for backwards compatibility
filtered = MembershipFeeStatus.filter_unpaid_members(members, false)
# Both members have no cycles, so both should be filtered out
assert Enum.empty?(filtered)
end
end
end

View file

@ -52,14 +52,11 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
field: field
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
{:ok, view, _html} = live(conn, "/members")
# Check that the sort button has aria-label
assert html =~ ~r/aria-label=["']Click to sort["']/i or
html =~ ~r/aria-label=["'].*sort.*["']/i
# Check that data-testid is present for testing
assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/
# Check that the sort button has aria-label and data-testid
test_id = "custom_field_#{field.id}"
assert has_element?(view, "[data-testid='#{test_id}'][aria-label='Click to sort']")
end
test "sort header component shows correct ARIA label when sorted ascending", %{
@ -71,10 +68,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
html = render(view)
# Check that aria-label indicates ascending sort
assert html =~ ~r/aria-label=["'].*ascending.*["']/i
# Check that aria-label indicates ascending sort using data-testid
test_id = "custom_field_#{field.id}"
assert has_element?(view, "[data-testid='#{test_id}'][aria-label='ascending']")
end
test "sort header component shows correct ARIA label when sorted descending", %{
@ -86,21 +82,21 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
html = render(view)
# Check that aria-label indicates descending sort
assert html =~ ~r/aria-label=["'].*descending.*["']/i
# Check that aria-label indicates descending sort using data-testid
test_id = "custom_field_#{field.id}"
assert has_element?(view, "[data-testid='#{test_id}'][aria-label='descending']")
end
test "custom field column header is keyboard accessible", %{conn: conn, field: field} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
{:ok, view, _html} = live(conn, "/members")
# Check that the sort button is a button element (keyboard accessible)
assert html =~ ~r/<button[^>]*data-testid=["']custom_field_#{field.id}["']/
test_id = "custom_field_#{field.id}"
assert has_element?(view, "button[data-testid='#{test_id}']")
# Button should not have tabindex="-1" (which would remove from tab order)
refute html =~ ~r/tabindex=["']-1["']/
refute has_element?(view, "button[data-testid='#{test_id}'][tabindex='-1']")
end
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do

View file

@ -10,7 +10,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
- Integration with member list display
- Custom fields visibility
"""
use MvWeb.ConnCase, async: true
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query

View file

@ -0,0 +1,261 @@
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
@moduledoc """
Tests for membership fee status column in member list view.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "status column display" do
test "shows status column in member list", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, _view, html} = live(conn, "/members")
# Should show membership fee status column
assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
end
test "shows last completed cycle status by default", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members")
# Should show unpaid status (2023 is last completed)
html = render(view)
assert html =~ "hero-x-circle" || html =~ "unpaid"
end
test "toggle switches to current cycle view", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
{:ok, view, _html} = live(conn, "/members")
# Toggle to current cycle (use the button in the header, not the one in the column)
view
|> element("button[phx-click='toggle_cycle_view'].btn.gap-2")
|> render_click()
html = render(view)
# Should show suspended status (current cycle)
assert html =~ "hero-pause-circle" || html =~ "suspended"
end
test "shows correct color coding for paid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-success" || html =~ "hero-check-circle"
end
test "shows correct color coding for unpaid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-error" || html =~ "hero-x-circle"
end
test "shows correct color coding for suspended status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :suspended})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
end
test "handles members without cycles gracefully", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# No cycles created
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Should not crash, may show empty or default state
assert html =~ member.first_name
end
end
describe "filters" do
test "filter unpaid in last cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# Member with unpaid last cycle
member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# Member with paid last cycle
member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!()
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!()
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
assert html =~ "UnpaidMember"
refute html =~ "PaidMember"
end
test "filter unpaid in current cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
# Member with unpaid current cycle
member1 = create_member(%{first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :unpaid})
# Member with paid current cycle
member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :paid})
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!()
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!()
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
assert html =~ "UnpaidCurrent"
refute html =~ "PaidCurrent"
end
end
describe "performance" do
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# Create multiple members with cycles
Enum.each(1..5, fn _ ->
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
end)
{:ok, _view, html} = live(conn, "/members")
# Should render without errors (N+1 would cause performance issues)
assert html =~ "Members" || html =~ "Mitglieder"
end
end
end

View file

@ -51,7 +51,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> render_submit()
|> follow_redirect(conn, "/members")
assert has_element?(index_view, "#flash-group", "Mitglied erstellt erfolgreich")
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
end
test "shows translated flash message after creating a member in English", %{conn: conn} do
@ -71,7 +71,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> render_submit()
|> follow_redirect(conn, "/members")
assert has_element?(index_view, "#flash-group", "Member create successfully")
assert has_element?(index_view, "#flash-group", "Member created successfully")
end
describe "sorting integration" do
@ -410,15 +410,17 @@ defmodule MvWeb.MemberLive.IndexTest do
assert render(view) =~ "1"
end
test "copy button is not visible when no members are selected", %{conn: conn} do
test "copy button is disabled when no members selected", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Ensure no members are selected (default state)
refute has_element?(view, "#copy-emails-btn")
# Copy button should be disabled (button element)
assert has_element?(view, "#copy-emails-btn[disabled]")
# Open email button should be disabled (link with tabindex and aria-disabled)
assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']")
end
test "copy button is visible when members are selected", %{
test "copy button is enabled after selection", %{
conn: conn,
member1: member1
} do
@ -428,8 +430,13 @@ defmodule MvWeb.MemberLive.IndexTest do
# Select a member by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
# Button should now be visible
assert has_element?(view, "#copy-emails-btn")
# Copy button should now be enabled (no disabled attribute)
refute has_element?(view, "#copy-emails-btn[disabled]")
# Open email button should now be enabled (no tabindex=-1 or aria-disabled)
refute has_element?(view, "#open-email-btn[tabindex='-1']")
refute has_element?(view, "#open-email-btn[aria-disabled='true']")
# Counter should show correct count
assert render(view) =~ "1"
end
test "copy button click triggers event and shows flash", %{
@ -450,220 +457,204 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
describe "payment filter integration" do
setup do
# Create members with different payment status
# Use unique names that won't appear elsewhere in the HTML
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Zahler",
last_name: "Mitglied",
email: "zahler@example.com",
paid: true
describe "cycle status filter" do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Nichtzahler",
last_name: "Mitglied",
email: "nichtzahler@example.com",
paid: false
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
{:ok, nil_paid_member} =
Mv.Membership.create_member(%{
first_name: "Unbestimmt",
last_name: "Mitglied",
email: "unbestimmt@example.com"
# paid is nil by default
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
assert html =~ "PaidLast"
refute html =~ "UnpaidLast"
end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
refute html =~ "PaidLast"
assert html =~ "UnpaidLast"
end
test "filter shows all members when no filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
test "filter shows only members with paid status in current cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
# Member with paid current cycle
paid_member =
create_member(%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
assert html =~ "PaidCurrent"
refute html =~ "UnpaidCurrent"
end
test "filter shows only paid members when paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
assert html =~ paid_member.first_name
refute html =~ unpaid_member.first_name
refute html =~ nil_paid_member.first_name
# Member with paid current cycle
paid_member =
create_member(%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
refute html =~ "PaidCurrent"
assert html =~ "UnpaidCurrent"
end
test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
refute html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter combines with search query (AND)", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
assert html =~ paid_member.first_name
end
test "filter combines with sorting", %{conn: conn} do
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
# Start with last cycle view and paid filter
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Click on email sort header
# Toggle to current cycle - this should update URL and preserve filter
# Use the button in the toolbar
view
|> element("[data-testid='email']")
|> element("button[phx-click='toggle_cycle_view']")
|> render_click()
# Filter should be preserved in URL
# Wait for patch to complete
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "sort_field=email"
end
test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "URL parameter is correctly read on page load", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
# Only paid member should be visible
assert html =~ paid_member.first_name
# Filter badge should be visible
assert html =~ "badge"
end
test "invalid URL parameter is ignored", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
# All members should be visible (filter not applied)
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
end
test "search maintains filter state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{"query" => "test"})
# Filter state should be maintained in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
end
describe "paid column in table" do
setup do
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Paid",
last_name: "Member",
email: "paid.column@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Unpaid",
last_name: "Member",
email: "unpaid.column@example.com",
paid: false
})
%{paid_member: paid_member, unpaid_member: unpaid_member}
end
test "paid column shows green badge for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for success badge (green)
assert html =~ "badge-success"
end
test "paid column shows red badge for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for error badge (red)
assert html =~ "badge-error"
end
test "paid column shows 'Yes' for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "Yes" text inside badge
assert html =~ "badge-success"
assert html =~ "Yes"
end
test "paid column shows 'No' for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "No" text inside badge
assert html =~ "badge-error"
assert html =~ "No"
# URL should contain both filter and show_current_cycle
assert path =~ "cycle_status_filter=paid"
assert path =~ "show_current_cycle=true"
end
end
end

View file

@ -0,0 +1,237 @@
defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
@moduledoc """
Integration tests for membership fee UI workflows.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "end-to-end workflows" do
test "create type → assign to member → view cycles → change status", %{conn: conn} do
# Create type
fee_type = create_fee_type(%{name: "Regular", interval: :yearly})
# Assign to member
member = create_member(%{membership_fee_type_id: fee_type.id})
# View cycles
{:ok, view, html} = live(conn, "/members/#{member.id}")
assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
# Get a cycle
cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
if !Enum.empty?(cycles) do
cycle = List.first(cycles)
# Switch to Membership Fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Change status
view
|> element("button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}']")
|> render_click()
# Verify status changed
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :paid
end
end
test "change member type → cycles regenerate", %{conn: conn} do
fee_type1 =
create_fee_type(%{name: "Type 1", interval: :yearly, amount: Decimal.new("50.00")})
fee_type2 =
create_fee_type(%{name: "Type 2", interval: :yearly, amount: Decimal.new("75.00")})
member = create_member(%{membership_fee_type_id: fee_type1.id})
# Change type
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
view
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
|> render_submit()
# Verify cycles regenerated with new amount
cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid)
|> Ash.read!()
# Future unpaid cycles should have new amount
Enum.each(cycles, fn cycle ->
if Date.compare(cycle.cycle_start, Date.utc_today()) != :lt do
assert Decimal.equal?(cycle.amount, fee_type2.amount)
end
end)
end
test "update settings → new members get default type", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# Update settings
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
# Create new member
{:ok, view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "New",
"member[last_name]" => "Member",
"member[email]" => "new#{System.unique_integer([:positive])}@example.com"
}
{:error, {:live_redirect, %{to: _to}}} =
view
|> form("#member-form", form_data)
|> render_submit()
# Verify member got default type
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!()
assert member.membership_fee_type_id == fee_type.id
end
test "delete cycle → confirmation → cycle deleted", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to Membership Fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Delete cycle with confirmation
view
|> element("button[phx-click='delete_cycle'][phx-value-cycle_id='#{cycle.id}']")
|> render_click()
# Confirm deletion
view
|> element("button[phx-click='confirm_delete_cycle'][phx-value-cycle_id='#{cycle.id}']")
|> render_click()
# Verify cycle deleted - Ash.read_one returns {:ok, nil} if not found
result = MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id) |> Ash.read_one()
assert result == {:ok, nil}
end
test "edit cycle amount → modal → amount updated", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to Membership Fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Open edit modal by clicking on the amount span
view
|> element("span[phx-click='edit_cycle_amount'][phx-value-cycle_id='#{cycle.id}']")
|> render_click()
# Update amount
view
|> form("form[phx-submit='save_cycle_amount']", %{"amount" => "75.00"})
|> render_submit()
# Verify amount updated
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.amount == Decimal.new("75.00")
end
end
end

View file

@ -0,0 +1,270 @@
defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
@moduledoc """
Tests for membership fees section in member detail view.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "cycles table display" do
test "displays all cycles for member", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
_cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
_cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to membership fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
html = render(view)
# Should show cycles table
assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
# Check for formatted cycle dates (e.g., "01.01.2022" or "2022")
assert html =~ "2022" || html =~ "2023" || html =~ "01.01.2022" || html =~ "01.01.2023"
end
test "table columns show correct data", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("60.00"),
status: :paid
})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to membership fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
html = render(view)
# Should show interval, amount, status
assert html =~ "Yearly" || html =~ "Jährlich"
assert html =~ "60" || html =~ "60,00"
assert html =~ "paid" || html =~ "bezahlt"
end
end
describe "membership fee type display" do
test "shows assigned membership fee type", %{conn: conn} do
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
_monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should show yearly type name
assert html =~ "Yearly Type"
end
test "shows no type message when no type assigned", %{conn: conn} do
member = create_member(%{})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should show message about no type assigned
assert html =~ "No membership fee type assigned" || html =~ "No type"
end
end
describe "status change actions" do
test "mark as paid works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to membership fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Mark as paid
view
|> element(
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='paid']"
)
|> render_click()
# Verify cycle is now paid
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :paid
end
test "mark as suspended works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to membership fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Mark as suspended
view
|> element(
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='suspended']"
)
|> render_click()
# Verify cycle is now suspended
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :suspended
end
test "mark as unpaid works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to membership fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Mark as unpaid
view
|> element(
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='unpaid']"
)
|> render_click()
# Verify cycle is now unpaid
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :unpaid
end
end
describe "cycle regeneration" do
test "manual regeneration button exists and can be clicked", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Switch to membership fees tab
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
# Verify regenerate button exists
assert has_element?(view, "button[phx-click='regenerate_cycles']")
# Trigger regeneration (just verify it doesn't crash)
view
|> element("button[phx-click='regenerate_cycles']")
|> render_click()
# Verify the action completed without error
# (The actual cycle generation depends on many factors, so we just test the UI works)
assert render(view) =~ "Membership Fees" || render(view) =~ "Mitgliedsbeiträge"
end
end
describe "edge cases" do
test "handles members without membership fee type gracefully", %{conn: conn} do
# No fee type
member = create_member(%{})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should not crash
assert html =~ member.first_name
end
end
end

View file

@ -0,0 +1,175 @@
defmodule MvWeb.MemberLive.ShowTest do
@moduledoc """
Tests for the member show page.
Tests cover:
- Displaying member information
- Custom Fields section visibility (Issue #282 regression test)
- Custom field values formatting
## Note on async: false
Tests use `async: false` (not `async: true`) to prevent PostgreSQL deadlocks
when creating members and custom fields concurrently. This is intentional and
documented here to avoid confusion in commit messages.
"""
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
%{member: member}
end
describe "custom fields section visibility (Issue #282)" do
test "displays Custom Fields section even when member has no custom field values", %{
conn: conn,
member: member
} do
# Create a custom field but no value for the member
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "phone_mobile",
value_type: :string
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
# Custom Fields section should be visible
assert html =~ gettext("Custom Fields")
# Custom field label should be visible
assert html =~ custom_field.name
# Value should show placeholder for empty value
assert html =~ "" or html =~ gettext("Not set")
end
test "displays Custom Fields section with multiple custom fields, some without values", %{
conn: conn,
member: member
} do
# Create multiple custom fields
{:ok, field1} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "phone_mobile",
value_type: :string
})
|> Ash.create()
{:ok, field2} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :integer
})
|> Ash.create()
# Create value only for first field
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field1.id,
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
# Custom Fields section should be visible
assert html =~ gettext("Custom Fields")
# Both field labels should be visible
assert html =~ field1.name
assert html =~ field2.name
# First field should show value
assert html =~ "+49123456789"
# Second field should show placeholder
assert html =~ "" or html =~ gettext("Not set")
end
test "does not display Custom Fields section when no custom fields exist", %{
conn: conn,
member: member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
# Custom Fields section should NOT be visible
refute html =~ gettext("Custom Fields")
end
end
describe "custom field value formatting" do
test "formats string custom field values", %{conn: conn, member: member} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "phone_mobile",
value_type: :string
})
|> Ash.create()
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
assert html =~ "+49123456789"
end
test "formats email custom field values as mailto links", %{conn: conn, member: member} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "private_email",
value_type: :email
})
|> Ash.create()
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "email", "_union_value" => "private@example.com"}
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
# Should contain mailto link
assert html =~ ~s(href="mailto:private@example.com")
assert html =~ "private@example.com"
end
end
end

View file

@ -1,5 +1,6 @@
defmodule MvWeb.UserLive.FormTest do
use MvWeb.ConnCase, async: true
# async: false to prevent PostgreSQL deadlocks when creating members and users
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
# Helper to setup authenticated connection and live view

View file

@ -1,6 +1,8 @@
defmodule Mv.SeedsTest do
use Mv.DataCase, async: false
require Ash.Query
describe "Seeds script" do
test "runs successfully without errors" do
# Run the seeds script - should not raise any errors
@ -11,9 +13,9 @@ defmodule Mv.SeedsTest do
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField)
assert length(users) > 0, "Seeds should create at least one user"
assert length(members) > 0, "Seeds should create at least one member"
assert length(custom_fields) > 0, "Seeds should create at least one custom field"
assert not Enum.empty?(users), "Seeds should create at least one user"
assert not Enum.empty?(members), "Seeds should create at least one member"
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field"
end
test "can be run multiple times (idempotent)" do
@ -42,5 +44,76 @@ defmodule Mv.SeedsTest do
assert length(custom_fields_count_1) == length(custom_fields_count_2),
"CustomFields count should remain same after re-running seeds"
end
test "at least one member has no membership fee type assigned" do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members
{:ok, members} = Ash.read(Mv.Membership.Member)
# At least one member should have no membership_fee_type_id
members_without_fee_type =
Enum.filter(members, fn member -> member.membership_fee_type_id == nil end)
assert not Enum.empty?(members_without_fee_type),
"At least one member should have no membership fee type assigned"
end
test "each membership fee type has at least one member" do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all fee types and members
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType)
{:ok, members} = Ash.read(Mv.Membership.Member)
# Group members by fee type (excluding nil)
members_by_fee_type =
members
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|> Enum.group_by(& &1.membership_fee_type_id)
# Each fee type should have at least one member
Enum.each(fee_types, fn fee_type ->
members_for_type = Map.get(members_by_fee_type, fee_type.id, [])
assert not Enum.empty?(members_for_type),
"Membership fee type #{fee_type.name} should have at least one member assigned"
end)
end
test "members with fee types have cycles with various statuses" do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members with fee types
{:ok, members} = Ash.read(Mv.Membership.Member)
members_with_fee_types =
members
|> Enum.filter(&(&1.membership_fee_type_id != nil))
# At least one member should have cycles
assert not Enum.empty?(members_with_fee_types),
"At least one member should have a membership fee type"
# Check that cycles exist and have various statuses
all_cycle_statuses =
members_with_fee_types
|> Enum.flat_map(fn member ->
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
end)
|> Enum.map(& &1.status)
# At least one cycle should be paid
assert :paid in all_cycle_statuses, "At least one cycle should be paid"
# At least one cycle should be unpaid
assert :unpaid in all_cycle_statuses, "At least one cycle should be unpaid"
# At least one cycle should be suspended
assert :suspended in all_cycle_statuses, "At least one cycle should be suspended"
end
end
end