Files
paliad/internal/services/name_template_test.go
mAi a05ae1f2ae feat(settings): firm-wide default name compositions (t-paliad-356 Slice 5)
Completes the nomen train (S1–S5). Adds the FIRM tier of the name-composition
precedence chain — per-document → user → FIRM → system (PRD §3.1/§3.2) —
mirroring firm_dashboard_default exactly.

Storage + service:
- Migration 162: paliad.firm_name_compositions singleton (id=1, CHECK id=1,
  RLS read-all + service-role writes) — same shape as firm_dashboard_default
  (mig 117), holding a validated { artifact_id: Composition } jsonb map.
- FirmNameCompositionService (Get/Set/Clear) + getFirmNameCompositions /
  setFirmNameCompositions / clearFirmNameCompositions singleton helpers in
  name_composition_spec.go.

Resolution:
- resolveComposition is now variadic over ordered specs (user, firm); first
  valid wins, else system default. Existing single-spec callers unchanged.
- Render path threads the firm tier: renderSubmissionDraftTitle /
  RenderSubmissionFilenameFor gain a firm param; newDraftName +
  submissionDownloadFilename load it (nil-safe). A firm default thus changes
  the effective name for every user without a personal override.

Admin surface (mirrors firm_dashboard_default):
- GET/PUT/DELETE /api/admin/name-compositions{/artifact_id} (adminGate) read
  back / set / clear the firm default per artifact.
- /settings Namensschemata cards gain an admin-only "Firmenstandard" block
  (set from the current template field / clear) revealed via is_admin, plus a
  "Firmenstandard" badge for non-admin users whose effective name comes from
  the firm tier. SettingsNameArtifact now resolves user→firm→system and
  exposes firm_is_set/firm_template.

Tests: pure precedence (user>firm>system) + firm-tier view + live firm
round-trip/Validate-rejection (via db.ApplyMigrations). go vet, go test ./...,
bun build all clean; gated live tests green against TEST_DATABASE_URL.

NOTE (merge ordering): golang-migrate is forward-only. Migration 162 must not
reach a DB before bohr's 161 (Rubrum Composer seed) exists, or 161 will be
skipped (current>161 → never applied). Merge 161 before/with 162.

Browser Playwright of the admin firm controls deferred to post-deploy
mai-tester — shared Supabase login wall blocks pre-merge browser login (same
ceiling as t-paliad-354).
2026-06-01 13:04:11 +02:00

175 lines
7.3 KiB
Go

