Files
paliad/internal/services/submission_render_test.go
mAi d86cac0b53 feat(submissions): t-paliad-230 format-only .dotm→.docx convert
m's 2026-05-21 scope reduction of the t-paliad-215 submission generator:
ship a demo that hands the lawyer the firm style template as a clean
.docx. No variable-merge engine, no per-submission template registry,
no fallback chain — the merge slice is deferred to a future task.

Replaces the previous engine (template registry + variable bag +
{{placeholder}} renderer + dual project_events/documents writes) with:

* services.ConvertDotmToDocx — single-function .dotm/.docm/.dotx → .docx
  format converter that strips word/vbaProject.bin, word/vbaData.xml,
  word/customizations.xml, and word/_rels/vbaProject.bin.rels, rewrites
  [Content_Types].xml (demotes the macro/template main type to plain
  docx, drops the .bin Default Extension and the macro Overrides), and
  rewrites word/_rels/document.xml.rels to drop the vbaProject +
  keyMapCustomizations relationships. Idempotent on a plain .docx.
  archive/zip + regex stdlib only — no new third-party dependencies.

* handlers/submissions.go — POST /api/projects/{id}/submissions/{code}
  /generate fetches the cached HL Patents Style .dotm (via a new
  fetchHLPatentsStyleBytes accessor on files.go that shares the same
  cache as /files/{slug}), converts, writes one paliad.system_audit_log
  row (event_type='submission.generated', metadata={submission_code,
  rule_name, filename}), and streams the .docx as an attachment. GET
  /api/projects/{id}/submissions still lists filing rules but
  has_template is unconditionally true (one universal template).

* Filename per design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}
  .docx, with Umlauts ASCII-folded and slashes → underscores.

Drops services/submission_templates.go, services/submission_vars.go,
and the wiring in cmd/server/main.go + handlers/handlers.go that bound
them together. Frontend client switched to POST.

Verified the converter against the real HL Patents Style.dotm (361 KB
input → 243 KB output, 46 parts in output zip):

  unzip -tq /tmp/hl-patents-style.converted.docx   → No errors
  python3 -c "import zipfile, xml.etree.ElementTree as ET; \
              z=zipfile.ZipFile('/tmp/hl-patents-style.converted.docx'); \
              [ET.fromstring(z.read(p)) for p in z.namelist() if p.endswith('.xml')]"
  uv run --with python-docx python3 -c "import docx; \
              d=docx.Document('/tmp/hl-patents-style.converted.docx'); \
              print(len(d.paragraphs), 'paragraphs', len(d.styles), 'styles')"
              → 236 paragraphs, 168 styles, 1 section

All assertions passed: every Override in [Content_Types].xml resolves
to a real part, every internal Target in document.xml.rels resolves,
zero macro-related residue, and the document body + styles + theme
survive untouched.

go test -run TestBootSmoke ./cmd/server/... clean (route additions
register without conflict on the Go ServeMux).
2026-05-21 15:23:24 +02:00

256 lines
8.9 KiB
Go

package services
import (
"archive/zip"
"bytes"
"io"
"strings"
"testing"
"time"
)
// minimalDOTM builds a small .dotm zip whose shape mirrors the real
// HL Patents Style template: macro-enabled main content type, Default
// extension declaring .bin as vbaProject, Overrides for vbaData.xml +
// customizations.xml, document.xml.rels with vbaProject +
// keyMapCustomizations relationships, and the four macro parts on
// disk (vbaProject.bin + auxiliary rels + vbaData.xml +
// customizations.xml).
//
// In-memory so the test is self-contained (no checked-in binary).
// Word and LibreOffice would reject this minimal file as incomplete
// (no _rels/.rels root manifest); the tests work at the byte level
// and assert structural properties of the converted output.
func minimalDOTM(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
t.Helper()
w, err := zw.CreateHeader(&zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("zip header %s: %v", name, err)
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
add(contentTypesPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`+
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`+
`<Default Extension="xml" ContentType="application/xml"/>`+
`<Override PartName="/word/document.xml" ContentType="`+dotmMainContentType+`"/>`+
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`+
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`+
`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`+
`</Types>`)
add("word/document.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
`<w:body><w:p><w:r><w:t>Hello Paliad</w:t></w:r></w:p></w:body></w:document>`)
add(documentRelsPath,
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
`<Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>`+
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`+
`<Relationship Id="rId3" Type="http://schemas.microsoft.com/office/2006/relationships/keyMapCustomizations" Target="customizations.xml"/>`+
`</Relationships>`)
add("word/styles.xml", `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`)
add("word/vbaProject.bin", "PRETEND-VBA-BINARY-PAYLOAD")
add("word/_rels/vbaProject.bin.rels", `<?xml version="1.0"?><Relationships/>`)
add("word/vbaData.xml", `<?xml version="1.0"?><wne:vbaSuppData xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
add("word/customizations.xml", `<?xml version="1.0"?><wne:tcg xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}
func unzipEntries(t *testing.T, data []byte) map[string]string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("open output zip: %v", err)
}
out := make(map[string]string, len(zr.File))
for _, f := range zr.File {
rc, err := f.Open()
if err != nil {
t.Fatalf("open %s: %v", f.Name, err)
}
body, err := io.ReadAll(rc)
rc.Close()
if err != nil {
t.Fatalf("read %s: %v", f.Name, err)
}
out[f.Name] = string(body)
}
return out
}
func TestConvertDotmToDocx_StripsMacroParts(t *testing.T) {
dotm := minimalDOTM(t)
out, err := ConvertDotmToDocx(dotm)
if err != nil {
t.Fatalf("ConvertDotmToDocx: %v", err)
}
entries := unzipEntries(t, out)
for _, name := range []string{
"word/vbaProject.bin",
"word/_rels/vbaProject.bin.rels",
"word/vbaData.xml",
"word/customizations.xml",
} {
if _, ok := entries[name]; ok {
t.Errorf("output still contains %s", name)
}
}
if doc, ok := entries["word/document.xml"]; !ok {
t.Error("output is missing word/document.xml")
} else if !strings.Contains(doc, "Hello Paliad") {
t.Errorf("document body lost during conversion: %q", doc)
}
if _, ok := entries["word/styles.xml"]; !ok {
t.Error("output lost unrelated word/styles.xml")
}
ctypes, ok := entries[contentTypesPath]
if !ok {
t.Fatal("output is missing [Content_Types].xml")
}
if strings.Contains(ctypes, "macroEnabled") {
t.Errorf("output [Content_Types].xml still references a macro-enabled type: %q", ctypes)
}
if !strings.Contains(ctypes, docxMainContentType) {
t.Errorf("output is missing plain docx main content type: %q", ctypes)
}
if strings.Contains(ctypes, "vbaProject") {
t.Errorf("output [Content_Types].xml still references vbaProject: %q", ctypes)
}
if strings.Contains(ctypes, "vbaData") {
t.Errorf("output [Content_Types].xml still overrides vbaData: %q", ctypes)
}
if strings.Contains(ctypes, "keyMapCustomizations") {
t.Errorf("output [Content_Types].xml still overrides customizations: %q", ctypes)
}
if !strings.Contains(ctypes, "wordprocessingml.styles") {
t.Errorf("output lost unrelated styles Override: %q", ctypes)
}
rels, ok := entries[documentRelsPath]
if !ok {
t.Fatal("output is missing word/_rels/document.xml.rels")
}
if strings.Contains(rels, "vbaProject") {
t.Errorf("output rels still references vbaProject: %q", rels)
}
if strings.Contains(rels, "keyMapCustomizations") {
t.Errorf("output rels still references keyMapCustomizations: %q", rels)
}
if !strings.Contains(rels, "styles.xml") {
t.Errorf("output rels lost unrelated styles relationship: %q", rels)
}
}
func TestConvertDotmToDocx_IdempotentOnPlainDocx(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
add(contentTypesPath, `<?xml version="1.0"?>`+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Override PartName="/word/document.xml" ContentType="`+docxMainContentType+`"/>`+
`</Types>`)
add("word/document.xml", `<w:document/>`)
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
out, err := ConvertDotmToDocx(buf.Bytes())
if err != nil {
t.Fatalf("ConvertDotmToDocx: %v", err)
}
entries := unzipEntries(t, out)
if _, ok := entries["word/vbaProject.bin"]; ok {
t.Error("plain docx grew a vbaProject during conversion")
}
if ctypes := entries[contentTypesPath]; !strings.Contains(ctypes, docxMainContentType) {
t.Errorf("plain docx lost its content type: %q", ctypes)
}
}
func TestConvertDotmToDocx_AcceptsDocmAndDotx(t *testing.T) {
for _, mainType := range []string{docmMainContentType, dotxMainContentType} {
t.Run(mainType, func(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
w, _ := zw.Create(name)
_, _ = io.WriteString(w, body)
}
add(contentTypesPath, `<?xml version="1.0"?>`+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Override PartName="/word/document.xml" ContentType="`+mainType+`"/>`+
`</Types>`)
add("word/document.xml", `<w:document/>`)
zw.Close()
out, err := ConvertDotmToDocx(buf.Bytes())
if err != nil {
t.Fatalf("ConvertDotmToDocx: %v", err)
}
ctypes := unzipEntries(t, out)[contentTypesPath]
if strings.Contains(ctypes, mainType) {
t.Errorf("non-docx main type survived conversion: %q", ctypes)
}
if !strings.Contains(ctypes, docxMainContentType) {
t.Errorf("docx main type not present: %q", ctypes)
}
})
}
}
func TestConvertDotmToDocx_RejectsNonZip(t *testing.T) {
_, err := ConvertDotmToDocx([]byte("not a zip file"))
if err == nil {
t.Fatal("expected error for non-zip input, got nil")
}
}
func TestSanitiseSubmissionFileName(t *testing.T) {
cases := map[string]string{
"Klageerwiderung": "Klageerwiderung",
"Berufungsbegründung": "Berufungsbegruendung",
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
if got := SanitiseSubmissionFileName(in); got != want {
t.Errorf("SanitiseSubmissionFileName(%q) = %q, want %q", in, got, want)
}
})
}
}