Files
paliad/internal/services/checklist_template_service_test.go
mAi c3cd51eb85 feat(checklists): t-paliad-225 Slice B backend — explicit sharing + admin promotion
m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.

Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
  recipient via xor-check: recipient_kind in {user, office,
  partner_unit, project} with exactly one matching recipient_* column
  populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
  duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
  INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
  no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
  share branch uses inline ltree walk over projects.path because
  can_see_project reads auth.uid() (NULL on service-role connection,
  same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
  set; accepts the matching kind/recipient pair

Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
  required FK target, friendly 409 on partial-unique-index conflict),
  Revoke (owner or global_admin), ListGrants (owner or global_admin;
  enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
  + promoted_at/by + audit), Demote (global_admin → target visibility,
  default 'firm', clears promoted_at/by; rejects demote of non-global
  rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
  include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
  'shared' as an author-set value; 'global' stays admin-only

Endpoints:
- GET    /api/checklists/templates/{slug}/shares  — list grants (owner/admin)
- POST   /api/checklists/templates/{slug}/shares  — grant
- DELETE /api/checklists/shares/{id}              — revoke
- POST   /api/admin/checklists/{slug}/promote     — promote to global
- POST   /api/admin/checklists/{slug}/demote      — demote (body.target default 'firm')

Audit (paliad.system_audit_log):
- checklist.shared      — recipient_kind + recipient_id in metadata
- checklist.unshared    — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted     — target_visibility

Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.

Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
2026-05-20 15:38:30 +02:00

130 lines
4.6 KiB
Go

package services
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestSlugifyTitle(t *testing.T) {
cases := []struct{ in, want string }{
{"UPC Klageschrift Strategie", "upc-klageschrift-strategie"},
{"Hülle für Münch (München!)", "huelle-fuer-muench-muenchen"},
{" ", ""},
{"&&&", ""},
{"A really really really really long title that ought to be clamped to forty chars max", "a-really-really-really-really-long-title"},
{"Straße ABC", "strasse-abc"},
{"---leading-and-trailing---", "leading-and-trailing"},
}
for _, c := range cases {
got := slugifyTitle(c.in)
if got != c.want {
t.Errorf("slugifyTitle(%q) = %q; want %q", c.in, got, c.want)
}
}
}
func TestNormaliseRegime(t *testing.T) {
for _, valid := range []string{"upc", "DE", " epa ", "Other", ""} {
if _, err := normaliseRegime(valid); err != nil {
t.Errorf("normaliseRegime(%q) errored unexpectedly: %v", valid, err)
}
}
if _, err := normaliseRegime("bogus"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("normaliseRegime(bogus) expected ErrInvalidInput, got %v", err)
}
}
func TestNormaliseLang(t *testing.T) {
for _, valid := range []string{"de", "EN", " ", ""} {
if _, err := normaliseLang(valid); err != nil {
t.Errorf("normaliseLang(%q) errored: %v", valid, err)
}
}
if _, err := normaliseLang("fr"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("normaliseLang(fr) expected ErrInvalidInput, got %v", err)
}
}
func TestNormaliseSliceAVisibility(t *testing.T) {
// Slice B opened up 'shared' as a valid author-set visibility
// (alongside 'private' and 'firm'). 'global' stays admin-only via
// ChecklistPromotionService.
for _, valid := range []string{"private", "firm", "shared", " ", ""} {
if _, err := normaliseSliceAVisibility(valid); err != nil {
t.Errorf("visibility(%q) errored: %v", valid, err)
}
}
for _, bad := range []string{"global", "public"} {
if _, err := normaliseSliceAVisibility(bad); !errors.Is(err, ErrInvalidInput) {
t.Errorf("visibility(%q) expected ErrInvalidInput, got %v", bad, err)
}
}
}
func TestRequireNonEmptyTrimmed(t *testing.T) {
if _, err := requireNonEmptyTrimmed(" ", "title", 200); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty title should be rejected, got %v", err)
}
if got, err := requireNonEmptyTrimmed(" hello ", "title", 200); err != nil || got != "hello" {
t.Errorf("expected 'hello', got %q (err=%v)", got, err)
}
if _, err := requireNonEmptyTrimmed(strings.Repeat("x", 201), "title", 200); !errors.Is(err, ErrInvalidInput) {
t.Errorf("over-length title should be rejected, got %v", err)
}
}
func TestValidateBodyShape(t *testing.T) {
// Happy path: at least one group, at least one item.
ok := json.RawMessage(`{"groups":[{"titleDE":"G1","titleEN":"G1","items":[{"labelDE":"X","labelEN":"X"}]}]}`)
if err := validateBodyShape(ok); err != nil {
t.Errorf("valid body rejected: %v", err)
}
// Empty groups.
if err := validateBodyShape(json.RawMessage(`{"groups":[]}`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty groups expected ErrInvalidInput, got %v", err)
}
// Group with no items.
if err := validateBodyShape(json.RawMessage(`{"groups":[{"titleDE":"G","titleEN":"G","items":[]}]}`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty items expected ErrInvalidInput, got %v", err)
}
// Missing field.
if err := validateBodyShape(json.RawMessage(nil)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("nil body expected ErrInvalidInput, got %v", err)
}
// Malformed JSON.
if err := validateBodyShape(json.RawMessage(`{not json`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("malformed body expected ErrInvalidInput, got %v", err)
}
}
func TestChecklistCatalogIsStaticSlug(t *testing.T) {
// nil DB is fine — we never touch it in this test.
cat := NewChecklistCatalogService(nil)
if !cat.IsStaticSlug("upc-statement-of-claim") {
t.Error("expected static slug to be detected")
}
if cat.IsStaticSlug("u-some-authored-slug") {
t.Error("unexpected static-slug match for authored slug")
}
}
func TestChecklistVisibilityPredicate(t *testing.T) {
got := checklistVisibilityPredicate("c", 1)
for _, want := range []string{"c.owner_id = $1", "c.visibility IN ('firm', 'global')", "u.global_role = 'global_admin'"} {
if !strings.Contains(got, want) {
t.Errorf("predicate missing %q in: %s", want, got)
}
}
}
func TestClampFreeText(t *testing.T) {
if got := clampFreeText(" hello ", 200); got != "hello" {
t.Errorf("expected trimmed 'hello', got %q", got)
}
if got := clampFreeText(strings.Repeat("x", 250), 200); len(got) != 200 {
t.Errorf("expected clamp to 200, got len=%d", len(got))
}
}