package convert import ( "context" "errors" "strings" "sync" "testing" "time" ) // fakeRunner records the args it was invoked with and replays canned // responses. Lets us assert the command lines + image refs without // needing podman. type fakeRunner struct { mu sync.Mutex calls [][]string images []string stdin [][]byte mounts [][]string resp []byte err error } func (f *fakeRunner) Run(_ context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, append([]string(nil), cmd...)) f.images = append(f.images, image) f.stdin = append(f.stdin, append([]byte(nil), stdin...)) f.mounts = append(f.mounts, append([]string(nil), mounts...)) return f.resp, f.err } func (f *fakeRunner) lastCall() (string, []string) { f.mu.Lock() defer f.mu.Unlock() if len(f.calls) == 0 { return "", nil } return f.images[len(f.images)-1], f.calls[len(f.calls)-1] } func TestToDocx_UsesPandocImage(t *testing.T) { f := &fakeRunner{resp: []byte("FAKE-DOCX")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) SetImages("docker.io/pandoc/latex:latest", "") out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{ Title: "Hello", Client: "Acme", }) if err != nil { t.Fatalf("ToDocx: %v", err) } if string(out) != "FAKE-DOCX" { t.Errorf("unexpected output: %q", out) } image, call := f.lastCall() if image != "docker.io/pandoc/latex:latest" { t.Errorf("expected pandoc image, got %q", image) } if !contains(call, "--to=docx") { t.Errorf("missing --to=docx: %v", call) } if !contains(call, "title=Hello") { t.Errorf("missing title metadata: %v", call) } if !contains(call, "client=Acme") { t.Errorf("missing client metadata: %v", call) } // Last arg must be "-" so pandoc reads from stdin. if call[len(call)-1] != "-" { t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1]) } } func TestToHTML_UsesTemplateAndMountsScratch(t *testing.T) { f := &fakeRunner{resp: []byte("fake")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) SetImages("docker.io/pandoc/latex:latest", "") _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}) if err != nil { t.Fatalf("ToHTML: %v", err) } image, call := f.lastCall() if image != "docker.io/pandoc/latex:latest" { t.Errorf("expected pandoc image, got %q", image) } if !contains(call, "--template=/tpl/viewer-template.html") { t.Errorf("template flag missing: %v", call) } if !contains(call, "--toc") { t.Errorf("TOC flag missing (default NoTOC=false): %v", call) } if len(f.mounts) == 0 || len(f.mounts[0]) == 0 { t.Fatalf("expected at least one bind mount for /tpl") } mount := f.mounts[0][0] if !strings.Contains(mount, ":/tpl:") { t.Errorf("mount missing /tpl: %q", mount) } } func TestToHTML_NoTOCSuppressesTOC(t *testing.T) { f := &fakeRunner{resp: []byte("")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) _, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}) _, call := f.lastCall() if contains(call, "--toc") { t.Errorf("TOC should be suppressed when NoTOC=true: %v", call) } if !contains(call, "no-toc=true") { t.Errorf("no-toc metadata variable missing: %v", call) } } // recordingRunner records every call and returns canned responses // in sequence. Lets ToPDF tests assert the two-stage pipeline // (pandoc image then chromium image). type recordingRunner struct { mu sync.Mutex calls []recordedCall resp [][]byte err []error cursor int } type recordedCall struct { image string cmd []string mounts []string } func (r *recordingRunner) Run(_ context.Context, image string, _ []byte, mounts []string, cmd []string) ([]byte, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, recordedCall{ image: image, cmd: append([]string(nil), cmd...), mounts: append([]string(nil), mounts...), }) if r.cursor >= len(r.resp) { return nil, nil } out := r.resp[r.cursor] var e error if r.cursor < len(r.err) { e = r.err[r.cursor] } r.cursor++ return out, e } func TestScratchDir_UsedByToHTML(t *testing.T) { f := &fakeRunner{resp: []byte("")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil); SetScratchDir("") }) scratchRoot := t.TempDir() SetScratchDir(scratchRoot) _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}) if err != nil { t.Fatalf("ToHTML: %v", err) } if len(f.mounts) == 0 || len(f.mounts[0]) == 0 { t.Fatalf("expected at least one mount") } mount := f.mounts[0][0] // ":/tpl:ro" if !strings.HasPrefix(mount, scratchRoot+"/") { t.Errorf("scratch dir not under configured root: %q (root=%q)", mount, scratchRoot) } } func TestToPDF_TwoStagePipeline(t *testing.T) { // Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from // the bind mount and writes /pdf/out.pdf. The fake runner can't // actually write the PDF, so we expect ToPDF to fail at the // read-back step — but we can still assert the two-stage call // shape and the right image per stage. r := &recordingRunner{ resp: [][]byte{ []byte("fake"), // stage 1 stdout nil, // stage 2 stdout (chromium writes PDF to bind mount) }, } InstallRunner(r) t.Cleanup(func() { InstallRunner(nil) }) SetImages("docker.io/pandoc/latex:latest", "docker.io/zenika/alpine-chrome:latest") _, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}) // PDF read-back will fail (fake runner didn't write the file) — // that's expected for this test which only inspects the call // shape. if err == nil { t.Fatalf("expected error from PDF read-back; got nil") } if len(r.calls) != 2 { t.Fatalf("expected 2 container calls (pandoc + chromium); got %d", len(r.calls)) } if r.calls[0].image != "docker.io/pandoc/latex:latest" { t.Errorf("stage 1 image: got %q want pandoc/latex", r.calls[0].image) } if r.calls[1].image != "docker.io/zenika/alpine-chrome:latest" { t.Errorf("stage 2 image: got %q want alpine-chrome", r.calls[1].image) } // Stage 2 must include the --print-to-pdf flag pointing at /pdf. if !contains(r.calls[1].cmd, "--print-to-pdf=/pdf/out.pdf") { t.Errorf("chromium call missing --print-to-pdf flag: %v", r.calls[1].cmd) } if !contains(r.calls[1].cmd, "--no-sandbox") { t.Errorf("chromium call missing --no-sandbox: %v", r.calls[1].cmd) } // Stage 2's bind mount must be writable (chromium writes the PDF). if len(r.calls[1].mounts) == 0 || !strings.Contains(r.calls[1].mounts[0], ":rw") { t.Errorf("chromium mount must be :rw, got %v", r.calls[1].mounts) } } func TestErrUnavailable_WhenNoRunner(t *testing.T) { InstallRunner(nil) _, err := ToDocx(context.Background(), []byte("x"), Metadata{}) if !errors.Is(err, ErrUnavailable) { t.Errorf("expected ErrUnavailable, got %v", err) } } func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) { args := metadataArgs(Metadata{ Title: "T", Project: "P", GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC), }) want := []string{ "-V", "title=T", "-V", "project=P", } for i, w := range want { if i >= len(args) || args[i] != w { t.Fatalf("args[%d]: got %v want prefix %v", i, args, want) } } joined := strings.Join(args, "|") if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") { t.Errorf("generation_time missing or malformed: %v", args) } if strings.Contains(joined, "client=") { t.Errorf("empty client should not be passed: %v", args) } } func TestImageTag(t *testing.T) { cases := map[string]string{ "docker.io/pandoc/latex:latest": "pandoc/latex", "docker.io/zenika/alpine-chrome:latest": "zenika/alpine-chrome", "pandoc/core": "pandoc/core", "quay.io/example/foo:v1": "example/foo", "alpine": "alpine", } for in, want := range cases { if got := imageTag(in); got != want { t.Errorf("imageTag(%q) = %q, want %q", in, got, want) } } } func TestSingleflight_Collapses(t *testing.T) { var g singleflightGroup const N = 50 var wg sync.WaitGroup var hits int32 var mu sync.Mutex wg.Add(N) for i := 0; i < N; i++ { go func() { defer wg.Done() _, _ = g.Do("k", func() (any, error) { mu.Lock() hits++ mu.Unlock() time.Sleep(20 * time.Millisecond) return "v", nil }) }() } wg.Wait() if hits != 1 { t.Errorf("singleflight collapse: got %d invocations, want 1", hits) } } // contains reports whether haystack has needle as any of its elements. func contains(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false }