package services
import (
"regexp"
"strings"
"testing"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
// TestParseNameTemplate_RoundTripsSystemDefaults asserts the system-default
// compositions survive Template() -> ParseNameTemplate unchanged in
// Var/Sep/Wrap, with MissingRules re-overlaid from the default. This is the
// guard that the settings shorthand is a faithful authoring view of the seed.
func TestParseNameTemplate_RoundTripsSystemDefaults(t *testing.T) {
for _, id := range []string{ArtifactSubmissionDraftTitle, ArtifactSubmissionDocxFilename} {
art, _ := NameArtifact(id)
tmpl := art.SystemDefault.Template()
got, err := ParseNameTemplate(id, tmpl)
if err != nil {
t.Fatalf("%s: ParseNameTemplate(%q): %v", id, tmpl, err)
}
want := art.SystemDefault
if len(got.Segments) != len(want.Segments) {
t.Fatalf("%s: %d segments, want %d", id, len(got.Segments), len(want.Segments))
}
for i, seg := range got.Segments {
w := want.Segments[i]
if seg.Var != w.Var || seg.Sep != w.Sep || seg.Wrap != w.Wrap || seg.Missing != w.Missing {
t.Errorf("%s seg %d = %+v, want %+v", id, i, seg, w)
}
}
}
}
func TestParseNameTemplate_Errors(t *testing.T) {
cases := []struct {
name, artifact, template string
}{
{"unknown artifact", "nope", "{date}"},
{"unknown variable", ArtifactSubmissionDocxFilename, "{date} {client}"}, // client not in filename catalog
{"malformed", ArtifactSubmissionDraftTitle, "{date"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := ParseNameTemplate(c.artifact, c.template); err == nil {
t.Errorf("expected error, got nil")
}
})
}
}
// TestPreviewNameComposition_SystemDefaults asserts the fixed-sample previews
// match the two shipped schemes. The date is render-time today, so only its
// shape is checked; the rest is byte-exact.
func TestPreviewNameComposition_SystemDefaults(t *testing.T) {
titleTmpl, _ := NameArtifact(ArtifactSubmissionDraftTitle)
full, empty, err := PreviewNameComposition(ArtifactSubmissionDraftTitle, titleTmpl.SystemDefault.Template())
if err != nil {
t.Fatalf("title preview: %v", err)
}
if !datePrefix.MatchString(full) {
t.Errorf("title full preview %q has no leading date", full)
}
if !strings.HasSuffix(full, " Bayer AG ./. UPC ./. Sandoz") {
t.Errorf("title full preview = %q, want date + ' Bayer AG ./. UPC ./. Sandoz'", full)
}
if !datePrefix.MatchString(empty) || strings.ContainsAny(empty, " ") {
t.Errorf("title empty preview = %q, want bare date (all party segments omitted)", empty)
}
fnTmpl, _ := NameArtifact(ArtifactSubmissionDocxFilename)
full, empty, err = PreviewNameComposition(ArtifactSubmissionDocxFilename, fnTmpl.SystemDefault.Template())
if err != nil {
t.Fatalf("filename preview: %v", err)
}
if !strings.HasSuffix(full, " submission (UPC_CFI_123_2026).docx") {
// '/' in the sample case number is sanitised to '_' by the filename target.
t.Errorf("filename full preview = %q, want date + ' submission (UPC_CFI_123_2026).docx'", full)
}
if !strings.HasSuffix(empty, " submission (Az. folgt).docx") {
t.Errorf("filename empty preview = %q, want date + ' submission (Az. folgt).docx'", empty)
}
}
// TestSettingsNameArtifacts_OverrideShown asserts a stored override surfaces as
// IsOverride with its own template, while the untouched artifact stays system.
func TestSettingsNameArtifacts_OverrideShown(t *testing.T) {
override := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
}}
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: override}
views := SettingsNameArtifacts(spec, nil)
if len(views) != 2 {
t.Fatalf("got %d views, want 2", len(views))
}
byID := map[string]NameCompositionView{}
for _, v := range views {
byID[v.ArtifactID] = v
}
if v := byID[ArtifactSubmissionDocxFilename]; !v.IsOverride || v.Template != "{date} {keyword}" {
t.Errorf("filename view = %+v, want IsOverride + template '{date} {keyword}'", v)
}
if v := byID[ArtifactSubmissionDraftTitle]; v.IsOverride {
t.Errorf("title view should be system default (no override), got IsOverride")
}
// Order is fixed: title first, filename second.
if views[0].ArtifactID != ArtifactSubmissionDraftTitle || views[1].ArtifactID != ArtifactSubmissionDocxFilename {
t.Errorf("artifact order = [%s %s], want [title filename]", views[0].ArtifactID, views[1].ArtifactID)
}
}
// TestSettingsNameArtifact_FirmTier asserts the firm tier shows through when
// the user has no override, and that a user override still wins over the firm
// default. Mirrors the precedence user → firm → system.
func TestSettingsNameArtifact_FirmTier(t *testing.T) {
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
}}
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
// No user override → effective template is the firm default; FirmIsSet set.
v, ok := SettingsNameArtifact(ArtifactSubmissionDocxFilename, nil, firm)
if !ok {
t.Fatal("artifact not found")
}
if v.IsOverride {
t.Errorf("IsOverride should be false (no user override), got true")
}
if !v.FirmIsSet || v.FirmTemplate != "{date} {keyword}" {
t.Errorf("firm tier = (set=%v, tmpl=%q), want (true, '{date} {keyword}')", v.FirmIsSet, v.FirmTemplate)
}
if v.Template != "{date} {keyword}" {
t.Errorf("effective template = %q, want firm default '{date} {keyword}'", v.Template)
}
// A user override beats the firm default in the effective template.
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Missing: nomen.Omit()},
}}
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
v, _ = SettingsNameArtifact(ArtifactSubmissionDocxFilename, user, firm)
if !v.IsOverride || v.Template != "{date}" {
t.Errorf("user override should win: IsOverride=%v template=%q, want true '{date}'", v.IsOverride, v.Template)
}
if !v.FirmIsSet {
t.Errorf("FirmIsSet should remain true even when user override wins")
}
}
// TestResolveComposition_Precedence pins the render-path precedence: user beats
// firm beats system; nil/empty tiers are skipped.
func TestResolveComposition_Precedence(t *testing.T) {
sys := nameArtifacts[ArtifactSubmissionDocxFilename].SystemDefault
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "date", Missing: nomen.Omit()}}}
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "keyword", Missing: nomen.Literal("x")}}}
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, nil); len(got.Segments) != len(sys.Segments) {
t.Errorf("no overrides → system default, got %d segments", len(got.Segments))
}
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, firm); got.Template() != firmComp.Template() {
t.Errorf("firm beats system: got %q", got.Template())
}
if got := resolveComposition(ArtifactSubmissionDocxFilename, user, firm); got.Template() != userComp.Template() {
t.Errorf("user beats firm: got %q", got.Template())
}
}