Files
paliad/internal/services/name_composition_live_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

168 lines
6.0 KiB
Go

package services
// Live-DB gate for the system→user name-composition precedence
// (t-paliad-356 Slice 3, PRD §3). Skipped without TEST_DATABASE_URL.
//
// Covers: (a) users.name_compositions round-trip via Set/Get + write-time
// Validate rejection; (b) a user override beating the system default for both
// the draft-title artifact (through Create) and the .docx-filename artifact
// (through RenderSubmissionFilenameFor); (c) the legacy
// composer_meta.filename_keyword reading cleanly as name_overrides.keyword.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
func TestNameCompositions_Precedence_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
email := "nc-" + userID.String()[:8] + "@hlc.com"
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
defer cleanup()
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'NameComp Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
date := todayBerlinDate()
// (a) Round-trip + Validate ------------------------------------------
validSpec := NameCompositionSpec{
ArtifactSubmissionDocxFilename: {
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
},
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, validSpec); err != nil {
t.Fatalf("set valid spec: %v", err)
}
got, err := drafts.UserNameCompositions(ctx, userID)
if err != nil {
t.Fatalf("get spec: %v", err)
}
if comp, ok := got[ArtifactSubmissionDocxFilename]; !ok || len(comp.Segments) != 1 || comp.Segments[0].Var != "keyword" {
t.Fatalf("round-trip mismatch: %+v", got)
}
// An override referencing a variable outside the artifact catalog is
// rejected on write.
badSpec := NameCompositionSpec{
ArtifactSubmissionDocxFilename: {
Version: nomen.Version,
Segments: []nomen.Segment{{Var: "opponent"}}, // not a filename variable
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, badSpec); err == nil {
t.Fatalf("invalid spec was accepted on write")
}
// (b1) Title override beats system default (through Create) ----------
titleOverride := NameCompositionSpec{
ArtifactSubmissionDraftTitle: {
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "keyword", Sep: " ", Missing: nomen.Omit()},
{Var: "date", Sep: "", Missing: nomen.Omit()},
},
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, titleOverride); err != nil {
t.Fatalf("set title override: %v", err)
}
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("create with title override: %v", err)
}
// System default would be "<date> Klageerwiderung"; the override flips
// the order to "<keyword> <date>".
if want := "Klageerwiderung " + date; d.Name != want {
t.Errorf("title override not applied: name = %q, want %q", d.Name, want)
}
// (b2) Filename override beats system default ------------------------
fnOverride := NameCompositionSpec{
ArtifactSubmissionDocxFilename: {
Version: nomen.Version,
Segments: []nomen.Segment{
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
},
},
}
if err := drafts.SetUserNameCompositions(ctx, userID, fnOverride); err != nil {
t.Fatalf("set filename override: %v", err)
}
overrides, err := drafts.UserNameCompositions(ctx, userID)
if err != nil {
t.Fatalf("load overrides: %v", err)
}
rule := &models.DeadlineRule{Name: "Klageerwiderung"}
proj := &models.Project{CaseNumber: strPtr("UPC_CFI_1_2026")}
// System default would be "<date> Klageerwiderung (UPC_CFI_1_2026).docx";
// the override reduces it to just the keyword.
if got := RenderSubmissionFilenameFor(overrides, nil, rule, proj, "de", ""); got != "Klageerwiderung.docx" {
t.Errorf("filename override not applied: %q, want %q", got, "Klageerwiderung.docx")
}
// And the system default (nil overrides) is unchanged.
if got := RenderSubmissionFilename(rule, proj, "de", ""); got != date+" Klageerwiderung (UPC_CFI_1_2026).docx" {
t.Errorf("system default filename drifted: %q", got)
}
// (c) Legacy filename_keyword reads back-compat ----------------------
dLegacy, err := drafts.Create(ctx, userID, nil, "de.inf.lg.duplik", "de")
if err != nil {
t.Fatalf("create legacy draft: %v", err)
}
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.submission_drafts SET composer_meta = '{"filename_keyword":"LegacyKW"}'::jsonb WHERE id = $1`,
dLegacy.ID); err != nil {
t.Fatalf("seed legacy composer_meta: %v", err)
}
reloaded, err := drafts.Get(ctx, userID, dLegacy.ID)
if err != nil {
t.Fatalf("get legacy draft: %v", err)
}
if kw := SubmissionFilenameKeyword(reloaded); kw != "LegacyKW" {
t.Errorf("legacy filename_keyword back-compat read = %q, want %q", kw, "LegacyKW")
}
}