mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 03:42:15 +01:00
feat: add more PDF formats, drastically improve LibreOffice long-running instance management
This commit is contained in:
3
pkg/modules/libreoffice/uno/doc.go
Normal file
3
pkg/modules/libreoffice/uno/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package uno provides a module which interacts with the UNO
|
||||
// (Universal Network Objects) API.
|
||||
package uno
|
||||
31
pkg/modules/libreoffice/uno/freeport.go
Normal file
31
pkg/modules/libreoffice/uno/freeport.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func freePort(logger *zap.Logger) (int, error) {
|
||||
netListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("listen on the local network address: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := netListener.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close network listener: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
addr := netListener.Addr().String()
|
||||
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get free port from host: %w", err)
|
||||
}
|
||||
|
||||
return strconv.Atoi(portStr)
|
||||
}
|
||||
248
pkg/modules/libreoffice/uno/listener.go
Normal file
248
pkg/modules/libreoffice/uno/listener.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type listener interface {
|
||||
start(logger *zap.Logger) error
|
||||
stop(logger *zap.Logger) error
|
||||
lock(ctx context.Context, logger *zap.Logger) error
|
||||
unlock(logger *zap.Logger) error
|
||||
port() int
|
||||
queue() int
|
||||
healthy() bool
|
||||
}
|
||||
|
||||
type libreOfficeListener struct {
|
||||
binPath string
|
||||
startTimeout time.Duration
|
||||
threshold int
|
||||
|
||||
socketPort int
|
||||
userProfileDirPath string
|
||||
cmd gotenberg.Cmd
|
||||
cfgMu sync.RWMutex
|
||||
|
||||
usage int
|
||||
queueLength int
|
||||
queueLengthMu sync.RWMutex
|
||||
lockChan chan struct{}
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func newLibreOfficeListener(logger *zap.Logger, binPath string, startTimeout time.Duration, threshold int) listener {
|
||||
return &libreOfficeListener{
|
||||
binPath: binPath,
|
||||
startTimeout: startTimeout,
|
||||
threshold: threshold,
|
||||
lockChan: make(chan struct{}, 1),
|
||||
logger: logger.Named("listener"),
|
||||
}
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) start(logger *zap.Logger) error {
|
||||
port, err := freePort(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get free port: %w", err)
|
||||
}
|
||||
|
||||
// Good to know: when the supervisor manages the LibreOffice listener,
|
||||
// the garbage collector might delete the next directory while it is
|
||||
// still running. It does seem to cause any issue though.
|
||||
userProfileDirPath := gotenberg.NewDirPath()
|
||||
|
||||
args := []string{
|
||||
"--headless",
|
||||
"--invisible",
|
||||
"--nocrashreport",
|
||||
"--nodefault",
|
||||
"--nologo",
|
||||
"--nofirststartwizard",
|
||||
"--norestore",
|
||||
fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath),
|
||||
fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), listener.startTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, listener.binPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create LibreOffice listener command: %w", err)
|
||||
}
|
||||
|
||||
// For whatever reason, LibreOffice requires a first start before being
|
||||
// able to run as a daemon.
|
||||
exitCode, err := cmd.Exec()
|
||||
if err != nil && exitCode != 81 {
|
||||
return fmt.Errorf("execute LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("got exit code 81, e.g., LibreOffice listener first start")
|
||||
|
||||
// Second start (daemon).
|
||||
cmd = gotenberg.Command(logger, listener.binPath, args...)
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
// As the LibreOffice socket may take some time to be available, we have to
|
||||
// ensure that it is indeed accepting connections.
|
||||
logger.Debug("waiting for the LibreOffice listener socket to be available...")
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("waiting for the LibreOffice listener socket to be available: %w", ctx.Err())
|
||||
}
|
||||
|
||||
_, err = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("LibreOffice listener socket available")
|
||||
|
||||
listener.cfgMu.Lock()
|
||||
listener.socketPort = port
|
||||
listener.userProfileDirPath = userProfileDirPath
|
||||
listener.cmd = cmd
|
||||
listener.cfgMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) stop(logger *zap.Logger) error {
|
||||
listener.cfgMu.RLock()
|
||||
|
||||
defer func() {
|
||||
defer listener.cfgMu.RUnlock()
|
||||
|
||||
err := os.RemoveAll(listener.userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove LibreOffice listener user profile directory: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
err := listener.cmd.Kill()
|
||||
if err != nil {
|
||||
return fmt.Errorf("kill LibreOffice listener process: %w", err)
|
||||
}
|
||||
|
||||
// Let's wait to make sure the process is no more.
|
||||
err = listener.cmd.Wait()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("wait for the LibreOffice listener: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) lock(ctx context.Context, logger *zap.Logger) error {
|
||||
listener.queueLengthMu.Lock()
|
||||
listener.queueLength += 1
|
||||
listener.queueLengthMu.Unlock()
|
||||
|
||||
select {
|
||||
case listener.lockChan <- struct{}{}:
|
||||
logger.Debug("LibreOffice listener lock acquired")
|
||||
|
||||
listener.queueLengthMu.Lock()
|
||||
listener.queueLength -= 1
|
||||
listener.queueLengthMu.Unlock()
|
||||
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
logger.Debug("failed to acquire LibreOffice listener lock before deadline")
|
||||
|
||||
listener.queueLengthMu.Lock()
|
||||
listener.queueLength -= 1
|
||||
listener.queueLengthMu.Unlock()
|
||||
|
||||
return fmt.Errorf("acquire LibreOffice listener lock: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) unlock(logger *zap.Logger) error {
|
||||
restart := func() error {
|
||||
err := listener.stop(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
err = listener.start(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
listener.usage = 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
<-listener.lockChan
|
||||
logger.Debug("LibreOffice listener lock released")
|
||||
}()
|
||||
|
||||
if !listener.healthy() {
|
||||
logger.Debug("LibreOffice listener is unhealthy, restarting it...")
|
||||
|
||||
err := restart()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("restart LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
listener.usage += 1
|
||||
if listener.usage < listener.threshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("LibreOffice listener threshold reached, restarting it...")
|
||||
|
||||
err := restart()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("restart LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) port() int {
|
||||
listener.cfgMu.RLock()
|
||||
defer listener.cfgMu.RUnlock()
|
||||
|
||||
return listener.socketPort
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) queue() int {
|
||||
listener.queueLengthMu.RLock()
|
||||
defer listener.queueLengthMu.RUnlock()
|
||||
|
||||
return listener.queueLength
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) healthy() bool {
|
||||
_, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", listener.port()), time.Duration(1)*time.Second)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ listener = (*libreOfficeListener)(nil)
|
||||
)
|
||||
288
pkg/modules/libreoffice/uno/listener_test.go
Normal file
288
pkg/modules/libreoffice/uno/listener_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListener_start(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listener listener
|
||||
expectStartErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
listener: newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10),
|
||||
},
|
||||
{
|
||||
name: "non-exit code 81 on first start",
|
||||
listener: newLibreOfficeListener(zap.NewNop(), "foo", time.Duration(10)*time.Second, 10),
|
||||
expectStartErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.listener.start(zap.NewNop())
|
||||
|
||||
if tc.expectStartErr && err == nil {
|
||||
t.Fatalf("expected listener.start() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStartErr && err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if tc.listener.healthy() {
|
||||
t.Error("expected a non-running LibreOffice listener")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = tc.listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_stop(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_lock(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
|
||||
err := listener.lock(ctx, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
err = listener.lock(ctx, zap.NewNop())
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected %v error, but got: %v", context.Canceled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_unlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listener listener
|
||||
teardown func(listener listener) error
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
return listener
|
||||
}(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unhealthy listener",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold reached",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 1)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
return listener
|
||||
}(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := tc.listener.lock(ctx, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = tc.listener.unlock(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.unlock(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = tc.teardown(tc.listener)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_port(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
port := listener.port()
|
||||
if port == 0 {
|
||||
t.Error("expected a non-zero value from listener.port")
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_queue(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
queueLength := listener.queue()
|
||||
if queueLength != 0 {
|
||||
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
|
||||
err := listener.lock(ctx, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 0 {
|
||||
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = listener.lock(ctx, zap.NewNop())
|
||||
}()
|
||||
|
||||
time.Sleep(time.Duration(100) * time.Millisecond)
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 1 {
|
||||
t.Fatalf("expected 1 from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = listener.lock(ctx, zap.NewNop())
|
||||
}()
|
||||
|
||||
time.Sleep(time.Duration(100) * time.Millisecond)
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 2 {
|
||||
t.Fatalf("expected 2 from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
time.Sleep(time.Duration(100) * time.Millisecond)
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 0 {
|
||||
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_healthy(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
if !listener.healthy() {
|
||||
t.Error("expected an healthy LibreOffice listener")
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
|
||||
if listener.healthy() {
|
||||
t.Errorf("expected a non-healthy LibreOffice listener")
|
||||
}
|
||||
}
|
||||
36
pkg/modules/libreoffice/uno/mocks.go
Normal file
36
pkg/modules/libreoffice/uno/mocks.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// APIMock is a mock for the API interface.
|
||||
type APIMock struct {
|
||||
PDFMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
ExtensionsMock func() []string
|
||||
}
|
||||
|
||||
func (api APIMock) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return api.PDFMock(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
func (api APIMock) Extensions() []string {
|
||||
return api.ExtensionsMock()
|
||||
}
|
||||
|
||||
// ProviderMock is a mock for the Provider interface.
|
||||
type ProviderMock struct {
|
||||
UNOMock func() (API, error)
|
||||
}
|
||||
|
||||
func (provider ProviderMock) UNO() (API, error) {
|
||||
return provider.UNOMock()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ API = (*APIMock)(nil)
|
||||
_ Provider = (*ProviderMock)(nil)
|
||||
)
|
||||
42
pkg/modules/libreoffice/uno/mocks_test.go
Normal file
42
pkg/modules/libreoffice/uno/mocks_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAPIMock(t *testing.T) {
|
||||
mock := APIMock{
|
||||
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.PDF(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.PDF(), but got: %v", err)
|
||||
}
|
||||
|
||||
ext := mock.Extensions()
|
||||
if ext != nil {
|
||||
t.Errorf("expected no extensions from mock.Extensions(), but got: %+v", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMock(t *testing.T) {
|
||||
mock := ProviderMock{
|
||||
UNOMock: func() (API, error) {
|
||||
return APIMock{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.UNO()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.UNO(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
522
pkg/modules/libreoffice/uno/uno.go
Normal file
522
pkg/modules/libreoffice/uno/uno.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(UNO{})
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPDFformat happens if the PDF format option cannot be handled
|
||||
// by LibreOffice.
|
||||
ErrInvalidPDFformat = errors.New("invalid PDF format")
|
||||
|
||||
// ErrMalformedPageRanges happens if the page ranges option cannot be
|
||||
// interpreted by LibreOffice.
|
||||
ErrMalformedPageRanges = errors.New("page ranges are malformed")
|
||||
)
|
||||
|
||||
// UNO is a module which provides an API to interact with LibreOffice.
|
||||
type UNO struct {
|
||||
unoconvBinPath string
|
||||
libreOfficeBinPath string
|
||||
libreOfficeStartTimeout time.Duration
|
||||
libreOfficeRestartThreshold int
|
||||
|
||||
listener listener
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
type Options struct {
|
||||
// Landscape allows to change the orientation of the resulting PDF.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
|
||||
// PageRanges allows to select the pages to convert.
|
||||
// TODO: should prefer a method form PDFEngine.
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// PDFformat allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b, or
|
||||
// PDF/A-3b.
|
||||
// Optional.
|
||||
PDFformat string
|
||||
}
|
||||
|
||||
// API is an abstraction on top of uno.
|
||||
type API interface {
|
||||
PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
Extensions() []string
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating an API
|
||||
// for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(uno.Provider))
|
||||
// unoAPI, _ := provider.(uno.Provider).UNO()
|
||||
// }
|
||||
type Provider interface {
|
||||
UNO() (API, error)
|
||||
}
|
||||
|
||||
// Descriptor returns a UNO's module descriptor.
|
||||
func (UNO) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "uno",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("uno", flag.ExitOnError)
|
||||
fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for starting the LibreOffice listener")
|
||||
fs.Int("uno-listener-restart-threshold", 10, "Operations limit after which the LibreOffice listener is restarted - 0 means no long-running LibreOffice listener")
|
||||
fs.Bool("unoconv-disable-listener", false, "Do not start a long-running listener - save resources in detriment of unitary performance")
|
||||
|
||||
err := fs.MarkDeprecated("unoconv-disable-listener", "use uno-listener-restart-threshold with 0 instead")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for the uno module: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(UNO) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties. It returns an error if the environment
|
||||
// variables UNOCONV_BIN_PATH and LIBREOFFICE_BIN_PATH are not set.
|
||||
func (mod *UNO) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
mod.libreOfficeStartTimeout = flags.MustDuration("uno-listener-start-timeout")
|
||||
mod.libreOfficeRestartThreshold = flags.MustInt("uno-listener-restart-threshold")
|
||||
|
||||
disableListener := flags.MustBool("unoconv-disable-listener")
|
||||
if disableListener {
|
||||
mod.libreOfficeRestartThreshold = 0
|
||||
}
|
||||
|
||||
unoconvBinPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("UNOCONV_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
mod.unoconvBinPath = unoconvBinPath
|
||||
|
||||
libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
mod.libreOfficeBinPath = libreOfficeBinPath
|
||||
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger provider: %w", err)
|
||||
}
|
||||
|
||||
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger: %w", err)
|
||||
}
|
||||
|
||||
mod.logger = logger
|
||||
|
||||
mod.listener = newLibreOfficeListener(
|
||||
mod.logger,
|
||||
mod.libreOfficeBinPath,
|
||||
mod.libreOfficeStartTimeout,
|
||||
mod.libreOfficeRestartThreshold,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (mod UNO) Validate() error {
|
||||
var err error
|
||||
|
||||
_, statErr := os.Stat(mod.unoconvBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("unoconv binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
_, statErr = os.Stat(mod.libreOfficeBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Start starts the long-running LibreOffice listener if the threshold is
|
||||
// superior to zero.
|
||||
func (mod UNO) Start() error {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := mod.listener.start(mod.logger)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("start long-running LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (mod UNO) StartupMessage() string {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return "Long-running LibreOffice listener disabled"
|
||||
}
|
||||
|
||||
return "Long-running LibreOffice listener started"
|
||||
}
|
||||
|
||||
// Stop stops the long-running LibreOffice Listener if it exists.
|
||||
func (mod UNO) Stop(ctx context.Context) error {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown cleanup.
|
||||
mod.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
err := mod.listener.stop(mod.logger)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("stop long-running LibreOffice supervisor")
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (mod UNO) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
{
|
||||
Name: "unoconv_active_instances_count",
|
||||
Description: "Current number of active unoconv instances.",
|
||||
Read: func() float64 {
|
||||
activeInstancesCountMu.RLock()
|
||||
defer activeInstancesCountMu.RUnlock()
|
||||
|
||||
return activeInstancesCount
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "libreoffice_listener_active_instances_count",
|
||||
Description: "Current number of active LibreOffice listener instances.",
|
||||
Read: func() float64 {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
listenerActiveInstancesCountMu.RLock()
|
||||
defer listenerActiveInstancesCountMu.RUnlock()
|
||||
|
||||
return listenerActiveInstancesCount
|
||||
}
|
||||
|
||||
if mod.listener.healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unoconv_listener_active_instances_count",
|
||||
Description: "Current number of active unoconv listener instances - deprecated, prefer libreoffice_listener_active_instances_count.",
|
||||
Read: func() float64 {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
listenerActiveInstancesCountMu.RLock()
|
||||
defer listenerActiveInstancesCountMu.RUnlock()
|
||||
|
||||
return listenerActiveInstancesCount
|
||||
}
|
||||
|
||||
if mod.listener.healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "libreoffice_listener_queue_length",
|
||||
Description: "Current number of processes in the queue.",
|
||||
Read: func() float64 {
|
||||
return float64(mod.listener.queue())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unoconv_listener_queue_length",
|
||||
Description: "Current number of processes in the queue - deprecated, prefer libreoffice_listener_queue_length.",
|
||||
Read: func() float64 {
|
||||
return float64(mod.listener.queue())
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Checks adds a health check that verifies the health of the long-running
|
||||
// LibreOffice listener.
|
||||
func (mod UNO) Checks() ([]health.CheckerOption, error) {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []health.CheckerOption{
|
||||
health.WithCheck(health.Check{
|
||||
Name: "uno",
|
||||
Check: func(_ context.Context) error {
|
||||
if mod.listener.healthy() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("long-running LibreOffice listener unhealthy")
|
||||
},
|
||||
// The long-running LibreOffice listener may be restarting, so we
|
||||
// wait a given amount of time until we consider the module
|
||||
// unavailable.
|
||||
MaxTimeInError: mod.libreOfficeStartTimeout,
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PDF converts a document to PDF.
|
||||
//
|
||||
// If there is no long-running LibreOffice listener, it creates a dedicated
|
||||
// LibreOffice instance for the conversion. Substantial calls to this method
|
||||
// may increase CPU and memory usage drastically
|
||||
//
|
||||
// If there is a long-running LibreOffice listener, the conversion performance
|
||||
// improves substantially. However, it cannot perform parallel operations.
|
||||
func (mod UNO) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
args := []string{
|
||||
"--no-launch",
|
||||
"--format",
|
||||
"pdf",
|
||||
}
|
||||
|
||||
switch mod.libreOfficeRestartThreshold {
|
||||
case 0:
|
||||
listener := newLibreOfficeListener(logger, mod.libreOfficeBinPath, mod.libreOfficeStartTimeout, 0)
|
||||
|
||||
err := listener.start(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := listener.stop(logger)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("stop LibreOffice listener: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
args = append(args, "--port", fmt.Sprintf("%d", listener.port()))
|
||||
default:
|
||||
err := mod.listener.lock(ctx, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock long-running LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
go func() {
|
||||
err := mod.listener.unlock(logger)
|
||||
if err != nil {
|
||||
mod.logger.Error(fmt.Sprintf("unlock long-running LibreOffice listener: %v", err))
|
||||
}
|
||||
}()
|
||||
}()
|
||||
|
||||
// If the LibreOffice listener is restarting while acquiring the lock,
|
||||
// the port will change. It's therefore important to add the port args
|
||||
// after we acquire the lock.
|
||||
args = append(args, "--port", fmt.Sprintf("%d", mod.listener.port()))
|
||||
}
|
||||
|
||||
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
|
||||
if checkedEntry != nil {
|
||||
args = append(args, "-vvv")
|
||||
}
|
||||
|
||||
if options.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
|
||||
if options.PageRanges != "" {
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
switch options.PDFformat {
|
||||
case "":
|
||||
case gotenberg.FormatPDFA1a:
|
||||
args = append(args, "--export", "SelectPdfVersion=1")
|
||||
case gotenberg.FormatPDFA2b:
|
||||
args = append(args, "--export", "SelectPdfVersion=2")
|
||||
case gotenberg.FormatPDFA3b:
|
||||
args = append(args, "--export", "SelectPdfVersion=3")
|
||||
default:
|
||||
return ErrInvalidPDFformat
|
||||
}
|
||||
|
||||
args = append(args, "--output", outputPath, inputPath)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, mod.unoconvBinPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create unoconv command: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount += 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
exitCode, err := cmd.Exec()
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount -= 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unoconv/LibreOffice errors are not explicit.
|
||||
// That's why we have to make an educated guess according to the exit code
|
||||
// and given inputs.
|
||||
|
||||
if exitCode == 5 && options.PageRanges != "" {
|
||||
return ErrMalformedPageRanges
|
||||
}
|
||||
|
||||
// Possible errors:
|
||||
// 1. Unoconv/LibreOffice failed for some reason.
|
||||
// 2. Context done.
|
||||
//
|
||||
// On the second scenario, LibreOffice might not have time to remove some
|
||||
// of its temporary files, as it has been killed without warning. The
|
||||
// garbage collector will delete them for us (if the module is loaded).
|
||||
return fmt.Errorf("unoconv PDF: %w", err)
|
||||
}
|
||||
|
||||
// Extensions returns the file extensions available for conversions.
|
||||
func (mod UNO) Extensions() []string {
|
||||
return []string{
|
||||
".bib",
|
||||
".doc",
|
||||
".xml",
|
||||
".docx",
|
||||
".fodt",
|
||||
".html",
|
||||
".ltx",
|
||||
".txt",
|
||||
".odt",
|
||||
".ott",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".psw",
|
||||
".rtf",
|
||||
".sdw",
|
||||
".stw",
|
||||
".sxw",
|
||||
".uot",
|
||||
".vor",
|
||||
".wps",
|
||||
".epub",
|
||||
".png",
|
||||
".bmp",
|
||||
".emf",
|
||||
".eps",
|
||||
".fodg",
|
||||
".gif",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".met",
|
||||
".odd",
|
||||
".otg",
|
||||
".pbm",
|
||||
".pct",
|
||||
".pgm",
|
||||
".ppm",
|
||||
".ras",
|
||||
".std",
|
||||
".svg",
|
||||
".svm",
|
||||
".swf",
|
||||
".sxd",
|
||||
".sxw",
|
||||
".tif",
|
||||
".tiff",
|
||||
".xhtml",
|
||||
".xpm",
|
||||
".odp",
|
||||
".fodp",
|
||||
".potm",
|
||||
".pot",
|
||||
".pptx",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pwp",
|
||||
".sda",
|
||||
".sdd",
|
||||
".sti",
|
||||
".sxi",
|
||||
".uop",
|
||||
".wmf",
|
||||
".csv",
|
||||
".dbf",
|
||||
".dif",
|
||||
".fods",
|
||||
".ods",
|
||||
".ots",
|
||||
".pxl",
|
||||
".sdc",
|
||||
".slk",
|
||||
".stc",
|
||||
".sxc",
|
||||
".uos",
|
||||
".xls",
|
||||
".xlt",
|
||||
".xlsx",
|
||||
}
|
||||
}
|
||||
|
||||
// UNO returns an API for interacting with LibreOffice.
|
||||
func (mod UNO) UNO() (API, error) {
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
var (
|
||||
listenerActiveInstancesCount float64
|
||||
listenerActiveInstancesCountMu sync.RWMutex
|
||||
activeInstancesCount float64
|
||||
activeInstancesCountMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*UNO)(nil)
|
||||
_ gotenberg.Provisioner = (*UNO)(nil)
|
||||
_ gotenberg.Validator = (*UNO)(nil)
|
||||
_ gotenberg.App = (*UNO)(nil)
|
||||
_ gotenberg.MetricsProvider = (*UNO)(nil)
|
||||
_ api.HealthChecker = (*UNO)(nil)
|
||||
_ API = (*UNO)(nil)
|
||||
_ Provider = (*UNO)(nil)
|
||||
)
|
||||
865
pkg/modules/libreoffice/uno/uno_test.go
Normal file
865
pkg/modules/libreoffice/uno/uno_test.go
Normal file
@@ -0,0 +1,865 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestUNO_Descriptor(t *testing.T) {
|
||||
descriptor := UNO{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(UNO))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Provision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *gotenberg.Context
|
||||
expectProvisionErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "threshold from deprecated flag --unoconv-disable-listener",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := new(UNO).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--unoconv-disable-listener=true"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
{
|
||||
name: "no logger from logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if tc.expectProvisionErr && err == nil {
|
||||
t.Errorf("expected mod.Provision() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectProvisionErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
unoconvBinPath string
|
||||
libreOfficeBinPath string
|
||||
expectValidateErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
},
|
||||
{
|
||||
name: "unoconv bin path does not exist",
|
||||
unoconvBinPath: "/foo",
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
expectValidateErr: true,
|
||||
},
|
||||
{
|
||||
name: "LibreOffice bin path does not exist",
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: "/foo",
|
||||
expectValidateErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := UNO{
|
||||
unoconvBinPath: tc.unoconvBinPath,
|
||||
libreOfficeBinPath: tc.libreOfficeBinPath,
|
||||
}
|
||||
|
||||
err := mod.Validate()
|
||||
|
||||
if tc.expectValidateErr && err == nil {
|
||||
t.Errorf("expected mod.Validate() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectValidateErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Validate(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Start(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectStartErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
startMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start error",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
startMock: func(logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
},
|
||||
expectStartErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mod.Start()
|
||||
|
||||
if tc.expectStartErr && err == nil {
|
||||
t.Errorf("expected mod.Start() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStartErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Start(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_StartupMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
name: "long-running LibreOffice listener started",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
},
|
||||
expectMessage: "Long-running LibreOffice listener started",
|
||||
},
|
||||
{
|
||||
name: "long-running LibreOffice listener disabled",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
expectMessage: "Long-running LibreOffice listener disabled",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := tc.mod.StartupMessage()
|
||||
|
||||
if tc.expectMessage != actual {
|
||||
t.Errorf("expected '%s' from mod.StartupMessage(), but got '%s'", tc.expectMessage, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Stop(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectStopErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
stopMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stop error",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
stopMock: func(logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
expectStopErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
cancel()
|
||||
|
||||
err := tc.mod.Stop(ctx)
|
||||
|
||||
if tc.expectStopErr && err == nil {
|
||||
t.Errorf("expected mod.Stop() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStopErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Stop(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Metrics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectUnoconvActiveInstancesCount float64
|
||||
expectLibreOfficeListenerActiveInstancesCount float64
|
||||
expectLibreOfficeListenerQueueLength float64
|
||||
}{
|
||||
{
|
||||
name: "with healthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 0
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
expectLibreOfficeListenerActiveInstancesCount: 1,
|
||||
},
|
||||
{
|
||||
name: "with unhealthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 0
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 0
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with a queue of 3",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 3
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
expectLibreOfficeListenerQueueLength: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
metrics, err := tc.mod.Metrics()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Metrics(), but got: %v", err)
|
||||
}
|
||||
|
||||
for _, metric := range metrics {
|
||||
switch metric.Name {
|
||||
case "unoconv_active_instances_count":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectUnoconvActiveInstancesCount {
|
||||
t.Errorf("expected 'unoconv_active_instances_count' to be %.0f, but got %.0f", tc.expectUnoconvActiveInstancesCount, actual)
|
||||
}
|
||||
case "libreoffice_listener_active_instances_count":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
|
||||
t.Errorf("expected 'libreoffice_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
|
||||
}
|
||||
case "unoconv_listener_active_instances_count":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
|
||||
t.Errorf("expected 'unoconv_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
|
||||
}
|
||||
case "libreoffice_listener_queue_length":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerQueueLength {
|
||||
t.Errorf("expected 'libreoffice_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
|
||||
}
|
||||
case "unoconv_listener_queue_length":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerQueueLength {
|
||||
t.Errorf("expected 'unoconv_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Checks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectAvailabilityStatus health.AvailabilityStatus
|
||||
}{
|
||||
{
|
||||
name: "no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with healthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
healthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
expectAvailabilityStatus: health.StatusUp,
|
||||
},
|
||||
{
|
||||
name: "with unhealthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
healthyMock: func() bool {
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
expectAvailabilityStatus: health.StatusDown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
checks, err := tc.mod.Checks()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Checks(), but got: %v", err)
|
||||
}
|
||||
|
||||
if len(checks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(checks) != 1 {
|
||||
t.Fatalf("expected 1 check from mod.Checks(), but got %d", len(checks))
|
||||
}
|
||||
|
||||
checker := health.NewChecker(checks...)
|
||||
result := checker.Check(context.Background())
|
||||
|
||||
if result.Status != tc.expectAvailabilityStatus {
|
||||
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_PDF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
inputPath string
|
||||
options Options
|
||||
expectPDFErr bool
|
||||
teardown func(mod UNO) error
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior with no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
},
|
||||
{
|
||||
name: "nominal behavior with a long-running LibreOffice listener",
|
||||
mod: func() UNO {
|
||||
mod := UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 10,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
mod.listener = newLibreOfficeListener(
|
||||
mod.logger,
|
||||
mod.libreOfficeBinPath,
|
||||
mod.libreOfficeStartTimeout,
|
||||
mod.libreOfficeRestartThreshold,
|
||||
)
|
||||
|
||||
err := mod.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
return mod
|
||||
}(),
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
teardown: func(mod UNO) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return mod.Stop(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert with a debug logger",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewExample(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
},
|
||||
{
|
||||
name: "convert with landscape",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
Landscape: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert with page ranges",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PageRanges: "1-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert with invalid page ranges",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PageRanges: "foo",
|
||||
},
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "convert to PDF/A-1a",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: gotenberg.FormatPDFA1a,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert to PDF/A-2b",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: gotenberg.FormatPDFA2b,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert to PDF/A-3b",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: gotenberg.FormatPDFA3b,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert to invalid PDF format",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: "foo",
|
||||
},
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: nil,
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "expired context",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "cannot lock long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
lockMock: func(ctx context.Context, logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "cannot unlock long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
lockMock: func(ctx context.Context, logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
unlockMock: func(logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
portMock: func() int {
|
||||
return 2002
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
ctx: nil,
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if tc.teardown == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := tc.teardown(tc.mod)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
outputDir, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from gotenberg.MkdirAll(), but got: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(outputDir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from os.RemoveAll(), but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options)
|
||||
|
||||
if tc.expectPDFErr && err == nil {
|
||||
t.Fatalf("expected mod.PDF() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectPDFErr && err != nil {
|
||||
t.Fatalf("expected no error from mod.PDF(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Extensions(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
extensions := mod.Extensions()
|
||||
|
||||
actual := len(extensions)
|
||||
expect := 76
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_UNO(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
|
||||
_, err := mod.UNO()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mod.UNO(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type listenerMock struct {
|
||||
startMock func(logger *zap.Logger) error
|
||||
stopMock func(logger *zap.Logger) error
|
||||
lockMock func(ctx context.Context, logger *zap.Logger) error
|
||||
unlockMock func(logger *zap.Logger) error
|
||||
portMock func() int
|
||||
queueMock func() int
|
||||
healthyMock func() bool
|
||||
}
|
||||
|
||||
func (listener listenerMock) start(logger *zap.Logger) error {
|
||||
return listener.startMock(logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) stop(logger *zap.Logger) error {
|
||||
return listener.stopMock(logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) lock(ctx context.Context, logger *zap.Logger) error {
|
||||
return listener.lockMock(ctx, logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) unlock(logger *zap.Logger) error {
|
||||
return listener.unlockMock(logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) port() int {
|
||||
return listener.portMock()
|
||||
}
|
||||
|
||||
func (listener listenerMock) queue() int {
|
||||
return listener.queueMock()
|
||||
}
|
||||
|
||||
func (listener listenerMock) healthy() bool {
|
||||
return listener.healthyMock()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ listener = (*listenerMock)(nil)
|
||||
)
|
||||
Reference in New Issue
Block a user