Files
paliad/internal/services/preview_render_test.go
mAi 8ccf64cf83 feat(submissions): t-paliad-370 S4 — truthful base-preview render (code)
docforge UX slice S4 (PRD §2.3). Extends the S3 /api/submission-preview
endpoint with a truthful page-image render behind the SAME modal+endpoint
shape (the swappable body region). Resolves the S3 caveat: anchors-only
Composer/Gitea bases now render their REAL letterhead/Rubrum styling instead
of falling back to a base-agnostic structural view.

Pipeline (PRD §2.3): build the .docx via the EXISTING export pipeline
(byte-faithful) → Gotenberg sidecar (.docx→PDF over HTTP, keeps the Go image
lean) → poppler pdftoppm (PDF→PNG-per-page) → cache + single-flight.

Code only — NO Dokploy/compose change (head provisions the sidecar + fonts).
When GOTENBERG_URL is unset or poppler is absent, Available()=false and the
endpoint gracefully falls back to S3 structural HTML, so merging this is safe
before the infra lands.

Backend:
- internal/services/preview_render.go: PreviewImageRenderer interface +
  GotenbergRenderer (.docx→PDF→PNG) + PreviewImageCache (bounded FIFO,
  single-flight per key, serialised conversions) + NewPreviewImageCacheFromEnv
  (GOTENBERG_URL / PREVIEW_DPI). The PNG cache is regenerable, not a retained
  document (no disk/DB persistence; rebuildable from inputs).
- /api/submission-preview gains fidelity=truthful: builds the truthful .docx
  (editor = clone draft + base override → full export pipeline incl. Composer;
  catalog = context bag → render), caches by (draft id+updated_at | code+
  project) × base × lang × data-mode, returns page data-URIs. Structural
  responses now carry truthful:false.
- exportSubmissionDraft + Export gain a pluggable missing-marker (nil = default,
  so every existing export path is byte-identical); enables sample-data truthful
  render. New SubmissionDraftService.{ExportWithMarker,RenderContextPreviewDocx}.
- Unit tests: cache hit/miss, single-flight, eviction, availability, gotenberg
  multipart request shape (httptest, no poppler needed).

Frontend:
- base-preview-modal.ts requests fidelity=truthful; renders <img> page(s) with
  a ‹ n/N › prev/next pager (instant — pages held client-side); falls back to
  the S3 structural sheet when truthful:false. CSS for .base-preview-img +
  page-nav.

bun build (i18n scan clean) + go vet ./... + go test ./... (15 ok, 0 fail;
+6 new service tests).
2026-06-01 18:54:41 +02:00

150 lines
4.3 KiB
Go

package services
import (
"context"
"io"
"mime"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
// fakeRenderer counts RenderPages calls so the cache + single-flight behaviour
// can be asserted without gotenberg/poppler.
type fakeRenderer struct {
calls int32
available bool
block chan struct{} // when non-nil, RenderPages blocks until closed
}
func (f *fakeRenderer) Available() bool { return f.available }
func (f *fakeRenderer) RenderPages(ctx context.Context, docx []byte) ([][]byte, error) {
atomic.AddInt32(&f.calls, 1)
if f.block != nil {
<-f.block
}
return [][]byte{[]byte("png-page-1")}, nil
}
func TestPreviewCache_HitMiss(t *testing.T) {
f := &fakeRenderer{available: true}
c := NewPreviewImageCache(f, 8)
build := func() ([]byte, error) { return []byte("docx"), nil }
if _, err := c.Pages(context.Background(), "k1", build); err != nil {
t.Fatalf("first miss: %v", err)
}
if _, err := c.Pages(context.Background(), "k1", build); err != nil {
t.Fatalf("second (cached): %v", err)
}
if got := atomic.LoadInt32(&f.calls); got != 1 {
t.Fatalf("expected 1 render for a cached key, got %d", got)
}
if _, err := c.Pages(context.Background(), "k2", build); err != nil {
t.Fatalf("new key: %v", err)
}
if got := atomic.LoadInt32(&f.calls); got != 2 {
t.Fatalf("expected 2 renders for two keys, got %d", got)
}
}
func TestPreviewCache_SingleFlight(t *testing.T) {
f := &fakeRenderer{available: true, block: make(chan struct{})}
c := NewPreviewImageCache(f, 8)
build := func() ([]byte, error) { return []byte("docx"), nil }
const n = 8
var wg sync.WaitGroup
wg.Add(n)
for range n {
go func() {
defer wg.Done()
_, _ = c.Pages(context.Background(), "same", build)
}()
}
// Let the goroutines collapse onto one in-flight render, then release it.
for atomic.LoadInt32(&f.calls) == 0 {
}
close(f.block)
wg.Wait()
if got := atomic.LoadInt32(&f.calls); got != 1 {
t.Fatalf("single-flight: expected 1 render for %d concurrent same-key calls, got %d", n, got)
}
}
func TestPreviewCache_Eviction(t *testing.T) {
f := &fakeRenderer{available: true}
c := NewPreviewImageCache(f, 2)
build := func() ([]byte, error) { return []byte("docx"), nil }
for _, k := range []string{"a", "b", "c"} { // "a" evicted by capacity 2
if _, err := c.Pages(context.Background(), k, build); err != nil {
t.Fatal(err)
}
}
// "a" was evicted → re-rendering it is a fresh call.
if _, err := c.Pages(context.Background(), "a", build); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&f.calls); got != 4 {
t.Fatalf("expected 4 renders (a,b,c, then a again after eviction), got %d", got)
}
}
func TestPreviewCache_Available(t *testing.T) {
if NewPreviewImageCache(&fakeRenderer{available: false}, 4).Available() {
t.Error("cache should be unavailable when the renderer is")
}
if !NewPreviewImageCache(&fakeRenderer{available: true}, 4).Available() {
t.Error("cache should be available when the renderer is")
}
}
// TestGotenbergRenderer_DocxToPDF asserts the converter POSTs a multipart .docx
// to the LibreOffice route and returns the body — no poppler needed.
func TestGotenbergRenderer_DocxToPDF(t *testing.T) {
var gotPath, gotFile string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
mr, err := r.MultipartReader()
if err == nil && params != nil {
if p, perr := mr.NextPart(); perr == nil {
gotFile = p.FileName()
_, _ = io.Copy(io.Discard, p)
}
}
_, _ = w.Write([]byte("%PDF-1.7 fake"))
}))
defer srv.Close()
g := NewGotenbergRenderer(srv.URL, 110)
g.HTTP = srv.Client()
pdf, err := g.docxToPDF(context.Background(), []byte("PK\x03\x04 fake docx"))
if err != nil {
t.Fatalf("docxToPDF: %v", err)
}
if !strings.HasPrefix(string(pdf), "%PDF") {
t.Errorf("expected PDF bytes, got %q", string(pdf))
}
if gotPath != "/forms/libreoffice/convert" {
t.Errorf("posted to %q, want /forms/libreoffice/convert", gotPath)
}
if !strings.HasSuffix(gotFile, ".docx") {
t.Errorf("uploaded file %q, want a .docx", gotFile)
}
}
func TestGotenbergRenderer_AvailableNeedsURL(t *testing.T) {
// No URL → unavailable regardless of poppler.
if NewGotenbergRenderer("", 110).Available() {
t.Error("renderer with empty URL must be unavailable")
}
}