The first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.
Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
(slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
users, mutations admin-only (handler-enforced, no RLS write paths).
Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
_skeleton.docx. Each section_spec carries the 10-section default
(letterhead, caption, introduction, requests, facts, legal_argument,
evidence, exhibits, closing, signature) with bilingual labels +
bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
composer_meta jsonb (default '{}'). Purely additive; pre-Composer
drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
section_key, order_index, kind ∈ {prose,requests,evidence},
label_de/en, included, content_md_de/en). RLS mirrors
submission_drafts (owner-scoped + can_see_project, four policies).
Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
the firm default base and seeds base_id + section rows in one tx.
Composer wiring is additive — when bases==nil the service stays
v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
on both project-scoped and global drafts accept "base_id".
Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
(hidden until catalog loads); section-list pane above the preview
(hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
paintBasePicker rebuilds <option> list on every paint; onBaseChange
PATCHes base_id and repaints; paintSectionList renders each section
read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
Pre-Composer drafts get the picker (catalog still loads) but the
section pane stays hidden until they pick a base on a new draft.
Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
(pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
seeded 10 default sections on both bases; GetDefaultForCode picks
hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
base_id + 10 section rows in tx with ascending order_index and
bilingual labels populated.
NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.
Build hygiene: go build/vet/test -short clean; bun run build clean
(2900 i18n keys, data-i18n scan clean).
t-paliad-313
179 lines
6.2 KiB
Go
179 lines
6.2 KiB
Go
package services
|
|
|
|
// Live-DB integration tests for the Composer seeding flow (t-paliad-313
|
|
// Slice A). Skipped when TEST_DATABASE_URL is unset, mirroring the
|
|
// other live-DB tests (see cansee_test.go for the bootstrap pattern).
|
|
//
|
|
// Covers:
|
|
// 1. Mig 146 seeded the catalog: hlc-letterhead + neutral both
|
|
// resolve via GetBySlug and carry 10 section defaults each.
|
|
// 2. BaseService.GetDefaultForCode picks the firm-matched base for a
|
|
// canonical submission_code (e.g. de.inf.lg.erwidg) — Slice A
|
|
// contract that drives new-draft seeding.
|
|
// 3. SubmissionDraftService.Create on a fresh draft seeds base_id +
|
|
// 10 submission_sections rows in one transaction, with order_index
|
|
// ascending and bilingual labels populated.
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
func TestComposerSeedFlow(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()
|
|
|
|
bases := NewBaseService(pool)
|
|
|
|
t.Run("seed catalog: hlc-letterhead has 10 default sections", func(t *testing.T) {
|
|
b, err := bases.GetBySlug(ctx, "hlc-letterhead")
|
|
if err != nil {
|
|
t.Fatalf("GetBySlug(hlc-letterhead): %v", err)
|
|
}
|
|
if got := len(b.SectionSpec.Defaults); got != 10 {
|
|
t.Errorf("len(Defaults) = %d; want 10", got)
|
|
}
|
|
if b.SectionSpec.Stylemap["heading_1"] != "HLpat-Heading-H1" {
|
|
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", b.SectionSpec.Stylemap["heading_1"])
|
|
}
|
|
// Verify the section order is strictly ascending.
|
|
prev := 0
|
|
for _, d := range b.SectionSpec.Defaults {
|
|
if d.OrderIndex <= prev {
|
|
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", d.OrderIndex, prev, d.SectionKey)
|
|
}
|
|
prev = d.OrderIndex
|
|
}
|
|
})
|
|
|
|
t.Run("seed catalog: neutral exists with universal stylemap", func(t *testing.T) {
|
|
b, err := bases.GetBySlug(ctx, "neutral")
|
|
if err != nil {
|
|
t.Fatalf("GetBySlug(neutral): %v", err)
|
|
}
|
|
if b.SectionSpec.Stylemap["heading_1"] != "Heading 1" {
|
|
t.Errorf("neutral Stylemap[heading_1] = %q; want \"Heading 1\"", b.SectionSpec.Stylemap["heading_1"])
|
|
}
|
|
})
|
|
|
|
t.Run("GetDefaultForCode firm match", func(t *testing.T) {
|
|
// HLC + de.inf.lg.erwidg → hlc-letterhead (firm-matched).
|
|
b, err := bases.GetDefaultForCode(ctx, "HLC", "de.inf.lg.erwidg")
|
|
if err != nil {
|
|
t.Fatalf("GetDefaultForCode HLC: %v", err)
|
|
}
|
|
if b.Slug != "hlc-letterhead" {
|
|
t.Errorf("Slug = %q; want hlc-letterhead", b.Slug)
|
|
}
|
|
})
|
|
|
|
t.Run("GetDefaultForCode falls back to neutral when no firm hint", func(t *testing.T) {
|
|
b, err := bases.GetDefaultForCode(ctx, "", "de.inf.lg.erwidg")
|
|
if err != nil {
|
|
t.Fatalf("GetDefaultForCode no-firm: %v", err)
|
|
}
|
|
// Without a firm hint, the fallback chain skips firm-matched
|
|
// queries and lands on the firm-NULL neutral base.
|
|
if b.Slug != "neutral" {
|
|
t.Errorf("Slug = %q; want neutral (firm-NULL fallback)", b.Slug)
|
|
}
|
|
})
|
|
|
|
// Section seeding via SubmissionDraftService.Create — exercises the
|
|
// transactional INSERT path. Requires a real auth.users + paliad.users
|
|
// row because submission_drafts.user_id is FK-constrained.
|
|
t.Run("SubmissionDraftService.Create seeds 10 section rows", func(t *testing.T) {
|
|
userID := uuid.New()
|
|
cleanup := func() {
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
|
|
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)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
email := "composer-seed-" + userID.String()[:8] + "@hlc.com"
|
|
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, 'Composer Seed', '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)
|
|
sections := NewSectionService(pool)
|
|
drafts.AttachComposer(bases, sections, "HLC")
|
|
|
|
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if d.BaseID == nil {
|
|
t.Fatalf("BaseID = nil; want seeded base reference")
|
|
}
|
|
// hlc-letterhead is the firm default for HLC.
|
|
base, _ := bases.GetByID(ctx, *d.BaseID)
|
|
if base == nil || base.Slug != "hlc-letterhead" {
|
|
t.Errorf("seeded base slug = %v; want hlc-letterhead", base)
|
|
}
|
|
|
|
secs, err := sections.ListForDraft(ctx, d.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListForDraft: %v", err)
|
|
}
|
|
if len(secs) != 10 {
|
|
t.Errorf("section count = %d; want 10", len(secs))
|
|
}
|
|
// Verify section_key set + bilingual labels populated.
|
|
wantKeys := map[string]bool{
|
|
"letterhead": false, "caption": false, "introduction": false,
|
|
"requests": false, "facts": false, "legal_argument": false,
|
|
"evidence": false, "exhibits": false, "closing": false, "signature": false,
|
|
}
|
|
prev := 0
|
|
for _, sec := range secs {
|
|
wantKeys[sec.SectionKey] = true
|
|
if sec.OrderIndex <= prev {
|
|
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", sec.OrderIndex, prev, sec.SectionKey)
|
|
}
|
|
prev = sec.OrderIndex
|
|
if sec.LabelDE == "" || sec.LabelEN == "" {
|
|
t.Errorf("section %s missing bilingual label: de=%q en=%q", sec.SectionKey, sec.LabelDE, sec.LabelEN)
|
|
}
|
|
}
|
|
for k, seen := range wantKeys {
|
|
if !seen {
|
|
t.Errorf("missing seeded section_key: %s", k)
|
|
}
|
|
}
|
|
})
|
|
}
|