Files
paliad/internal/services/submission_section_service_live_test.go
mAi e2969fc358
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(submissions): Composer Slice A — base picker + read-only section list (m/paliad#141)
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
2026-05-26 19:23:40 +02:00

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)
}
}
})
}