defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do @moduledoc """ Tests for ValidateSameInterval change module. """ use Mv.DataCase, async: true alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.Changes.ValidateSameInterval setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() %{actor: system_actor} end # Helper to create a membership fee type defp create_fee_type(attrs, actor) 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!(actor: actor) end # Helper to create a member defp create_member(attrs, actor) do default_attrs = %{ first_name: "Test", last_name: "Member", email: "test.member.#{System.unique_integer([:positive])}@example.com" } attrs = Map.merge(default_attrs, attrs) {:ok, member} = Mv.Membership.create_member(attrs, actor: actor) member end describe "validate_interval_match/1" do test "allows change to type with same interval", %{actor: actor} do yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}, actor) yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"}, actor) member = create_member(%{membership_fee_type_id: yearly_type1.id}, actor) changeset = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id}, actor: actor ) |> ValidateSameInterval.change(%{}, %{}) assert changeset.valid? end test "prevents change to type with different interval", %{actor: actor} do yearly_type = create_fee_type(%{interval: :yearly}, actor) monthly_type = create_fee_type(%{interval: :monthly}, actor) member = create_member(%{membership_fee_type_id: yearly_type.id}, actor) changeset = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id}, actor: actor ) |> 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", %{actor: actor} do yearly_type = create_fee_type(%{interval: :yearly}, actor) # No fee type assigned member = create_member(%{}, actor) changeset = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id}, actor: actor ) |> ValidateSameInterval.change(%{}, %{}) assert changeset.valid? end test "prevents removal of membership fee type", %{actor: actor} do yearly_type = create_fee_type(%{interval: :yearly}, actor) member = create_member(%{membership_fee_type_id: yearly_type.id}, actor) changeset = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor) |> 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", %{actor: actor} do yearly_type = create_fee_type(%{interval: :yearly}, actor) member = create_member(%{membership_fee_type_id: yearly_type.id}, actor) changeset = member |> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor) |> ValidateSameInterval.change(%{}, %{}) assert changeset.valid? end test "error message is clear and helpful", %{actor: actor} do yearly_type = create_fee_type(%{interval: :yearly}, actor) quarterly_type = create_fee_type(%{interval: :quarterly}, actor) member = create_member(%{membership_fee_type_id: yearly_type.id}, actor) changeset = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id}, actor: actor ) |> 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", %{actor: actor} 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])}" }, actor ) type2 = create_fee_type( %{ interval: interval2, name: "Type #{interval2} #{System.unique_integer([:positive])}" }, actor ) member = create_member(%{membership_fee_type_id: type1.id}, actor) changeset = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id}, actor: actor ) |> 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", %{actor: actor} do yearly_type = create_fee_type(%{interval: :yearly}, actor) monthly_type = create_fee_type(%{interval: :monthly}, actor) member = create_member(%{membership_fee_type_id: yearly_type.id}, actor) # Try to update member with different interval type assert {:error, %Ash.Error.Invalid{} = error} = Mv.Membership.update_member(member, %{membership_fee_type_id: monthly_type.id}, actor: actor ) # 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", %{actor: actor} do yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}, actor) yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"}, actor) member = create_member(%{membership_fee_type_id: yearly_type1.id}, actor) # Update member with same-interval type assert {:ok, updated_member} = Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id}, actor: actor ) 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