Files
paliad/internal/services/derivation_membership_scan_test.go
m bfc48b1420 fix(t-paliad-143): derived team members all show 'Attorney' + Herkunft collapses multi-unit users
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":

1. **All derived members labeled 'Attorney'.** Migration 055 added
   partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
   the column in the admin UI. So 100% of pum rows are 'attorney' and
   Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
   surfaces every member as 'attorney' even when they're really PAs.

2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
   ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
   wins, every other unit-membership dropped. Judith Molarinho Vaz +
   Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.

**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
  UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
  all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
  bool_or(derive_grants_authority), GROUP BY user. One row per user, every
  (unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
  doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
  directly into the Go struct. Pinned by unit test.

**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
  (unit, role) source — single-unit users render as before
  ("über: **Lehment** [Sicht]"); multi-unit users render
  "über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.

**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
  change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
  already shipped in t-paliad-139 Phase 2). Disables during request,
  rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
  admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.

**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).

**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
  returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
  and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
  unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).

After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
2026-05-06 17:16:17 +02:00

72 lines
2.0 KiB
Go

package services
import (
"testing"
"github.com/google/uuid"
)
// TestDerivedMembershipListScan covers the sql.Scanner over a Postgres
// jsonb column — the wire format that ListDerivedMembers' jsonb_agg
// returns. Pinned because if a future migration changes the JSON shape
// (e.g. drops a key), the rendered Herkunft column on /projects/{id}
// silently breaks (t-paliad-143).
func TestDerivedMembershipListScan(t *testing.T) {
unitA := uuid.New()
unitB := uuid.New()
cases := []struct {
name string
src any
want []DerivedMembership
}{
{
name: "nil",
src: nil,
want: nil,
},
{
name: "single membership as bytes",
src: []byte(`[{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"}]`),
want: []DerivedMembership{{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"}},
},
{
name: "two memberships as string",
src: `[
{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"},
{"unit_id":"` + unitB.String() + `","unit_name":"Plassmann","unit_role":"pa"}
]`,
want: []DerivedMembership{
{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"},
{UnitID: unitB, UnitName: "Plassmann", UnitRole: "pa"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got DerivedMembershipList
if err := got.Scan(tc.src); err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len: got %d want %d", len(got), len(tc.want))
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("row %d: got %+v want %+v", i, got[i], tc.want[i])
}
}
})
}
}
// TestDerivedMembershipListScanRejectsUnknown ensures we don't silently
// accept random column types and produce an empty list (which would mask
// a schema regression).
func TestDerivedMembershipListScanRejectsUnknown(t *testing.T) {
var l DerivedMembershipList
if err := l.Scan(123); err == nil {
t.Fatal("expected error scanning int into DerivedMembershipList, got nil")
}
}