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).
168 lines
6.0 KiB
Go
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")
|
|
}
|
|
}
|