Merge pull request 'fix existing flakiness + cut runtime closes #533' (#544) from issue/mitgliederverwaltung-533 into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #544
This commit is contained in:
commit
a629bfb617
39 changed files with 664 additions and 735 deletions
|
|
@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **Column-header tooltips clipped** – Tooltips on the members-overview column headers are no longer clipped by the sticky table header.
|
||||
- **Text selection opens member** – Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them.
|
||||
- **Sort by custom date** – Sorting the member list or member export by a custom date field now orders rows chronologically instead of like text, so e.g. 29.01.1981 correctly comes before 01.03.1982.
|
||||
- **Concurrent member creation no longer deadlocks** – Creating members in parallel (e.g. simultaneous sign-ups, or batch operations) could intermittently fail with a PostgreSQL deadlock; the affected foreign keys are now deferrable, so concurrent member creation succeeds reliably.
|
||||
|
||||
## [1.2.0] - 2026-05-08
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,29 @@ test/
|
|||
|
||||
---
|
||||
|
||||
## 5. Concurrent `create_member` deadlock and deferrable FKs
|
||||
|
||||
A class of intermittent failures (PostgreSQL `deadlock_detected`, SQLSTATE `40P01`) was traced to **concurrent `create_member` transactions**, not to any single test. It surfaced as a `MatchError` on `{:ok, member} = ...` in member-heavy LiveView tests (e.g. `FormMemberSelectionTest`) and reproduced only under CPU contention (≈1 in 12 full-fast-suite runs at high `async: true` concurrency; effectively never on an idle machine).
|
||||
|
||||
**Root cause.** `create_member` writes a cascade in one transaction (member row, `custom_field_values`, the `user` link, fee-type defaulting, cycle generation). Concurrent inserts take FK `FOR KEY SHARE` (MultiXact) locks on shared parent rows across `members` / `users` / `membership_fee_types`; under contention these can form a cross-transaction lock cycle that Postgres resolves by aborting one transaction. It is a product-level concurrency property, **not** test-data contention, so it is not fixable by test-state isolation.
|
||||
|
||||
**Fix.** Migration `…_make_member_user_fks_deferrable.exs` makes the three FKs (`users.member_id`, `users.role_id`, `members.membership_fee_type_id`) `DEFERRABLE INITIALLY DEFERRED`, moving the FK check (and its lock) to commit time and breaking the cycle. Verified: **0 deadlocks in 15 full-suite runs under maximum CPU contention**, versus 1/12 before. This does **not** weaken integrity — `NOT NULL` is independent of FK deferral, a real dangling reference still aborts the commit, and `ON DELETE RESTRICT` (e.g. `users.role_id`) stays immediate regardless of deferrability. `Mv.DeferrableFkTest` asserts the constraint state as a regression guard (a deterministic in-process concurrent reproduction is infeasible under the Ecto sandbox, which serializes connections by ownership).
|
||||
|
||||
This deadlock is also a latent **production** risk under concurrent sign-ups; the deferrable-FK fix addresses both.
|
||||
|
||||
### Async-test-safety checklist (members/groups/custom fields)
|
||||
|
||||
Several member-creating test files historically used `async: false` with a "prevent PostgreSQL deadlocks" comment. With the deferrable-FK migration in place those files are deadlock-safe, but before flipping any such file to `async: true`:
|
||||
|
||||
- **Prove isolation under load, not just one green run.** Re-run the file (and the full suite) under varying `--seed` **and** CPU contention; a single green run is not evidence (the deadlock and the isolation flakes below are load-dependent).
|
||||
- **Watch for separate async-isolation issues beyond the deadlock.** `index_groups_url_params_test.exs` and `member_filter_component_test.exs` showed filtered-member-leak failures (`refute html =~ name`) under concurrency that are independent of the FK deadlock — these need their own per-file isolation fix before they can run async.
|
||||
|
||||
### StreamData generator pitfall
|
||||
|
||||
`FilterTooNarrowError` appeared on unlucky seeds (e.g. 222) in a property test that built a value with a reject-filter (`StreamData.filter` discarding ~1/4 of generated pairs). Under full property-run counts this hits too many consecutive rejections. Fix: **construct the desired value directly** instead of generating-then-filtering (preserves the exact domain, no rejection). Prefer constructive generators over reject-filters in property tests.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Testing Standards: `CODE_GUIDELINES.md` §4
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
defmodule Mv.Repo.Migrations.MakeMemberUserFksDeferrable do
|
||||
@moduledoc """
|
||||
Makes the members/users foreign keys DEFERRABLE INITIALLY DEFERRED.
|
||||
|
||||
Concurrent `create_member` transactions take FK `FOR KEY SHARE` (MultiXact)
|
||||
locks on these foreign keys at statement time and can form a cross-transaction
|
||||
lock cycle, producing a PostgreSQL `deadlock_detected` (40P01). Deferring the
|
||||
FK checks to commit time breaks the cycle.
|
||||
|
||||
Constraint deferrability is not tracked by AshPostgres resource snapshots, so
|
||||
this is a hand-written migration with raw `execute/2`. Do not regenerate it
|
||||
via `mix ash_postgres.generate_migrations`.
|
||||
"""
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute(
|
||||
"ALTER TABLE users ALTER CONSTRAINT users_member_id_fkey DEFERRABLE INITIALLY DEFERRED",
|
||||
"ALTER TABLE users ALTER CONSTRAINT users_member_id_fkey NOT DEFERRABLE"
|
||||
)
|
||||
|
||||
execute(
|
||||
"ALTER TABLE users ALTER CONSTRAINT users_role_id_fkey DEFERRABLE INITIALLY DEFERRED",
|
||||
"ALTER TABLE users ALTER CONSTRAINT users_role_id_fkey NOT DEFERRABLE"
|
||||
)
|
||||
|
||||
execute(
|
||||
"ALTER TABLE members ALTER CONSTRAINT members_membership_fee_type_id_fkey DEFERRABLE INITIALLY DEFERRED",
|
||||
"ALTER TABLE members ALTER CONSTRAINT members_membership_fee_type_id_fkey NOT DEFERRABLE"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -4,30 +4,15 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
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 = %{
|
||||
|
|
@ -41,23 +26,6 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
|||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs, actor) 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!(actor: actor)
|
||||
end
|
||||
|
||||
describe "current_cycle_status" do
|
||||
test "returns status of current cycle for member with active cycle", %{actor: actor} do
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -15,21 +16,6 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
%{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 = %{
|
||||
|
|
@ -44,23 +30,6 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs, actor) 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!(actor: actor)
|
||||
end
|
||||
|
||||
describe "type change cycle regeneration" do
|
||||
test "future unpaid cycles are regenerated with new amount", %{actor: actor} do
|
||||
today = Date.utc_today()
|
||||
|
|
|
|||
|
|
@ -4,29 +4,15 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
alias Mv.MembershipFees.Changes.ValidateSameInterval
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
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 = %{
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -14,21 +15,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
%{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 set up settings
|
||||
defp setup_settings(include_joining_cycle, actor) do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
|
|
|||
|
|
@ -4,29 +4,15 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
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 = %{
|
||||
|
|
@ -40,22 +26,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
|||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs, actor) 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!(actor: actor)
|
||||
end
|
||||
|
||||
describe "status defaults" do
|
||||
test "status defaults to :unpaid when creating a cycle", %{actor: actor} do
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
|
@ -15,21 +17,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
%{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
|
||||
|
||||
describe "admin can create membership fee type" do
|
||||
test "creates type with all fields", %{actor: actor} do
|
||||
attrs = %{
|
||||
|
|
|
|||
|
|
@ -274,17 +274,38 @@ defmodule Mv.Membership.MembersPDFTest do
|
|||
|
||||
assert {:ok, _pdf_binary} = result
|
||||
|
||||
# Wait a bit for cleanup (async cleanup might take a moment)
|
||||
Process.sleep(100)
|
||||
|
||||
# Count temp directories after
|
||||
after_count =
|
||||
count_export_dirs = fn ->
|
||||
temp_base
|
||||
|> File.ls!()
|
||||
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
|
||||
end
|
||||
|
||||
# Poll the observable cleanup condition (temp-dir count returns to the baseline)
|
||||
# with a bounded deadline instead of a fixed sleep, so the test waits no longer
|
||||
# than the cleanup actually needs and still fails if cleanup never runs.
|
||||
after_count = poll_until_cleaned(count_export_dirs, before_count, 100)
|
||||
|
||||
# Should have same or fewer temp dirs (cleanup should have run)
|
||||
assert after_count <= before_count + 1
|
||||
end
|
||||
end
|
||||
|
||||
# Bounded poll: returns the export-dir count once it drops back to the baseline
|
||||
# (cleanup done), or the last observed count when the attempt budget is exhausted
|
||||
# (so the caller's assertion reports the real state on a genuine cleanup stall).
|
||||
defp poll_until_cleaned(count_fun, baseline, attempts_left) do
|
||||
current = count_fun.()
|
||||
|
||||
cond do
|
||||
current <= baseline ->
|
||||
current
|
||||
|
||||
attempts_left <= 0 ->
|
||||
current
|
||||
|
||||
true ->
|
||||
Process.sleep(10)
|
||||
poll_until_cleaned(count_fun, baseline, attempts_left - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -23,21 +24,6 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
%{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. Note: If membership_fee_type_id is provided,
|
||||
# cycles will be auto-generated during creation in test environment.
|
||||
defp create_member(attrs, actor) do
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -15,21 +16,6 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
%{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 without triggering cycle generation
|
||||
defp create_member_without_cycles(attrs, actor) do
|
||||
default_attrs = %{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees
|
||||
|
||||
|
|
@ -32,41 +34,15 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
member
|
||||
end
|
||||
|
||||
defp create_fee_type_fixture do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, fee_type} =
|
||||
MembershipFees.create_membership_fee_type(
|
||||
%{
|
||||
name: "Test Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("10.00"),
|
||||
interval: :yearly,
|
||||
description: "Test"
|
||||
},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
fee_type
|
||||
defp fee_type_fixture do
|
||||
create_fee_type(%{amount: Decimal.new("10.00"), description: "Test"})
|
||||
end
|
||||
|
||||
defp create_cycle_fixture do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
member = create_member_fixture()
|
||||
fee_type = create_fee_type_fixture()
|
||||
|
||||
{:ok, cycle} =
|
||||
MembershipFees.create_membership_fee_cycle(
|
||||
%{
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
cycle_start: Date.utc_today(),
|
||||
amount: Decimal.new("10.00"),
|
||||
status: :unpaid
|
||||
},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
cycle
|
||||
defp cycle_fixture do
|
||||
create_cycle(create_member_fixture(), fee_type_fixture(), %{
|
||||
cycle_start: Date.utc_today(),
|
||||
amount: Decimal.new("10.00")
|
||||
})
|
||||
end
|
||||
|
||||
describe "own_data permission set" do
|
||||
|
|
@ -74,7 +50,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_member_fixture()
|
||||
other_member = create_member_fixture()
|
||||
fee_type = create_fee_type_fixture()
|
||||
fee_type = fee_type_fixture()
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
user =
|
||||
|
|
@ -130,7 +106,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
describe "read_only permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
cycle = create_cycle_fixture()
|
||||
cycle = cycle_fixture()
|
||||
%{actor: actor, user: user, cycle: cycle}
|
||||
end
|
||||
|
||||
|
|
@ -156,7 +132,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
|
||||
test "cannot create cycle (returns forbidden)", %{user: user, actor: _actor} do
|
||||
member = create_member_fixture()
|
||||
fee_type = create_fee_type_fixture()
|
||||
fee_type = fee_type_fixture()
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
MembershipFees.create_membership_fee_cycle(
|
||||
|
|
@ -180,7 +156,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
describe "normal_user permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
cycle = create_cycle_fixture()
|
||||
cycle = cycle_fixture()
|
||||
%{actor: actor, user: user, cycle: cycle}
|
||||
end
|
||||
|
||||
|
|
@ -210,7 +186,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
|
||||
test "can create cycle", %{user: user, actor: _actor} do
|
||||
member = create_member_fixture()
|
||||
fee_type = create_fee_type_fixture()
|
||||
fee_type = fee_type_fixture()
|
||||
|
||||
assert {:ok, created} =
|
||||
MembershipFees.create_membership_fee_cycle(
|
||||
|
|
@ -235,7 +211,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
describe "admin permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
cycle = create_cycle_fixture()
|
||||
cycle = cycle_fixture()
|
||||
%{actor: actor, user: user, cycle: cycle}
|
||||
end
|
||||
|
||||
|
|
@ -270,7 +246,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
|||
|
||||
test "can create cycle", %{user: user, actor: _actor} do
|
||||
member = create_member_fixture()
|
||||
fee_type = create_fee_type_fixture()
|
||||
fee_type = fee_type_fixture()
|
||||
|
||||
assert {:ok, created} =
|
||||
MembershipFees.create_membership_fee_cycle(
|
||||
|
|
|
|||
37
test/mv/repo/deferrable_fk_test.exs
Normal file
37
test/mv/repo/deferrable_fk_test.exs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule Mv.DeferrableFkTest do
|
||||
@moduledoc """
|
||||
Regression guard for the deferrable-FK migration.
|
||||
|
||||
Asserts the schema property directly (`condeferred = true`) for the three
|
||||
members/users foreign keys. A multi-connection deadlock reproduction cannot
|
||||
be made deterministic under the Ecto sandbox (ownership serializes
|
||||
connections), so the structural assertion is the guard here; see the
|
||||
migration moduledoc for the full FOR-KEY-SHARE/MultiXact deadlock rationale.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
@deferrable_fks ~w(
|
||||
users_member_id_fkey
|
||||
users_role_id_fkey
|
||||
members_membership_fee_type_id_fkey
|
||||
)
|
||||
|
||||
test "member/user foreign keys are DEFERRABLE INITIALLY DEFERRED" do
|
||||
query = """
|
||||
SELECT conname, condeferrable, condeferred
|
||||
FROM pg_constraint
|
||||
WHERE conname = ANY($1)
|
||||
"""
|
||||
|
||||
{:ok, %{rows: rows}} = Mv.Repo.query(query, [@deferrable_fks])
|
||||
|
||||
found = Map.new(rows, fn [name, deferrable, deferred] -> {name, {deferrable, deferred}} end)
|
||||
|
||||
for fk <- @deferrable_fks do
|
||||
assert Map.has_key?(found, fk), "expected foreign key #{fk} to exist"
|
||||
|
||||
assert found[fk] == {true, true},
|
||||
"expected #{fk} to be DEFERRABLE INITIALLY DEFERRED, got #{inspect(found[fk])}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -5,11 +5,11 @@ defmodule Mv.StatisticsTest do
|
|||
use Mv.DataCase, async: true
|
||||
|
||||
import Ash.Expr
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Statistics
|
||||
|
||||
require Ash.Query
|
||||
|
|
@ -19,22 +19,6 @@ defmodule Mv.StatisticsTest do
|
|||
%{actor: actor}
|
||||
end
|
||||
|
||||
defp create_fee_type(actor, attrs) do
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(
|
||||
:create,
|
||||
Map.merge(
|
||||
%{
|
||||
name: "Test Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
attrs
|
||||
)
|
||||
)
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
describe "first_join_year/1" do
|
||||
test "returns the year of the earliest join_date", %{actor: actor} do
|
||||
Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]})
|
||||
|
|
@ -132,7 +116,7 @@ defmodule Mv.StatisticsTest do
|
|||
end
|
||||
|
||||
test "returns totals by status for cycles in that year", %{actor: actor} do
|
||||
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")}, actor)
|
||||
|
||||
# Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles.
|
||||
_member1 =
|
||||
|
|
@ -172,8 +156,8 @@ defmodule Mv.StatisticsTest do
|
|||
test "when fee_type_id is passed in opts, returns only cycles of that fee type", %{
|
||||
actor: actor
|
||||
} do
|
||||
fee_type_a = create_fee_type(actor, %{amount: Decimal.new("30.00")})
|
||||
fee_type_b = create_fee_type(actor, %{amount: Decimal.new("70.00")})
|
||||
fee_type_a = create_fee_type(%{amount: Decimal.new("30.00")}, actor)
|
||||
fee_type_b = create_fee_type(%{amount: Decimal.new("70.00")}, actor)
|
||||
|
||||
_m1 =
|
||||
Mv.Fixtures.member_fixture(%{
|
||||
|
|
@ -208,7 +192,7 @@ defmodule Mv.StatisticsTest do
|
|||
end
|
||||
|
||||
test "returns sum of amount for all unpaid cycles", %{actor: actor} do
|
||||
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")}, actor)
|
||||
|
||||
_member =
|
||||
Mv.Fixtures.member_fixture(%{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
- Button label and badge logic
|
||||
- Filtering to show only boolean custom fields
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
|
||||
# Kept async: false. The deferrable-FK migration removed the concurrent
|
||||
# create_member deadlock, but this file additionally showed an async-isolation
|
||||
# failure under load (filtered members from a parallel test leaking in), so it
|
||||
# is not trivially async-safe; resolving that is a separate follow-up.
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
|
|
|||
|
|
@ -37,7 +37,27 @@ defmodule MvWeb.ImportLiveTest do
|
|||
confirm_import(view)
|
||||
end
|
||||
|
||||
defp wait_for_import_completion, do: Process.sleep(1000)
|
||||
# Waits for the asynchronous chunk-import to finish by polling the rendered
|
||||
# results panel, instead of a fixed sleep. In the test sandbox the chunks run
|
||||
# in-process and signal completion via self-messages; each render/2 forces the
|
||||
# LiveView to drain its mailbox before replying, so the panel appears once all
|
||||
# chunk messages have been processed. Bounded so a genuine stall still fails.
|
||||
defp wait_for_import_completion(view) do
|
||||
wait_for_import_completion(view, 200)
|
||||
end
|
||||
|
||||
defp wait_for_import_completion(_view, 0) do
|
||||
flunk("import did not complete: results panel never rendered")
|
||||
end
|
||||
|
||||
defp wait_for_import_completion(view, attempts_left) do
|
||||
if has_element?(view, "[data-testid='import-results-panel']") do
|
||||
:ok
|
||||
else
|
||||
Process.sleep(10)
|
||||
wait_for_import_completion(view, attempts_left - 1)
|
||||
end
|
||||
end
|
||||
|
||||
# ---------- Business logic: Authorization ----------
|
||||
describe "Authorization" do
|
||||
|
|
@ -67,7 +87,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
run_full_import(view, csv_content)
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-summary']")
|
||||
|
|
@ -131,7 +151,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
run_full_import(view, csv_content, "invalid_import.csv")
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
|
|
@ -150,7 +170,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
for i <- 1..100, do: "Row#{i};Last#{i};;Country#{i};City#{i};Street#{i};12345\n"
|
||||
|
||||
run_full_import(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
|
|
@ -182,7 +202,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
|> File.read!()
|
||||
|
||||
run_full_import(view, csv_content, "bom_import.csv")
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
html = render(view)
|
||||
|
|
@ -200,7 +220,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
|> File.read!()
|
||||
|
||||
run_full_import(view, csv_content, "empty_lines.csv")
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
html = render(view)
|
||||
|
|
@ -214,7 +234,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
run_full_import(view, csv_content, "unknown_custom.csv")
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-warnings']")
|
||||
|
|
@ -279,7 +299,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
run_full_import(view, csv_content)
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
assert has_element?(view, "[data-testid='import-progress-container']")
|
||||
html = render(view)
|
||||
assert html =~ "aria-live"
|
||||
|
|
@ -342,7 +362,7 @@ defmodule MvWeb.ImportLiveTest do
|
|||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import")
|
||||
run_full_import(view, csv_content)
|
||||
wait_for_import_completion()
|
||||
wait_for_import_completion(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
|
|||
|
||||
# Generators -----------------------------------------------------------
|
||||
|
||||
defp date_gen do
|
||||
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
|
||||
end
|
||||
|
||||
defp optional_date_gen do
|
||||
one_of([
|
||||
constant(nil),
|
||||
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
|
||||
])
|
||||
one_of([constant(nil), date_gen()])
|
||||
end
|
||||
|
||||
defp exit_date_mode_gen do
|
||||
|
|
@ -57,19 +58,25 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
|
|||
|
||||
# Property -------------------------------------------------------------
|
||||
|
||||
# Generates a {from, to} pair with at least one bound set. Built by construction
|
||||
# (pick which bounds are set, then generate the required dates) rather than by
|
||||
# filtering out the both-nil case, so StreamData never rejects values and cannot
|
||||
# raise FilterTooNarrowError on unlucky seeds.
|
||||
defp bound_pair_with_at_least_one_set_gen do
|
||||
gen all(
|
||||
from <- optional_date_gen(),
|
||||
to <- optional_date_gen(),
|
||||
from != nil or to != nil
|
||||
which <- member_of([:from_only, :to_only, :both]),
|
||||
date_a <- date_gen(),
|
||||
date_b <- date_gen()
|
||||
) do
|
||||
{from, to}
|
||||
case which do
|
||||
:from_only -> {date_a, nil}
|
||||
:to_only -> {nil, date_a}
|
||||
:both -> {date_a, date_b}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp value_date_gen do
|
||||
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
|
||||
end
|
||||
defp value_date_gen, do: date_gen()
|
||||
|
||||
# Property -------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Mv.Fixtures, only: [create_fee_type: 1]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
|
|
@ -17,23 +18,6 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
%{conn: authenticated_conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
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: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
|
|
@ -13,22 +14,6 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
# Use global setup from ConnCase which provides admin user with role
|
||||
# No custom setup needed
|
||||
|
||||
# Helper to create a membership fee type
|
||||
# Uses admin_user to test permissions (UI-/Permissions-nah)
|
||||
defp create_fee_type(attrs, admin_user) 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: admin_user)
|
||||
end
|
||||
|
||||
# Helper to create a member. Requires actor (e.g. admin_user from setup); no fallback so
|
||||
# missing-actor bugs are not masked in tests.
|
||||
defp create_member(attrs, actor) do
|
||||
|
|
|
|||
|
|
@ -5,27 +5,10 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
# Uses admin_user to test permissions (UI-/Permissions-nah)
|
||||
defp create_fee_type(attrs, admin_user) 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: admin_user)
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
# Uses admin_user to test permissions (UI-/Permissions-nah)
|
||||
defp create_member(attrs, admin_user) do
|
||||
|
|
|
|||
|
|
@ -4,30 +4,13 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
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: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
@ -43,27 +26,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member
|
||||
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
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
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!(actor: system_actor)
|
||||
end
|
||||
|
||||
describe "load_cycles_for_members/2" do
|
||||
test "efficiently loads cycles for members" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
- Custom field values are correctly formatted for different types
|
||||
- Members without custom field values show empty cell or "-"
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/custom-fields suite to async is a possible follow-up
|
||||
# rather than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
- Integration with member list display
|
||||
- Custom fields visibility
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/custom-fields suite to async is a possible follow-up
|
||||
# rather than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
|
@ -158,9 +161,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
|
|
@ -187,9 +187,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Custom field should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "M001"
|
||||
|
|
@ -214,9 +211,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_all']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# All fields should be visible
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
|
|
@ -238,9 +232,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_none']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Only first_name should be visible (it's always shown)
|
||||
html = render(view)
|
||||
# Email and street should be hidden
|
||||
|
|
@ -263,9 +254,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for URL update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Check that URL contains fields parameter
|
||||
# Note: In LiveView tests, we check the rendered HTML for the updated state
|
||||
# The actual URL update happens via push_patch
|
||||
|
|
@ -330,8 +318,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
:timer.sleep(100)
|
||||
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
end
|
||||
|
|
@ -388,8 +374,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
:timer.sleep(50)
|
||||
end
|
||||
|
||||
# Should still work correctly
|
||||
|
|
@ -459,9 +443,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_keydown(%{key: "Enter"})
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
|
|||
- Sort header has aria-label for screen reader
|
||||
- Keyboard navigation works (Tab through filter, sort header)
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/groups suite to async is a possible follow-up rather
|
||||
# than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
|
|||
- No badge for members without groups
|
||||
- Badge shows group name correctly
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/groups suite to async is a possible follow-up rather
|
||||
# than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
|||
All / Yes / No (per group). Multiple active group filters combine with AND
|
||||
(member must match all selected group conditions).
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/groups suite to async is a possible follow-up rather
|
||||
# than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
|||
- Groups work with existing search (but not testing search integration itself)
|
||||
- Member index search by group name returns members in that group (Issue #375)
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/groups suite to async is a possible follow-up rather
|
||||
# than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
|
|||
- Filter works at database level (not in-memory)
|
||||
- Sort runs in-memory but uses preloaded group data (no extra DB queries)
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/groups suite to async is a possible follow-up rather
|
||||
# than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ defmodule MvWeb.MemberLive.IndexGroupsSortingTest do
|
|||
@moduledoc """
|
||||
Tests for sorting by groups in the member overview.
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/groups suite to async is a possible follow-up rather
|
||||
# than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
|||
- URL parameters work with other parameters (query, sort_field, etc.)
|
||||
- URL is bookmarkable (filter/sorting persist)
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and groups
|
||||
# Kept async: false. The deferrable-FK migration removed the concurrent
|
||||
# create_member deadlock, but this file additionally showed an async-isolation
|
||||
# failure under load (filtered members from a parallel test leaking in), so it
|
||||
# is not trivially async-safe; resolving that is a separate follow-up.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -5,29 +5,12 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
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: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
@ -43,38 +26,16 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Delete any auto-generated cycles first to avoid conflicts
|
||||
existing_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!(actor: system_actor)
|
||||
|
||||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) 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!(actor: system_actor)
|
||||
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})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -85,8 +46,18 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
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})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -102,8 +73,17 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
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})
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: current_year_start,
|
||||
status: :suspended,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -120,7 +100,12 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
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})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -131,7 +116,12 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
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})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -142,7 +132,12 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
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})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :suspended,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -169,11 +164,21 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
|
||||
# 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})
|
||||
|
||||
create_cycle(member1, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
# 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})
|
||||
|
||||
create_cycle(member2, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
@ -205,11 +210,21 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
|
||||
# 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})
|
||||
|
||||
create_cycle(member1, fee_type, %{
|
||||
cycle_start: current_year_start,
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
# 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})
|
||||
|
||||
create_cycle(member2, fee_type, %{
|
||||
cycle_start: current_year_start,
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
@ -243,7 +258,12 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
# 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})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
end)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
|
|
|||
|
|
@ -1,57 +1,18 @@
|
|||
defmodule MvWeb.MemberLive.IndexTest do
|
||||
# async: false on purpose: the @slow "boolean filter performance with 150 members"
|
||||
# test asserts a wall-clock budget (duration < 1000ms). Running this module in
|
||||
# parallel with others adds CPU contention that inflates that measurement and makes
|
||||
# the timing assertion flaky, so this module stays synchronous.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.CustomFieldValue
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.MemberLive.Index, as: MemberIndex
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type (shared across all tests)
|
||||
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 cycle (shared across all tests)
|
||||
defp create_cycle(member, fee_type, attrs, actor) do
|
||||
# Delete any auto-generated cycles first to avoid conflicts
|
||||
existing_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) 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!(actor: actor)
|
||||
end
|
||||
|
||||
describe "desktop layout: scroll container and sticky table header" do
|
||||
@describetag :ui
|
||||
|
||||
|
|
@ -338,10 +299,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
send(view.pid, {:search_changed, "Friedrich"})
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
|
||||
assert state.socket.assigns.query == "Friedrich"
|
||||
assert is_list(state.socket.assigns.members)
|
||||
# Rationale: this exercises the handle_info(:search_changed) callback in isolation.
|
||||
# The search box value is owned by SearchBarComponent (assign_new), and scope is
|
||||
# recomputed on handle_params rather than this handle_info, so the updated :query
|
||||
# and :members assigns have no faithful rendered proxy here. The assigns are
|
||||
# asserted on internal state to preserve the original coverage of the callback.
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.query == "Friedrich"
|
||||
assert is_list(assigns.members)
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
|
|
@ -434,6 +399,38 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|
||||
end
|
||||
|
||||
# Opens the bulk-actions dropdown and returns the mailto link's BCC payload
|
||||
# (everything after "mailto:?bcc="). This is the observable carrier of the
|
||||
# recipient set / recipient_count, replacing direct socket-assign inspection.
|
||||
defp mailto_bcc(view) do
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
|
||||
href =
|
||||
render(view)
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s([data-testid="bulk-actions-mailto"]))
|
||||
|> LazyHTML.attribute("href")
|
||||
|> List.first()
|
||||
|
||||
case href do
|
||||
"mailto:?bcc=" <> bcc -> bcc
|
||||
other -> other || ""
|
||||
end
|
||||
end
|
||||
|
||||
# Opens the member-filter dropdown so its boolean filter controls are rendered.
|
||||
defp open_member_filter(view) do
|
||||
view
|
||||
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
# Returns the rendered HTML of the member-filter dropdown (with it open).
|
||||
defp member_filter_html(view) do
|
||||
open_member_filter(view)
|
||||
render(view)
|
||||
end
|
||||
|
||||
describe "copy_emails feature" do
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
|
@ -569,15 +566,13 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Select two members by sending the select_member event directly
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
render_click(view, "select_member", %{"id" => member2.id})
|
||||
html = render_click(view, "select_member", %{"id" => member2.id})
|
||||
|
||||
# Get the socket state to verify the formatted email string
|
||||
state = :sys.get_state(view.pid)
|
||||
selected_members = state.socket.assigns.selected_members
|
||||
|
||||
# Verify MapSet is used
|
||||
assert %MapSet{} = selected_members
|
||||
assert MapSet.size(selected_members) == 2
|
||||
# Both selected members are observably reflected: their row checkboxes are
|
||||
# checked and the scope badge shows the selection count ("2").
|
||||
assert has_element?(view, ~s(input[role="checkbox"][name="#{member1.id}"][checked]))
|
||||
assert has_element?(view, ~s(input[role="checkbox"][name="#{member2.id}"][checked]))
|
||||
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "2"
|
||||
end
|
||||
|
||||
test "email format is 'First Last <email>' with comma separator", %{
|
||||
|
|
@ -1010,26 +1005,23 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
test "scope is :all when nothing selected and no filter", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :all
|
||||
# :all scope renders the muted "all" badge.
|
||||
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "all"
|
||||
end
|
||||
|
||||
test "scope is :filtered when a search term is active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=Scope")
|
||||
{:ok, _view, html} = live(conn, "/members?query=Scope")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :filtered
|
||||
# An active search narrows the list, so the scope badge reads "filtered".
|
||||
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
end
|
||||
|
||||
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :filtered
|
||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
|
|
@ -1042,10 +1034,13 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
html = render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :selection
|
||||
# A selection switches the badge to the emphasized (primary) variant whose
|
||||
# label is the selected count ("1"), which is the observable proxy for scope == :selection.
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "1"
|
||||
assert badge |> LazyHTML.attribute("class") |> List.first() =~ "badge-primary"
|
||||
end
|
||||
|
||||
test "with no selection, recipient_count and mailto_bcc cover all members", %{
|
||||
|
|
@ -1054,11 +1049,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
# The mailto link's BCC is the observable carrier of recipient_count/mailto_bcc.
|
||||
bcc = mailto_bcc(view)
|
||||
# Both seeded members have an email, so the no-selection scope covers both.
|
||||
assert assigns.recipient_count == 2
|
||||
assert assigns.mailto_bcc =~ "scope1%40example.com"
|
||||
assert assigns.mailto_bcc =~ "scope2%40example.com"
|
||||
assert bcc =~ "scope1%40example.com"
|
||||
assert bcc =~ "scope2%40example.com"
|
||||
end
|
||||
|
||||
test "with a selection, recipient_count and mailto_bcc cover only the selection", %{
|
||||
|
|
@ -1070,10 +1065,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.recipient_count == 1
|
||||
assert assigns.mailto_bcc =~ "scope1%40example.com"
|
||||
refute assigns.mailto_bcc =~ "scope2%40example.com"
|
||||
bcc = mailto_bcc(view)
|
||||
assert bcc =~ "scope1%40example.com"
|
||||
refute bcc =~ "scope2%40example.com"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1111,7 +1105,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
paid_member,
|
||||
fee_type,
|
||||
%{cycle_start: last_year_start, status: :paid},
|
||||
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1128,7 +1122,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
unpaid_member,
|
||||
fee_type,
|
||||
%{cycle_start: last_year_start, status: :unpaid},
|
||||
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1158,7 +1152,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
paid_member,
|
||||
fee_type,
|
||||
%{cycle_start: last_year_start, status: :paid},
|
||||
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1175,7 +1169,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
unpaid_member,
|
||||
fee_type,
|
||||
%{cycle_start: last_year_start, status: :unpaid},
|
||||
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1205,7 +1199,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
paid_member,
|
||||
fee_type,
|
||||
%{cycle_start: current_year_start, status: :paid},
|
||||
%{cycle_start: current_year_start, status: :paid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1222,7 +1216,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
unpaid_member,
|
||||
fee_type,
|
||||
%{cycle_start: current_year_start, status: :unpaid},
|
||||
%{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1252,7 +1246,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
paid_member,
|
||||
fee_type,
|
||||
%{cycle_start: current_year_start, status: :paid},
|
||||
%{cycle_start: current_year_start, status: :paid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1269,7 +1263,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
unpaid_member,
|
||||
fee_type,
|
||||
%{cycle_start: current_year_start, status: :unpaid},
|
||||
%{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -1339,6 +1333,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Rationale: with no boolean fields and no active filter there is no rendered
|
||||
# filter control or active-count badge to observe, so the empty initial filter
|
||||
# map is asserted on internal state directly.
|
||||
state = :sys.get_state(view.pid)
|
||||
assert state.socket.assigns.boolean_custom_field_filters == %{}
|
||||
end
|
||||
|
|
@ -1350,6 +1347,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Rationale: the absence of boolean fields means the filter dropdown renders
|
||||
# no boolean fieldsets; "empty list" has no positive rendered signal, so it is
|
||||
# asserted on internal state directly.
|
||||
state = :sys.get_state(view.pid)
|
||||
assert state.socket.assigns.boolean_custom_fields == []
|
||||
end
|
||||
|
|
@ -1360,89 +1360,82 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
# Create boolean and non-boolean custom fields
|
||||
boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
|
||||
boolean_field2 = create_boolean_custom_field(%{name: "Newsletter Subscription"})
|
||||
_string_field = create_string_custom_field(%{name: "Phone Number"})
|
||||
string_field = create_string_custom_field(%{name: "Phone Number"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
open_member_filter(view)
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
boolean_custom_fields = state.socket.assigns.boolean_custom_fields
|
||||
|
||||
# Should only contain boolean fields
|
||||
assert length(boolean_custom_fields) == 2
|
||||
assert Enum.all?(boolean_custom_fields, &(&1.value_type == :boolean))
|
||||
assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field1.id))
|
||||
assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field2.id))
|
||||
# Only the boolean fields render a tri-state filter control; the string field does not.
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}")
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}")
|
||||
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}")
|
||||
end
|
||||
|
||||
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Create boolean fields with specific names to test sorting
|
||||
_boolean_field_z = create_boolean_custom_field(%{name: "Zebra Field"})
|
||||
_boolean_field_a = create_boolean_custom_field(%{name: "Alpha Field"})
|
||||
_boolean_field_m = create_boolean_custom_field(%{name: "Middle Field"})
|
||||
field_z = create_boolean_custom_field(%{name: "Zebra Field"})
|
||||
field_a = create_boolean_custom_field(%{name: "Alpha Field"})
|
||||
field_m = create_boolean_custom_field(%{name: "Middle Field"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
html = member_filter_html(view)
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
boolean_custom_fields = state.socket.assigns.boolean_custom_fields
|
||||
# The rendered boolean filter controls appear in name-ascending order.
|
||||
rendered_order =
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s(input[id$="-all"][name^="custom_boolean"]))
|
||||
|> LazyHTML.attribute("id")
|
||||
|> Enum.map(
|
||||
&(&1
|
||||
|> String.replace_prefix("custom-boolean-filter-", "")
|
||||
|> String.replace_suffix("-all", ""))
|
||||
)
|
||||
|
||||
# Should be sorted by name ascending
|
||||
names = Enum.map(boolean_custom_fields, & &1.name)
|
||||
assert names == ["Alpha Field", "Middle Field", "Zebra Field"]
|
||||
assert rendered_order == [field_a.id, field_m.id, field_z.id]
|
||||
end
|
||||
|
||||
test "handle_params parses bf_<id> values correctly", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field = create_boolean_custom_field()
|
||||
|
||||
# Test true value
|
||||
{:ok, view1, _html} =
|
||||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||
# Test true value: the "Yes" radio is checked (the boolean true, not the string "true").
|
||||
{:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||
open_member_filter(view1)
|
||||
assert has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||||
refute has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||||
|
||||
state1 = :sys.get_state(view1.pid)
|
||||
filters1 = state1.socket.assigns.boolean_custom_field_filters
|
||||
assert filters1[boolean_field.id] == true
|
||||
refute filters1[boolean_field.id] == "true"
|
||||
|
||||
# Test false value
|
||||
{:ok, view2, _html} =
|
||||
live(conn, "/members?bf_#{boolean_field.id}=false")
|
||||
|
||||
state2 = :sys.get_state(view2.pid)
|
||||
filters2 = state2.socket.assigns.boolean_custom_field_filters
|
||||
assert filters2[boolean_field.id] == false
|
||||
refute filters2[boolean_field.id] == "false"
|
||||
# Test false value: the "No" radio is checked.
|
||||
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
|
||||
open_member_filter(view2)
|
||||
assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]")
|
||||
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||||
end
|
||||
|
||||
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
fake_id = Ecto.UUID.generate()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_#{fake_id}=true")
|
||||
{:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
|
||||
open_member_filter(view)
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
|
||||
# Filter should not be added for non-existent custom field
|
||||
refute Map.has_key?(filters, fake_id)
|
||||
assert filters == %{}
|
||||
# No filter control exists for a non-existent field, and no active-filter badge appears.
|
||||
refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}")
|
||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||||
end
|
||||
|
||||
test "handle_params ignores non-boolean custom fields", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
string_field = create_string_custom_field()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_#{string_field.id}=true")
|
||||
{:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
|
||||
open_member_filter(view)
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
|
||||
# Filter should not be added for non-boolean custom field
|
||||
refute Map.has_key?(filters, string_field.id)
|
||||
assert filters == %{}
|
||||
# A string field is never rendered as a boolean filter, and no filter becomes active.
|
||||
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}")
|
||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||||
end
|
||||
|
||||
test "handle_params ignores invalid filter values", %{conn: conn} do
|
||||
|
|
@ -1453,15 +1446,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
invalid_values = ["1", "0", "yes", "no", "True", "False", "", "invalid", "null"]
|
||||
|
||||
for invalid_value <- invalid_values do
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
|
||||
{:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
|
||||
open_member_filter(view)
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
|
||||
# Invalid values should not be added to filters
|
||||
refute Map.has_key?(filters, boolean_field.id),
|
||||
"Invalid value '#{invalid_value}' should not be added to filters"
|
||||
# An invalid value leaves the field's filter at "All" (no filter applied).
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"),
|
||||
"Invalid value '#{invalid_value}' should leave the filter at All"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1476,12 +1466,16 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
|
||||
)
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
open_member_filter(view)
|
||||
|
||||
assert filters[boolean_field1.id] == true
|
||||
assert filters[boolean_field2.id] == false
|
||||
assert map_size(filters) == 2
|
||||
# Both filters are reflected: field1 at "Yes", field2 at "No", and the
|
||||
# active-filter count badge shows 2.
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-true"}[checked]")
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-false"}[checked]")
|
||||
|
||||
assert view
|
||||
|> element(~s(button[aria-label="Filter members"] .badge), "2")
|
||||
|> has_element?()
|
||||
end
|
||||
|
||||
test "build_query_params includes active boolean filters and excludes nil filters", %{
|
||||
|
|
@ -1555,12 +1549,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
"/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
|
||||
)
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
open_member_filter(view)
|
||||
|
||||
# Both filters should be set
|
||||
assert filters[boolean_field.id] == true
|
||||
assert state.socket.assigns.cycle_status_filter == :paid
|
||||
# Both filters are reflected in the rendered controls: the boolean field at
|
||||
# "Yes" and the payment-status filter at "paid".
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||||
assert has_element?(view, "#payment-filter-paid[checked]")
|
||||
|
||||
# Both should be in URL when triggering search
|
||||
view
|
||||
|
|
@ -1581,9 +1575,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||
|
||||
state_before = :sys.get_state(view.pid)
|
||||
filters_before = state_before.socket.assigns.boolean_custom_field_filters
|
||||
assert filters_before[boolean_field.id] == true
|
||||
open_member_filter(view)
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||||
|
||||
# Delete the custom field (requires actor with destroy permission)
|
||||
Ash.destroy!(boolean_field, actor: system_actor)
|
||||
|
|
@ -1592,12 +1585,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
{:ok, view2, _html} =
|
||||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||
|
||||
state_after = :sys.get_state(view2.pid)
|
||||
filters_after = state_after.socket.assigns.boolean_custom_field_filters
|
||||
open_member_filter(view2)
|
||||
|
||||
# Filter should not be present for deleted custom field
|
||||
refute Map.has_key?(filters_after, boolean_field.id)
|
||||
assert filters_after == %{}
|
||||
# The deleted field renders no filter control and no filter is active.
|
||||
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-true"}")
|
||||
refute has_element?(view2, ~s(button[aria-label="Filter members"].btn-active))
|
||||
end
|
||||
|
||||
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
|
||||
|
|
@ -1610,12 +1602,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_#{encoded_id}=true")
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
open_member_filter(view)
|
||||
|
||||
# Filter should work with URL-encoded ID
|
||||
# Phoenix should decode it automatically, so we check with original ID
|
||||
assert filters[boolean_field.id] == true
|
||||
# Phoenix decodes the param, so the filter applies under the original ID:
|
||||
# the "Yes" radio for the field is checked.
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||||
end
|
||||
|
||||
test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do
|
||||
|
|
@ -1626,12 +1617,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
open_member_filter(view)
|
||||
|
||||
# Should not parse as valid filter (UUID validation should fail)
|
||||
refute Map.has_key?(filters, boolean_field.id)
|
||||
assert filters == %{}
|
||||
# The double-prefixed param is not a valid filter: the field stays at "All"
|
||||
# and no filter is active.
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||||
end
|
||||
|
||||
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
|
||||
|
|
@ -1646,17 +1637,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
|
||||
# Should limit to maximum 50 filters
|
||||
assert map_size(filters) <= 50
|
||||
# All filters in the result should be valid
|
||||
Enum.each(filters, fn {id, value} ->
|
||||
assert value in [true, false]
|
||||
# Verify the ID corresponds to one of our boolean fields
|
||||
assert id in Enum.map(boolean_fields, &to_string(&1.id))
|
||||
end)
|
||||
# The active-filter count badge is the observable carrier of the filter count.
|
||||
# With 60 requested filters, the DoS cap clamps it to exactly 50.
|
||||
assert view
|
||||
|> element(~s(button[aria-label="Filter members"] .badge), "50")
|
||||
|> has_element?()
|
||||
end
|
||||
|
||||
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
|
||||
|
|
@ -1669,14 +1654,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_#{fake_long_id}=true")
|
||||
|
||||
state = :sys.get_state(view.pid)
|
||||
filters = state.socket.assigns.boolean_custom_field_filters
|
||||
open_member_filter(view)
|
||||
|
||||
# Should not accept the extremely long ID
|
||||
refute Map.has_key?(filters, fake_long_id)
|
||||
# Valid boolean field should still work
|
||||
refute Map.has_key?(filters, boolean_field.id)
|
||||
assert filters == %{}
|
||||
# The over-long ID is rejected: the real field stays at "All" and no filter
|
||||
# is active.
|
||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||||
end
|
||||
|
||||
# Helper to create a member with a boolean custom field value
|
||||
|
|
@ -2197,7 +2180,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
member_paid_true,
|
||||
fee_type,
|
||||
%{cycle_start: last_year_start, status: :paid},
|
||||
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -2225,7 +2208,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
create_cycle(
|
||||
member_unpaid_true,
|
||||
fee_type,
|
||||
%{cycle_start: last_year_start, status: :unpaid},
|
||||
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
|
||||
system_actor
|
||||
)
|
||||
|
||||
|
|
@ -2324,24 +2307,20 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Start with no boolean custom fields
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
open_member_filter(view)
|
||||
|
||||
state_before = :sys.get_state(view.pid)
|
||||
boolean_fields_before = state_before.socket.assigns.boolean_custom_fields
|
||||
assert boolean_fields_before == []
|
||||
# No boolean field control is rendered yet.
|
||||
refute has_element?(view, ~s(input[name^="custom_boolean"]))
|
||||
|
||||
# Create a new boolean custom field
|
||||
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
|
||||
|
||||
# Navigate again - the new field should appear
|
||||
# Navigate again - the new field should appear in the filter dropdown.
|
||||
{:ok, view2, _html} = live(conn, "/members")
|
||||
html_after = member_filter_html(view2)
|
||||
|
||||
state_after = :sys.get_state(view2.pid)
|
||||
boolean_fields_after = state_after.socket.assigns.boolean_custom_fields
|
||||
|
||||
# New boolean field should be present
|
||||
assert length(boolean_fields_after) == 1
|
||||
assert Enum.any?(boolean_fields_after, &(&1.id == new_boolean_field.id))
|
||||
assert Enum.any?(boolean_fields_after, &(&1.name == "Newly Added Field"))
|
||||
assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}")
|
||||
assert html_after =~ "Newly Added Field"
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
|
|
|
|||
|
|
@ -5,29 +5,12 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
|||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Mv.Fixtures, only: [create_fee_type: 1]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
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: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ defmodule MvWeb.MemberLive.ShowGroupsDisplayTest do
|
|||
- Accessibility: group links have aria-label for screen readers
|
||||
|
||||
## Note on async
|
||||
async: false to avoid PostgreSQL deadlocks when creating members and groups
|
||||
in the same test run (same as IndexGroupsDisplayTest).
|
||||
Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
removed the concurrent-create_member deadlock that previously forced this, so
|
||||
re-flipping this members/groups suite to async is a possible follow-up rather
|
||||
than part of the original change.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
|
|
|||
|
|
@ -5,56 +5,12 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
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: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Delete any auto-generated cycles first to avoid conflicts
|
||||
existing_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!(actor: system_actor)
|
||||
|
||||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) 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!(actor: system_actor)
|
||||
end
|
||||
|
||||
describe "cycle-regeneration control tooltip (§3.5 icon/tooltip audit)" do
|
||||
test "the regenerate_cycles control carries a tooltip and accessible label", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
|
@ -76,8 +32,19 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{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})
|
||||
_cycle1 =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
_cycle2 =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -101,7 +68,8 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("60.00"),
|
||||
status: :paid
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
|
@ -158,7 +126,12 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
cycle =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -189,7 +162,12 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
cycle =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -220,7 +198,12 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
cycle =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -316,7 +299,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
_cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
_cycle =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -335,7 +324,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
cycle =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -361,7 +356,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
# This test verifies that Ash.destroy(cycle, actor: read_only_user) returns Forbidden.
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
cycle =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Ash.destroy(cycle, domain: Mv.MembershipFees, actor: read_only_user)
|
||||
|
|
@ -389,8 +390,20 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
_c1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
_c2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
_c1 =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :paid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
_c2 =
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid,
|
||||
replace_existing: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
defmodule MvWeb.UserLive.FormTest do
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and users
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
# re-flipping this members/users suite to async is a possible follow-up rather
|
||||
# than part of the original change.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
|
|
|
|||
|
|
@ -24,21 +24,11 @@ defmodule Mv.SeedsTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
# Module attribute to track if seeds have been run
|
||||
# This allows us to run seeds once per test module while respecting sandbox isolation
|
||||
@seeds_run_key :seeds_test_run
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Run seeds once per test process (due to sandbox isolation, each test runs in its own process)
|
||||
# Process.get/put ensures seeds are only executed once per test process, not once per test
|
||||
# Note: With async: false and sandbox isolation, this effectively runs seeds once per test,
|
||||
# but the guard prevents multiple executions within the same test process if setup is called multiple times
|
||||
unless Process.get(@seeds_run_key) do
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
Process.put(@seeds_run_key, true)
|
||||
end
|
||||
# Each test runs in its own sandbox-owned process, so seeds are loaded once per test.
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -335,4 +335,82 @@ defmodule Mv.Fixtures do
|
|||
{:ok, request} = Membership.confirm_join_request(token, actor: nil)
|
||||
request
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a membership fee type with default or custom attributes.
|
||||
|
||||
Defaults: a unique `name`, `amount` 50.00, `interval` :yearly.
|
||||
|
||||
## Parameters
|
||||
- `attrs` - Map of attributes to override defaults (e.g. `%{interval: :monthly}`).
|
||||
- `actor` - the authorization actor; defaults to the system actor when omitted/nil.
|
||||
|
||||
## Returns
|
||||
- MembershipFeeType struct.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_fee_type(%{interval: :monthly})
|
||||
%Mv.MembershipFees.MembershipFeeType{interval: :monthly, ...}
|
||||
|
||||
iex> create_fee_type(%{amount: Decimal.new("10.00")}, admin)
|
||||
%Mv.MembershipFees.MembershipFeeType{...}
|
||||
"""
|
||||
def create_fee_type(attrs \\ %{}, actor \\ nil) do
|
||||
actor = actor || SystemActor.get_system_actor()
|
||||
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, Map.merge(default_attrs, attrs))
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a membership fee cycle for the given member and fee type.
|
||||
|
||||
Defaults: `cycle_start` ~D[2024-01-01], `amount` 50.00, `status` :unpaid,
|
||||
with `member_id`/`membership_fee_type_id` derived from the passed structs.
|
||||
|
||||
## Parameters
|
||||
- `member` - the Member struct the cycle belongs to.
|
||||
- `fee_type` - the MembershipFeeType struct the cycle references.
|
||||
- `attrs` - Map overriding the cycle defaults (e.g. `%{cycle_start: ~D[2023-01-01], status: :paid}`).
|
||||
A reserved `:replace_existing` key (truthy) deletes any pre-existing cycles for the member
|
||||
before creating the new one (used where auto-generated cycles would otherwise conflict);
|
||||
it is stripped from the attrs and never passed to the create action. Defaults to absent/false.
|
||||
- `actor` - the authorization actor; defaults to the system actor when omitted/nil.
|
||||
|
||||
## Returns
|
||||
- MembershipFeeCycle struct.
|
||||
"""
|
||||
def create_cycle(member, fee_type, attrs \\ %{}, actor \\ nil) do
|
||||
actor = actor || SystemActor.get_system_actor()
|
||||
{replace_existing, attrs} = Map.pop(attrs, :replace_existing, false)
|
||||
|
||||
if replace_existing do
|
||||
require Ash.Query
|
||||
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!(actor: actor)
|
||||
|> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||
end
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, Map.merge(default_attrs, attrs))
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue