package services // Unit tests for SubmissionComposer's pure splice logic — no DB // dependency. The end-to-end Compose path is exercised by the live // integration test in submission_section_service_live_test.go (Slice // A) once anchors land in the seeded .docx; this file covers the // anchor-splicing primitives and the section rendering glue. import ( "archive/zip" "bytes" "context" "strings" "testing" "github.com/google/uuid" ) // minimalBaseBytes builds a tiny .docx zip with one document.xml body // for the composer tests. The body content is provided by the caller // so different splice scenarios can be exercised in-process. func minimalBaseBytes(t *testing.T, body string) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) parts := map[string]string{ "[Content_Types].xml": ` `, "_rels/.rels": ` `, "word/document.xml": ` ` + body + ` `, } for name, contents := range parts { w, err := zw.Create(name) if err != nil { t.Fatalf("zip create %s: %v", name, err) } if _, err := w.Write([]byte(contents)); err != nil { t.Fatalf("zip write %s: %v", name, err) } } if err := zw.Close(); err != nil { t.Fatalf("zip close: %v", err) } return buf.Bytes() } // extractDocumentXML pulls word/document.xml out of a .docx zip for // assertions. func extractDocumentXML(t *testing.T, data []byte) string { return extractZipEntry(t, data, "word/document.xml") } // extractZipEntry pulls any named entry out of a .docx zip. func extractZipEntry(t *testing.T, data []byte, name string) string { t.Helper() zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("open zip: %v", err) } for _, f := range zr.File { if f.Name != name { continue } rc, err := f.Open() if err != nil { t.Fatalf("open %s: %v", name, err) } defer rc.Close() var buf bytes.Buffer if _, err := buf.ReadFrom(rc); err != nil { t.Fatalf("read %s: %v", name, err) } return buf.String() } t.Fatalf("%s not found in zip", name) return "" } // composerBase returns a SubmissionBase wired with the neutral // stylemap for composer tests. func composerBase() *SubmissionBase { return &SubmissionBase{ ID: uuid.New(), Slug: "test-base", SectionSpec: BaseSectionSpec{ Version: 1, Stylemap: map[string]string{ "paragraph": "Normal", }, }, } } func TestComposer_AppendMode_NoAnchors(t *testing.T) { // Base has no anchors → composer appends sections before sectPr. base := composerBase() body := `Static chrome` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Section text"}, } out, err := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", Vars: PlaceholderMap{}, }) if err != nil { t.Fatalf("Compose: %v", err) } docXML := extractDocumentXML(t, out) if !strings.Contains(docXML, "Static chrome") { t.Errorf("base chrome dropped: %q", docXML) } if !strings.Contains(docXML, "Section text") { t.Errorf("section content missing: %q", docXML) } // Section must land before sectPr (rule of thumb: it's an end-of-body element). staticIdx := strings.Index(docXML, "Section text") sectPrIdx := strings.Index(docXML, " sectPrIdx { t.Errorf("section landed after sectPr: section=%d sectPr=%d", staticIdx, sectPrIdx) } } func TestComposer_AnchorMode_SpliceContent(t *testing.T) { base := composerBase() body := `Header` + `{{#section:facts}}` + `(seed)` + `{{/section:facts}}` + `Footer` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Real prose"}, } out, err := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", }) if err != nil { t.Fatalf("Compose: %v", err) } docXML := extractDocumentXML(t, out) if !strings.Contains(docXML, "Header") || !strings.Contains(docXML, "Footer") { t.Errorf("base chrome dropped: %q", docXML) } if !strings.Contains(docXML, "Real prose") { t.Errorf("section content missing: %q", docXML) } // Anchor paragraphs themselves must be gone. if strings.Contains(docXML, "{{#section:facts}}") || strings.Contains(docXML, "{{/section:facts}}") { t.Errorf("anchor markers survived: %q", docXML) } // Seed content between anchors must be gone (replaced by the // composed section). if strings.Contains(docXML, "(seed)") { t.Errorf("anchor-spanned seed survived: %q", docXML) } } func TestComposer_ExcludedSection_DropsAnchorPair(t *testing.T) { base := composerBase() body := `Header` + `{{#section:exhibits}}` + `(default)` + `{{/section:exhibits}}` + `Footer` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "exhibits", OrderIndex: 8, Kind: "prose", Included: false, ContentMDDE: "ignored"}, } out, err := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", }) if err != nil { t.Fatalf("Compose: %v", err) } docXML := extractDocumentXML(t, out) if strings.Contains(docXML, "{{#section:exhibits}}") || strings.Contains(docXML, "{{/section:exhibits}}") { t.Errorf("anchors for excluded section survived: %q", docXML) } if strings.Contains(docXML, "ignored") { t.Errorf("excluded section content rendered: %q", docXML) } } func TestComposer_PlaceholdersResolve(t *testing.T) { base := composerBase() body := `{{#section:greeting}}` + `{{/section:greeting}}` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "greeting", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Hallo {{user.name}}"}, } out, err := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", Vars: PlaceholderMap{"user.name": "Maria Schmidt"}, }) if err != nil { t.Fatalf("Compose: %v", err) } docXML := extractDocumentXML(t, out) if !strings.Contains(docXML, "Hallo") || !strings.Contains(docXML, "Maria Schmidt") { t.Errorf("placeholder not substituted: %q", docXML) } if strings.Contains(docXML, "{{user.name}}") { t.Errorf("placeholder survived: %q", docXML) } } func TestComposer_LangPicksColumn(t *testing.T) { base := composerBase() body := `{{#section:facts}}{{/section:facts}}` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "deutscher text", ContentMDEN: "english text"}, } deOut, _ := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", }) enOut, _ := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "en", }) deXML := extractDocumentXML(t, deOut) enXML := extractDocumentXML(t, enOut) if !strings.Contains(deXML, "deutscher text") || strings.Contains(deXML, "english text") { t.Errorf("DE pick failed: %q", deXML) } if !strings.Contains(enXML, "english text") || strings.Contains(enXML, "deutscher text") { t.Errorf("EN pick failed: %q", enXML) } } // Slice D — rich-prose end-to-end through the composer. func TestComposer_HeadingsAndLists(t *testing.T) { base := composerBase() // Extend the stylemap so the walker has named styles to apply. base.SectionSpec.Stylemap["heading_1"] = "Heading1" base.SectionSpec.Stylemap["list_bullet"] = "ListBullet" base.SectionSpec.Stylemap["list_numbered"] = "ListNumber" base.SectionSpec.Stylemap["blockquote"] = "Quote" body := `{{#section:body}}{{/section:body}}` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) md := "# Heading line\n\n- bullet a\n- bullet b\n\n1. first\n2. second\n\n> quoted" sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "body", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md}, } out, err := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", }) if err != nil { t.Fatalf("Compose: %v", err) } docXML := extractDocumentXML(t, out) for _, want := range []string{ ``, ``, ``, ``, "Heading line", "bullet a", "bullet b", `1. `, `2. `, "first", "second", "quoted", } { if !strings.Contains(docXML, want) { t.Errorf("expected %q in composed body; got: %s", want, docXML) } } } func TestComposer_HyperlinkWiresRels(t *testing.T) { base := composerBase() body := `{{#section:facts}}{{/section:facts}}` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "See [BGH](https://bgh.bund.de) and [EuGH](https://curia.europa.eu)."}, } out, err := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", }) if err != nil { t.Fatalf("Compose: %v", err) } // Body: hyperlink elements with composer rIds. docXML := extractDocumentXML(t, out) if !strings.Contains(docXML, ``) || !strings.Contains(docXML, ``) { t.Errorf("hyperlink rIds missing in body: %q", docXML) } if !strings.Contains(docXML, "BGH") || !strings.Contains(docXML, "EuGH") { t.Errorf("hyperlink labels missing: %q", docXML) } // Rels: the matching rows must be in // word/_rels/document.xml.rels with the URL targets + External mode. rels := extractZipEntry(t, out, "word/_rels/document.xml.rels") for _, want := range []string{ `Id="rIdComposer1"`, `Id="rIdComposer2"`, `Target="https://bgh.bund.de"`, `Target="https://curia.europa.eu"`, `TargetMode="External"`, "hyperlink", // the Type URL contains "hyperlink" } { if !strings.Contains(rels, want) { t.Errorf("expected %q in document.xml.rels: %s", want, rels) } } } func TestComposer_HyperlinkDedupesByURL(t *testing.T) { base := composerBase() body := `{{#section:facts}}{{/section:facts}}` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) // Same URL referenced twice — should produce one rId, two // elements both pointing at it. sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "First [BGH](https://bgh.bund.de) and again [Bundesgerichtshof](https://bgh.bund.de)."}, } out, _ := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", }) docXML := extractDocumentXML(t, out) if strings.Count(docXML, ``) != 2 { t.Errorf("expected 2 hyperlinks sharing rIdComposer1; got: %s", docXML) } if strings.Contains(docXML, ``) { t.Errorf("dedupe failed — second rId allocated for same URL: %s", docXML) } } func TestComposer_OrderIndexAscending(t *testing.T) { base := composerBase() // No anchors → both sections append in order_index ASC order // before sectPr. body := `` baseBytes := minimalBaseBytes(t, body) composer := NewSubmissionComposer(NewSubmissionRenderer()) sections := []SubmissionSection{ {ID: uuid.New(), SectionKey: "second", OrderIndex: 2, Kind: "prose", Included: true, ContentMDDE: "ZWEITER"}, {ID: uuid.New(), SectionKey: "first", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "ERSTER"}, } out, err := composer.Compose(context.Background(), ComposeOptions{ Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de", }) if err != nil { t.Fatalf("Compose: %v", err) } docXML := extractDocumentXML(t, out) firstIdx := strings.Index(docXML, "ERSTER") secondIdx := strings.Index(docXML, "ZWEITER") if firstIdx < 0 || secondIdx < 0 || firstIdx > secondIdx { t.Errorf("order_index ASC not honoured: ERSTER=%d ZWEITER=%d", firstIdx, secondIdx) } }