mitgliederverwaltung/test/membership/member_groups_relationship_test.exs
Simon 2bb01bd201
All checks were successful
continuous-integration/drone/push Build is passing
Improve UX of join requests and fix minor bugs (#492)
## Description of the implemented changes
The changes were:
- [x] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [ ] Refactoring

This PR improves the join-request flow and presentation quality, fixes several data-display issues in join/join-request screens, and adds a usability improvement in global settings (directly opening the join link). It also includes dependency updates and changelog maintenance.

## What has been changed?
- Join form (`JoinLive`) now renders inputs based on actual field types (including checkbox/date/number/email behavior instead of generic text-only handling).
- Join form custom-field labels are resolved from configured custom fields (fallback remains safe if lookup fails).
- Join-request details page (`JoinRequestLive.Show`) now:
  - resolves and shows custom field names instead of raw IDs,
  - formats boolean-like values (`on/true/1`, `off/false/0`) as localized `Yes/No`,
  - formats ISO date strings for better readability,
  - keeps legacy field handling while improving output consistency.
- Join-request detail layout was improved semantically and visually (`dl/dt/dd` structure for label/value rows).
- Global settings page now includes an **Open** button for the join URL (`target="_blank"`, `rel="noopener noreferrer"`, ARIA label).
- Added/updated tests around:
  - join field type rendering,
  - custom field labels in join-request views,
  - related auth/global-settings behavior.
- Updated translations (`default.pot`, `en`, `de`) for new UI strings.
- Updated dependencies/tooling (`mix.lock`, `mix.exs`, CI/renovate-related updates).
- Updated `CHANGELOG.md` entries for unreleased changes.

## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added were needed

### Accessibility
- [x] New elements are properly defined with html-tags
- [x] Colour contrast follows WCAG criteria
- [x] Aria labels are added when needed
- [x] Everything is accessible by keyboard
- [x] Tab-Order is comprehensible
- [x] All interactive elements have a visible focus

### Testing
- [x] Tests for new code are written
- [ ] All tests pass
- [ ] axe-core dev tools show no critical or major issues

## Additional Notes
- Reviewer focus areas:
  - `lib/mv_web/live/join_live.ex`: input type derivation and custom field lookup strategy (`authorize?: false` read path used intentionally for field metadata).
  - `lib/mv_web/live/join_request_live/show.ex`: value-formatting logic (especially backward compatibility for legacy `form_data` payloads).
  - `lib/mv_web/live/global_settings_live.ex`: external-link behavior and accessibility attributes.
- The branch also contains dependency update commits; please review lockfile and CI-related changes separately from functional join/join-request changes.

Reviewed-on: #492
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
2026-05-06 14:34:42 +02:00

225 lines
7.5 KiB
Elixir

defmodule Mv.Membership.MemberGroupsRelationshipTest do
@moduledoc """
Tests for Member resource extension with groups relationship.
"""
use Mv.DataCase, async: true
alias Mv.Membership
require Ash.Query
import Ash.Expr
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Relationships" do
test "member has many_to_many groups relationship (load with preloading)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor)
{:ok, group1} =
Membership.create_group(%{name: unique_group_name("Group One")}, actor: actor)
{:ok, group2} =
Membership.create_group(%{name: unique_group_name("Group Two")}, actor: actor)
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member.id, group_id: group2.id},
actor: actor
)
# Load member with groups
{:ok, member_with_groups} =
Ash.load(member, :groups, actor: actor, domain: Mv.Membership)
assert length(member_with_groups.groups) == 2
assert Enum.any?(member_with_groups.groups, &(&1.id == group1.id))
assert Enum.any?(member_with_groups.groups, &(&1.id == group2.id))
end
test "load multiple members with groups preloaded (N+1 prevention)", %{actor: actor} do
{:ok, member1} = Membership.create_member(%{email: unique_email("member1")}, actor: actor)
{:ok, member2} = Membership.create_member(%{email: unique_email("member2")}, actor: actor)
{:ok, group} =
Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor)
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: actor
)
# Load all members with groups in single query
{:ok, members} =
Ash.read(Mv.Membership.Member, actor: actor, domain: Mv.Membership, load: [:groups])
member1_loaded = Enum.find(members, &(&1.id == member1.id))
member2_loaded = Enum.find(members, &(&1.id == member2.id))
assert length(member1_loaded.groups) == 1
assert length(member2_loaded.groups) == 1
assert hd(member1_loaded.groups).id == group.id
assert hd(member2_loaded.groups).id == group.id
end
end
describe "Member-Group Association Operations" do
test "add member to group via Ash API", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor)
{:ok, group} =
Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor)
assert {:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
assert member_group.member_id == member.id
assert member_group.group_id == group.id
end
test "remove member from group via Ash API", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor)
{:ok, group} =
Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor)
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Remove association
:ok = Membership.destroy_member_group(member_group, actor: actor)
# Verify association is removed
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(member_id == ^member.id and group_id == ^group.id)),
actor: actor,
domain: Mv.Membership
)
assert mgs == []
end
test "add member to multiple groups in single operation", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor)
{:ok, group1} =
Membership.create_group(%{name: unique_group_name("Group One")}, actor: actor)
{:ok, group2} =
Membership.create_group(%{name: unique_group_name("Group Two")}, actor: actor)
{:ok, group3} =
Membership.create_group(%{name: unique_group_name("Group Three")}, actor: actor)
# Add to all groups
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member.id, group_id: group2.id},
actor: actor
)
{:ok, _mg3} =
Membership.create_member_group(%{member_id: member.id, group_id: group3.id},
actor: actor
)
# Verify all associations exist
{:ok, member_with_groups} =
Ash.load(member, :groups, actor: actor, domain: Mv.Membership)
assert length(member_with_groups.groups) == 3
end
end
describe "Edge Cases" do
test "adding member to same group twice fails (duplicate prevention)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor)
{:ok, group} =
Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor)
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Try to add again
assert {:error, %Ash.Error.Invalid{}} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
end
test "removing member from group they're not in (idempotent, no error)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor)
{:ok, group} =
Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor)
# Verify no association exists
{:ok, nil} =
Ash.read_one(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(member_id == ^member.id and group_id == ^group.id)),
actor: actor,
domain: Mv.Membership
)
# Test idempotency: Create association, delete it, then try to delete again
# This verifies that destroy_member_group is idempotent
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# First deletion should succeed
assert :ok = Membership.destroy_member_group(member_group, actor: actor)
# Verify association is deleted
{:ok, nil} =
Ash.read_one(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(id == ^member_group.id)),
actor: actor,
domain: Mv.Membership
)
# Try to destroy again - should be idempotent (either succeed or return not found error)
# Note: This tests the idempotency of the destroy action
result = Membership.destroy_member_group(member_group, actor: actor)
# Should either succeed (idempotent) or return an error (not found)
# Both behaviors are acceptable for idempotency
assert result == :ok || match?({:error, _}, result)
end
end
defp unique_email(prefix) do
"#{prefix}-#{System.unique_integer([:positive])}@test.com"
end
defp unique_group_name(prefix) do
"#{prefix} #{System.unique_integer([:positive])}"
end
end