Persistence foundation for authoring (slice 6) + generation-on-templates
(slice 7). docforge owns no tables — it defines the contract; paliad
implements it (litigationplanner pattern).
Migration 158_docforge_templates (additive, generic — NOT submission_*-named
so a second docforge consumer reuses it):
- templates — catalog row; current_version_id pins the live
version (FK added post-create to break the
templates<->versions cycle; ON DELETE SET NULL).
- template_versions — immutable snapshots; carrier .docx in a bytea
column (the TemplateStore bytea backend) + stylemap
jsonb. Versioning = snapshot-at-create (PRD A3).
- template_slots — variable slots per version; anchor = sentinel token
locating the slot in the carrier OOXML (PRD §5
lean), slot_key = the bound variable.
RLS mirrors submission_bases: firm-shared SELECT for authenticated,
mutations admin-only + gated in Go (no mutation policy = denied).
docforge root: TemplateStore interface + neutral types (TemplateMeta,
Template, TemplateSlot, *Input, TemplateFilter) + ErrTemplateNotFound.
CarrierBytes is format-opaque []byte so the root never imports the docx
adapter; the exporter wraps (CarrierBytes, Stylemap) into a docx.Carrier.
paliad: PgTemplateStore (sqlx, follows the submission_base_service pattern):
List / Get (current version) / GetVersion (pinned snapshot) / Create
(version 1 + pin) / AddVersion (next version + re-pin), all transactional.
Gated live round-trip test (TEST_DATABASE_URL) covers carrier+stylemap+slot
round-trip and the version bump. No handler wires this yet (PRD: no UI in
slice 4).
Verification: go build ./... clean, go vet clean, gofmt clean, full module
test green, migration NoDuplicateSlot structural test green.
m/paliad#157
147 lines
4.7 KiB
Go
147 lines
4.7 KiB
Go
package services
|
|
|
|
// Live-DB integration tests for PgTemplateStore (t-paliad-349 slice 4).
|
|
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB
|
|
// tests. Exercises the full round-trip: Create (version 1) → Get →
|
|
// GetVersion → AddVersion (version 2, current re-pointed) → List, asserting
|
|
// the carrier bytes, stylemap, and slots persist and resolve intact.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"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/pkg/docforge"
|
|
)
|
|
|
|
func TestPgTemplateStore_RoundTrip(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()
|
|
store := NewPgTemplateStore(pool)
|
|
author := uuid.NewString()
|
|
|
|
carrierV1 := []byte("PK\x03\x04 fake docx carrier v1")
|
|
tmpl, err := store.Create(ctx,
|
|
docforge.TemplateMetaInput{
|
|
NameDE: "Test-Vorlage",
|
|
NameEN: "Test template",
|
|
Firm: "HLC",
|
|
CreatedBy: author,
|
|
},
|
|
docforge.TemplateVersionInput{
|
|
CarrierBytes: carrierV1,
|
|
Stylemap: map[string]string{"paragraph": "Normal", "heading_1": "Heading 1"},
|
|
Slots: []docforge.TemplateSlot{
|
|
{Key: "project.case_number", Anchor: "{{project.case_number}}", Label: "Aktenzeichen", OrderIndex: 0},
|
|
{Key: "parties.claimant.0.name", Anchor: "{{parties.claimant.0.name}}", OrderIndex: 1},
|
|
},
|
|
CreatedBy: author,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
// Clean up the row (cascades to versions + slots) regardless of outcome.
|
|
defer func() {
|
|
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE id = $1`, tmpl.ID)
|
|
}()
|
|
|
|
// --- Create assertions: version 1, defaults applied, content intact.
|
|
if tmpl.Version != 1 {
|
|
t.Errorf("Create version = %d; want 1", tmpl.Version)
|
|
}
|
|
if tmpl.Kind != "submission" || tmpl.SourceFormat != "docx" {
|
|
t.Errorf("defaults: kind=%q format=%q; want submission/docx", tmpl.Kind, tmpl.SourceFormat)
|
|
}
|
|
if !bytes.Equal(tmpl.CarrierBytes, carrierV1) {
|
|
t.Errorf("carrier round-trip mismatch: got %q", tmpl.CarrierBytes)
|
|
}
|
|
if tmpl.Stylemap["heading_1"] != "Heading 1" {
|
|
t.Errorf("stylemap[heading_1] = %q; want 'Heading 1'", tmpl.Stylemap["heading_1"])
|
|
}
|
|
if len(tmpl.Slots) != 2 {
|
|
t.Fatalf("len(slots) = %d; want 2", len(tmpl.Slots))
|
|
}
|
|
if tmpl.Slots[0].Key != "project.case_number" || tmpl.Slots[0].Label != "Aktenzeichen" {
|
|
t.Errorf("slot[0] = %+v; want project.case_number/Aktenzeichen", tmpl.Slots[0])
|
|
}
|
|
|
|
// --- Get by template id resolves the current version.
|
|
got, err := store.Get(ctx, tmpl.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got.Version != 1 || !bytes.Equal(got.CarrierBytes, carrierV1) || len(got.Slots) != 2 {
|
|
t.Errorf("Get current version mismatch: v=%d slots=%d", got.Version, len(got.Slots))
|
|
}
|
|
|
|
// --- AddVersion bumps to 2 and re-points current.
|
|
carrierV2 := []byte("PK\x03\x04 fake docx carrier v2 edited")
|
|
v2, err := store.AddVersion(ctx, tmpl.ID, docforge.TemplateVersionInput{
|
|
CarrierBytes: carrierV2,
|
|
Stylemap: map[string]string{"paragraph": "HLpat-Body-B0"},
|
|
Slots: []docforge.TemplateSlot{{Key: "today", Anchor: "{{today}}", OrderIndex: 0}},
|
|
CreatedBy: author,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddVersion: %v", err)
|
|
}
|
|
if v2.Version != 2 {
|
|
t.Errorf("AddVersion version = %d; want 2", v2.Version)
|
|
}
|
|
if !bytes.Equal(v2.CarrierBytes, carrierV2) || len(v2.Slots) != 1 || v2.Slots[0].Key != "today" {
|
|
t.Errorf("AddVersion content mismatch: carrier/slots wrong")
|
|
}
|
|
|
|
// Get now resolves version 2 (current re-pointed).
|
|
cur, err := store.Get(ctx, tmpl.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get after AddVersion: %v", err)
|
|
}
|
|
if cur.Version != 2 || !bytes.Equal(cur.CarrierBytes, carrierV2) {
|
|
t.Errorf("Get after AddVersion = v%d; want v2 with new carrier", cur.Version)
|
|
}
|
|
|
|
// --- List reflects the current version number, filtered by firm.
|
|
metas, err := store.List(ctx, docforge.TemplateFilter{Firm: "HLC", ActiveOnly: true})
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
var found *docforge.TemplateMeta
|
|
for i := range metas {
|
|
if metas[i].ID == tmpl.ID {
|
|
found = &metas[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("List did not return the created template")
|
|
}
|
|
if found.Version != 2 {
|
|
t.Errorf("List version = %d; want 2 (current)", found.Version)
|
|
}
|
|
|
|
// --- Unknown id → ErrTemplateNotFound.
|
|
if _, err := store.Get(ctx, uuid.NewString()); !errors.Is(err, docforge.ErrTemplateNotFound) {
|
|
t.Errorf("Get(unknown) err = %v; want ErrTemplateNotFound", err)
|
|
}
|
|
}
|