diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md
index 1c04703..88af7de 100644
--- a/docs/groups-architecture.md
+++ b/docs/groups-architecture.md
@@ -354,6 +354,106 @@ end
- Edit group button
- Delete group button (with confirmation)
+### Accessibility (A11y) Considerations
+
+**Requirements:**
+- All UI elements must be keyboard accessible
+- Screen readers must be able to navigate and understand the interface
+- ARIA labels and roles must be properly set
+
+**Group Badges in Member Overview:**
+
+```heex
+
+ <%= group.name %>
+
+```
+
+**Clickable Group Badge (for filtering):**
+
+```heex
+
+```
+
+**Group Filter Dropdown:**
+
+```heex
+
+```
+
+**Screen Reader Announcements:**
+
+```heex
+
+ <%= if @filtered_by_group do %>
+ Showing <%= @member_count %> members in group <%= @filtered_group.name %>
+ <% else %>
+ Showing <%= @member_count %> members
+ <% end %>
+
+```
+
+**Delete Confirmation Modal:**
+
+```heex
+
+```
+
+**Keyboard Navigation:**
+- All interactive elements (buttons, links, form inputs) must be focusable via Tab key
+- Modal dialogs must trap focus (Tab key cycles within modal)
+- Escape key closes modals
+- Enter/Space activates buttons when focused
+
---
## Integration Points
@@ -403,8 +503,7 @@ end
- `destroy` - Delete groups (admin only)
**Scopes:**
-- `:all` - All groups (for admins)
-- `:all` - All groups (for read-only users, read permission only)
+- `:all` - All groups (for all permission sets that have read access)
### Permission Sets Update
@@ -413,10 +512,10 @@ end
%{
resources: [
# ... existing resources ...
- %{resource: "groups", action: :read, scope: :all, granted: true},
- %{resource: "groups", action: :create, scope: :all, granted: true},
- %{resource: "groups", action: :update, scope: :all, granted: true},
- %{resource: "groups", action: :destroy, scope: :all, granted: true}
+ %{resource: "Group", action: :read, scope: :all, granted: true},
+ %{resource: "Group", action: :create, scope: :all, granted: true},
+ %{resource: "Group", action: :update, scope: :all, granted: true},
+ %{resource: "Group", action: :destroy, scope: :all, granted: true}
]
}
```
@@ -426,11 +525,33 @@ end
%{
resources: [
# ... existing resources ...
- %{resource: "groups", action: :read, scope: :all, granted: true}
+ %{resource: "Group", action: :read, scope: :all, granted: true}
]
}
```
+**Normal User Permission Set:**
+```elixir
+%{
+ resources: [
+ # ... existing resources ...
+ %{resource: "Group", action: :read, scope: :all, granted: true}
+ ]
+}
+```
+
+**Own Data Permission Set:**
+```elixir
+%{
+ resources: [
+ # ... existing resources ...
+ %{resource: "Group", action: :read, scope: :all, granted: true}
+ ]
+}
+```
+
+**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. Only admins can manage (create/update/destroy) groups.
+
### Member-Group Association Permissions
**Current (MVP):**
@@ -456,13 +577,60 @@ end
### Query Optimization
**Member Overview:**
-- Load groups with members in single query (using `load`)
+- Load groups with members in single query using `Ash.Query.load`
- Use `Ash.Query.load(groups: [:id, :name])` to minimize data transfer
- Filter groups at database level when filtering by group
+**Example:**
+```elixir
+query =
+ Mv.Membership.Member
+ |> Ash.Query.new()
+ |> Ash.Query.load(groups: [:id, :name])
+ |> Ash.Query.filter(expr(groups.id == ^selected_group_id))
+
+members = Ash.read!(query, actor: actor)
+```
+
+**N+1 Query Prevention:**
+- Always use `Ash.Query.load` to preload groups relationship
+- Never access `member.groups` without preloading (would trigger N+1 queries)
+
+**Performance Threshold:**
+- With proper `load` usage: Works efficiently up to **100 members** (MVP scope)
+- For larger datasets (>100 members), consider:
+ - Pagination (limit number of members loaded)
+ - Lazy loading of groups (only load when groups column is visible)
+ - Database-level aggregation for group counts
+
+**Example of N+1 Problem (DO NOT DO THIS):**
+```elixir
+# BAD: This causes N+1 queries
+members = Ash.read!(Mv.Membership.Member)
+Enum.each(members, fn member ->
+ # Each iteration triggers a separate query!
+ groups = member.groups # N+1 query!
+end)
+```
+
+**Correct Approach:**
+```elixir
+# GOOD: Preload in single query
+members =
+ Mv.Membership.Member
+ |> Ash.Query.load(groups: [:id, :name])
+ |> Ash.read!()
+
+# No additional queries needed
+Enum.each(members, fn member ->
+ groups = member.groups # Already loaded!
+end)
+```
+
**Group Detail:**
-- Paginate member list for large groups
+- Paginate member list for large groups (>50 members)
- Load member count via calculation (not separate query)
+- Use `Ash.Query.load` for member details when displaying
### Search Performance
@@ -927,48 +1095,352 @@ Jede fachliche Einheit kann als **separates Issue** umgesetzt werden:
### Unit Tests
-**Group Resource:**
-- Name uniqueness validation
-- Name/description length constraints
-- Member count calculation
+#### Group Resource Tests
-**MemberGroup Resource:**
-- Unique constraint on (member_id, group_id)
-- Cascade delete behavior
+**File:** `test/membership/group_test.exs`
+
+```elixir
+defmodule Mv.Membership.GroupTest do
+ use Mv.DataCase
+ alias Mv.Membership.Group
+
+ describe "create_group/1" do
+ test "creates group with valid attributes" do
+ attrs = %{name: "Vorstand", description: "Board of directors"}
+ assert {:ok, group} = Group.create(attrs)
+ assert group.name == "Vorstand"
+ assert group.description == "Board of directors"
+ end
+
+ test "returns error when name is missing" do
+ attrs = %{description: "Some description"}
+ assert {:error, changeset} = Group.create(attrs)
+ assert %{name: ["is required"]} = errors_on(changeset)
+ end
+
+ test "returns error when name exceeds 100 characters" do
+ long_name = String.duplicate("a", 101)
+ attrs = %{name: long_name}
+ assert {:error, changeset} = Group.create(attrs)
+ assert %{name: ["must be at most 100 character(s)"]} = errors_on(changeset)
+ end
+
+ test "returns error when name is not unique" do
+ Group.create!(%{name: "Vorstand"})
+ attrs = %{name: "Vorstand"}
+ assert {:error, changeset} = Group.create(attrs)
+ assert %{name: ["has already been taken"]} = errors_on(changeset)
+ end
+
+ test "name uniqueness is case-sensitive" do
+ Group.create!(%{name: "Vorstand"})
+ attrs = %{name: "VORSTAND"}
+ # For MVP: case-sensitive uniqueness
+ assert {:ok, _} = Group.create(attrs)
+ end
+
+ test "allows description to be nil" do
+ attrs = %{name: "Test Group"}
+ assert {:ok, group} = Group.create(attrs)
+ assert is_nil(group.description)
+ end
+
+ test "trims whitespace from name" do
+ attrs = %{name: " Vorstand "}
+ assert {:ok, group} = Group.create(attrs)
+ assert group.name == "Vorstand"
+ end
+
+ test "description max length is 500 characters" do
+ long_desc = String.duplicate("a", 501)
+ attrs = %{name: "Test", description: long_desc}
+ assert {:error, changeset} = Group.create(attrs)
+ assert %{description: ["must be at most 500 character(s)"]} = errors_on(changeset)
+ end
+ end
+
+ describe "update_group/2" do
+ test "updates group name and description" do
+ group = Group.create!(%{name: "Old Name", description: "Old Desc"})
+ attrs = %{name: "New Name", description: "New Desc"}
+ assert {:ok, updated} = Group.update(group, attrs)
+ assert updated.name == "New Name"
+ assert updated.description == "New Desc"
+ end
+
+ test "prevents duplicate name on update" do
+ Group.create!(%{name: "Existing"})
+ group = Group.create!(%{name: "Other"})
+ attrs = %{name: "Existing"}
+ assert {:error, changeset} = Group.update(group, attrs)
+ assert %{name: ["has already been taken"]} = errors_on(changeset)
+ end
+ end
+
+ describe "delete_group/1" do
+ test "deletes group and all member associations" do
+ group = Group.create!(%{name: "Test Group"})
+ member = Member.create!(%{email: "test@example.com"})
+ MemberGroup.create!(%{member_id: member.id, group_id: group.id})
+
+ assert :ok = Group.destroy(group)
+
+ # Group should be deleted
+ assert {:error, _} = Group.get(group.id)
+
+ # MemberGroup association should be deleted (CASCADE)
+ assert [] = MemberGroup.read!(filter: [group_id: group.id])
+
+ # Member should still exist
+ assert {:ok, _} = Member.get(member.id)
+ end
+
+ test "does not delete members themselves" do
+ group = Group.create!(%{name: "Test Group"})
+ member = Member.create!(%{email: "test@example.com"})
+ MemberGroup.create!(%{member_id: member.id, group_id: group.id})
+
+ Group.destroy!(group)
+
+ # Member should still exist
+ assert {:ok, _} = Member.get(member.id)
+ end
+ end
+
+ describe "member_count calculation" do
+ test "returns 0 for empty group" do
+ group = Group.create!(%{name: "Empty Group"})
+ assert group.member_count == 0
+ end
+
+ test "returns correct count when members added" do
+ group = Group.create!(%{name: "Test Group"})
+ member1 = Member.create!(%{email: "test1@example.com"})
+ member2 = Member.create!(%{email: "test2@example.com"})
+
+ MemberGroup.create!(%{member_id: member1.id, group_id: group.id})
+ MemberGroup.create!(%{member_id: member2.id, group_id: group.id})
+
+ # Reload group to get updated count
+ group = Group.get!(group.id, load: [:member_count])
+ assert group.member_count == 2
+ end
+
+ test "updates correctly when members removed" do
+ group = Group.create!(%{name: "Test Group"})
+ member = Member.create!(%{email: "test@example.com"})
+ mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id})
+
+ # Remove member
+ MemberGroup.destroy!(mg)
+
+ # Reload group
+ group = Group.get!(group.id, load: [:member_count])
+ assert group.member_count == 0
+ end
+ end
+end
+```
+
+#### MemberGroup Resource Tests
+
+**File:** `test/membership/member_group_test.exs`
+
+```elixir
+defmodule Mv.Membership.MemberGroupTest do
+ use Mv.DataCase
+ alias Mv.Membership.{MemberGroup, Member, Group}
+
+ describe "create_member_group/1" do
+ test "creates association between member and group" do
+ member = Member.create!(%{email: "test@example.com"})
+ group = Group.create!(%{name: "Test Group"})
+
+ attrs = %{member_id: member.id, group_id: group.id}
+ assert {:ok, mg} = MemberGroup.create(attrs)
+ assert mg.member_id == member.id
+ assert mg.group_id == group.id
+ end
+
+ test "prevents duplicate associations" do
+ member = Member.create!(%{email: "test@example.com"})
+ group = Group.create!(%{name: "Test Group"})
+ MemberGroup.create!(%{member_id: member.id, group_id: group.id})
+
+ attrs = %{member_id: member.id, group_id: group.id}
+ assert {:error, changeset} = MemberGroup.create(attrs)
+ assert %{member_id: ["has already been taken"]} = errors_on(changeset)
+ end
+
+ test "cascade deletes when member deleted" do
+ member = Member.create!(%{email: "test@example.com"})
+ group = Group.create!(%{name: "Test Group"})
+ mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id})
+
+ Member.destroy!(member)
+
+ # Association should be deleted
+ assert {:error, _} = MemberGroup.get(mg.id)
+ end
+
+ test "cascade deletes when group deleted" do
+ member = Member.create!(%{email: "test@example.com"})
+ group = Group.create!(%{name: "Test Group"})
+ mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id})
+
+ Group.destroy!(group)
+
+ # Association should be deleted
+ assert {:error, _} = MemberGroup.get(mg.id)
+ end
+ end
+end
+```
### Integration Tests
-**Group Management:**
-- Create group
-- Update group
-- Delete group with confirmation
-- Add member to group
-- Remove member from group
+#### Member-Group Relationships
-**Member-Group Relationships:**
-- Member can belong to multiple groups
-- Group can contain multiple members
-- Cascade delete when member deleted
-- Cascade delete when group deleted
+**File:** `test/membership/group_integration_test.exs`
+
+```elixir
+defmodule Mv.Membership.GroupIntegrationTest do
+ use Mv.DataCase
+ alias Mv.Membership.{Group, Member, MemberGroup}
+
+ describe "member-group relationships" do
+ test "member can belong to multiple groups" do
+ member = Member.create!(%{email: "test@example.com"})
+ group1 = Group.create!(%{name: "Group 1"})
+ group2 = Group.create!(%{name: "Group 2"})
+
+ MemberGroup.create!(%{member_id: member.id, group_id: group1.id})
+ MemberGroup.create!(%{member_id: member.id, group_id: group2.id})
+
+ member = Member.get!(member.id, load: [:groups])
+ assert length(member.groups) == 2
+ assert Enum.any?(member.groups, &(&1.id == group1.id))
+ assert Enum.any?(member.groups, &(&1.id == group2.id))
+ end
+
+ test "group can contain multiple members" do
+ group = Group.create!(%{name: "Test Group"})
+ member1 = Member.create!(%{email: "test1@example.com"})
+ member2 = Member.create!(%{email: "test2@example.com"})
+
+ MemberGroup.create!(%{member_id: member1.id, group_id: group.id})
+ MemberGroup.create!(%{member_id: member2.id, group_id: group.id})
+
+ group = Group.get!(group.id, load: [:members])
+ assert length(group.members) == 2
+ end
+ end
+end
+```
### UI Tests
-**Groups Management:**
-- Groups index page loads
-- Create group form works
-- Edit group form works
-- Delete confirmation modal works
-- Name confirmation required for delete
+#### Groups Management
-**Member Overview:**
-- Groups column displays correctly
-- Group filter works
-- Group sorting works
-- URL params persist
+**File:** `test/mv_web/live/group_live/index_test.exs`
-**Member Detail:**
-- Groups section displays
-- Links to group pages work
+```elixir
+defmodule MvWeb.GroupLive.IndexTest do
+ use MvWeb.ConnCase
+ alias Mv.Membership.Group
+
+ describe "groups index page" do
+ test "lists all groups", %{conn: conn} do
+ group1 = Group.create!(%{name: "Group 1", description: "First group"})
+ group2 = Group.create!(%{name: "Group 2", description: "Second group"})
+
+ {:ok, view, _html} = live(conn, ~p"/groups")
+
+ assert render(view) =~ "Group 1"
+ assert render(view) =~ "Group 2"
+ end
+
+ test "creates new group", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/groups")
+
+ view
+ |> element("button", "New Group")
+ |> render_click()
+
+ view
+ |> form("#group-form", group: %{name: "New Group", description: "Description"})
+ |> render_submit()
+
+ assert render(view) =~ "New Group"
+ end
+
+ test "deletes group with confirmation", %{conn: conn} do
+ group = Group.create!(%{name: "To Delete"})
+
+ {:ok, view, _html} = live(conn, ~p"/groups")
+
+ # Click delete
+ view
+ |> element("button[phx-click='delete']", "Delete")
+ |> render_click()
+
+ # Enter group name to confirm
+ view
+ |> form("#delete-group-modal form", name: "To Delete")
+ |> render_change()
+
+ # Confirm deletion
+ view
+ |> element("#delete-group-modal button", "Delete Group")
+ |> render_click()
+
+ assert render(view) =~ "Group deleted successfully"
+ assert {:error, _} = Group.get(group.id)
+ end
+ end
+end
+```
+
+#### Member Overview Integration
+
+**File:** `test/mv_web/live/member_live/index_groups_test.exs`
+
+```elixir
+defmodule MvWeb.MemberLive.IndexGroupsTest do
+ use MvWeb.ConnCase
+ alias Mv.Membership.{Member, Group, MemberGroup}
+
+ describe "groups in member overview" do
+ test "displays group badges", %{conn: conn} do
+ member = Member.create!(%{email: "test@example.com"})
+ group = Group.create!(%{name: "Test Group"})
+ MemberGroup.create!(%{member_id: member.id, group_id: group.id})
+
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ assert render(view) =~ "Test Group"
+ end
+
+ test "filters members by group", %{conn: conn} do
+ member1 = Member.create!(%{email: "test1@example.com"})
+ member2 = Member.create!(%{email: "test2@example.com"})
+ group = Group.create!(%{name: "Test Group"})
+ MemberGroup.create!(%{member_id: member1.id, group_id: group.id})
+
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ # Select group filter
+ view
+ |> element("#group-filter")
+ |> render_change(%{"group_filter" => group.id})
+
+ html = render(view)
+ assert html =~ "test1@example.com"
+ refute html =~ "test2@example.com"
+ end
+ end
+end
+```
---