feat(pdfengines): add split feature

This commit is contained in:
Julien Neuhart
2024-12-20 15:51:57 +01:00
parent 42ee593708
commit c30da805b3
41 changed files with 2153 additions and 346 deletions

View File

@@ -13,6 +13,7 @@ import (
type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMedataEngines []gotenberg.PdfEngine
writeMedataEngines []gotenberg.PdfEngine
@@ -20,12 +21,14 @@ type multiPdfEngines struct {
func newMultiPdfEngines(
mergeEngines,
splitEngines,
convertEngines,
readMetadataEngines,
writeMedataEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
splitEngines: splitEngines,
convertEngines: convertEngines,
readMedataEngines: readMetadataEngines,
writeMedataEngines: writeMedataEngines,
@@ -57,6 +60,44 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
}
type splitResult struct {
outputPaths []string
err error
}
// Split tries to split at intervals a given PDF thanks to its children. If the
// context is done, it stops and returns an error.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var err error
var mu sync.Mutex // to safely append errors.
resultChan := make(chan splitResult, len(multi.splitEngines))
for _, engine := range multi.splitEngines {
go func(engine gotenberg.PdfEngine) {
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
resultChan <- splitResult{outputPaths: outputPaths, err: err}
}(engine)
}
for range multi.splitEngines {
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.outputPaths, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
}
// Convert converts the given PDF to a specific PDF format. thanks to its
// children. If the context is done, it stops and returns an error.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {

View File

@@ -19,25 +19,22 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}{
{
scenario: "nominal behavior",
engine: newMultiPdfEngines(
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
nil,
nil,
nil,
),
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines(
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
@@ -49,17 +46,14 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
},
},
},
nil,
nil,
nil,
),
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "all engines return an error",
engine: newMultiPdfEngines(
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
@@ -71,27 +65,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
},
},
},
nil,
nil,
nil,
),
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: newMultiPdfEngines(
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
nil,
nil,
nil,
),
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -115,6 +103,97 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}
}
func TestMultiPdfEngines_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := tc.engine.Split(tc.ctx, zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -124,25 +203,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}{
{
scenario: "nominal behavior",
engine: newMultiPdfEngines(
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
nil,
nil,
),
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines(
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
@@ -154,16 +229,13 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
},
},
},
nil,
nil,
),
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: newMultiPdfEngines(
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
@@ -175,26 +247,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
},
},
},
nil,
nil,
),
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: newMultiPdfEngines(
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
nil,
nil,
),
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -227,26 +294,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}{
{
scenario: "nominal behavior",
engine: newMultiPdfEngines(
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
},
},
nil,
),
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines(
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
@@ -258,16 +320,13 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
},
},
},
nil,
),
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: newMultiPdfEngines(
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
@@ -279,25 +338,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
},
},
},
nil,
),
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: newMultiPdfEngines(
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
},
},
nil,
),
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -330,27 +385,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}{
{
scenario: "nominal behavior",
engine: newMultiPdfEngines(
nil,
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
},
),
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines(
nil,
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
@@ -362,16 +411,13 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
},
},
},
),
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: newMultiPdfEngines(
nil,
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
@@ -383,24 +429,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
},
},
},
),
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: newMultiPdfEngines(
nil,
nil,
nil,
[]gotenberg.PdfEngine{
engine: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
},
),
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()

View File

@@ -28,6 +28,7 @@ func init() {
// enabled.
type PdfEngines struct {
mergeNames []string
splitNames []string
convertNames []string
readMetadataNames []string
writeMedataNames []string
@@ -42,6 +43,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
@@ -64,6 +66,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
splitNames := flags.MustStringSlice("pdfengines-split-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
@@ -98,6 +101,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.mergeNames = mergeNames
}
mod.splitNames = defaultNames
if len(splitNames) > 0 {
mod.splitNames = splitNames
}
mod.convertNames = defaultNames
if len(convertNames) > 0 {
mod.convertNames = convertNames
@@ -161,6 +169,7 @@ func (mod *PdfEngines) Validate() error {
}
findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMedataNames)
@@ -177,6 +186,7 @@ func (mod *PdfEngines) Validate() error {
func (mod *PdfEngines) SystemMessages() []string {
return []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
@@ -201,6 +211,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
return newMultiPdfEngines(
engines(mod.mergeNames),
engines(mod.splitNames),
engines(mod.convertNames),
engines(mod.readMetadataNames),
engines(mod.writeMedataNames),
@@ -222,6 +233,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{
mergeRoute(engine),
splitRoute(engine),
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),

View File

@@ -26,6 +26,7 @@ func TestPdfEngines_Provision(t *testing.T) {
scenario string
ctx *gotenberg.Context
expectedMergePdfEngines []string
expectedSplitPdfEngines []string
expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string
@@ -66,6 +67,7 @@ func TestPdfEngines_Provision(t *testing.T) {
)
}(),
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"},
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"},
@@ -107,7 +109,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}
fs := new(PdfEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -125,6 +127,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}(),
expectedMergePdfEngines: []string{"b"},
expectedSplitPdfEngines: []string{"a"},
expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"},
@@ -200,6 +203,12 @@ func TestPdfEngines_Provision(t *testing.T) {
}
}
for index, name := range mod.splitNames {
if name != tc.expectedSplitPdfEngines[index] {
t.Fatalf("expected split name at index %d to be %s, but got: %s", index, name, tc.expectedSplitPdfEngines[index])
}
}
for index, name := range mod.convertNames {
if name != tc.expectedConvertPdfEngines[index] {
t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
@@ -303,17 +312,19 @@ func TestPdfEngines_Validate(t *testing.T) {
func TestPdfEngines_SystemMessages(t *testing.T) {
mod := new(PdfEngines)
mod.mergeNames = []string{"foo", "bar"}
mod.splitNames = []string{"foo", "bar"}
mod.convertNames = []string{"foo", "bar"}
mod.readMetadataNames = []string{"foo", "bar"}
mod.writeMedataNames = []string{"foo", "bar"}
messages := mod.SystemMessages()
if len(messages) != 4 {
if len(messages) != 5 {
t.Errorf("expected one and only one message, but got %d", len(messages))
}
expect := []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
@@ -329,6 +340,7 @@ func TestPdfEngines_SystemMessages(t *testing.T) {
func TestPdfEngines_PdfEngine(t *testing.T) {
mod := PdfEngines{
mergeNames: []string{"foo", "bar"},
splitNames: []string{"foo", "bar"},
convertNames: []string{"foo", "bar"},
readMetadataNames: []string{"foo", "bar"},
writeMedataNames: []string{"foo", "bar"},
@@ -370,7 +382,7 @@ func TestPdfEngines_Routes(t *testing.T) {
}{
{
scenario: "routes not disabled",
expectRoutes: 4,
expectRoutes: 5,
disableRoutes: false,
},
{

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4"
@@ -13,6 +15,63 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
// FormDataPdfSplitMode creates a [gotenberg.SplitMode] from the form data.
func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMode {
var (
mode string
span string
)
splitModeFunc := func(value string) error {
if value != "" && value != gotenberg.SplitModeIntervals && value != gotenberg.SplitModePages {
return fmt.Errorf("wrong value, expected either '%s' or '%s'", gotenberg.SplitModeIntervals, gotenberg.SplitModePages)
}
mode = value
return nil
}
splitSpanFunc := func(value string) error {
value = strings.Join(strings.Fields(value), "")
if mode == gotenberg.SplitModeIntervals {
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
}
span = value
return nil
}
if mandatory {
form.
MandatoryCustom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
MandatoryCustom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
} else {
form.
Custom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
Custom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
}
return gotenberg.SplitMode{
Mode: mode,
Span: span,
}
}
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to default value if the considered key is not present.
func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
@@ -32,9 +91,10 @@ func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
}
// FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} {
var metadata map[string]interface{}
form.Custom("metadata", func(value string) error {
metadataFunc := func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
@@ -42,7 +102,18 @@ func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
}
}
return nil
})
}
if mandatory {
form.MandatoryCustom("metadata", func(value string) error {
return metadataFunc(value)
})
} else {
form.Custom("metadata", func(value string) error {
return metadataFunc(value)
})
}
return metadata
}
@@ -66,6 +137,52 @@ func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string
return outputPath, nil
}
// SplitPdfStub splits a list of PDF files based on [gotenberg.SplitMode].
// It returns a list of output paths or the list of provided input paths if no
// split requested.
func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.SplitMode, inputPaths []string) ([]string, error) {
zeroValued := gotenberg.SplitMode{}
if mode == zeroValued {
return inputPaths, nil
}
var outputPaths []string
for _, inputPath := range inputPaths {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_"))
if err != nil {
return nil, fmt.Errorf("create subdirectory from input path: %w", err)
}
paths, err := engine.Split(ctx, ctx.Log(), mode, inputPath, outputDirPath)
if err != nil {
return nil, fmt.Errorf("split PDF '%s': %w", inputPath, err)
}
if mode.Mode == gotenberg.SplitModePages {
return paths, nil
}
// Keep the original filename.
for i, path := range paths {
newPath := fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, i,
)
err = ctx.Rename(path, newPath)
if err != nil {
return nil, fmt.Errorf("rename path: %w", err)
}
outputPaths = append(outputPaths, newPath)
}
}
return outputPaths, nil
}
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
@@ -116,7 +233,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form)
metadata := FormDataPdfMetadata(form, false)
var inputPaths []string
err := form.
@@ -152,6 +269,65 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
}
// splitRoute returns an [api.Route] which can extract pages from a PDF.
func splitRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/split",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths)
if err != nil {
return fmt.Errorf("split PDFs: %w", err)
}
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
// Rename the files to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
}
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
// format.
func convertRoute(engine gotenberg.PdfEngine) api.Route {
@@ -258,25 +434,12 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
var (
inputPaths []string
metadata map[string]interface{}
)
form := ctx.FormData()
metadata := FormDataPdfMetadata(form, true)
err := ctx.FormData().
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
MandatoryCustom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
if len(metadata) == 0 {
return errors.New("no metadata")
}
return nil
}).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)

View File

@@ -3,13 +3,16 @@ package pdfengines
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"reflect"
"slices"
"strings"
"testing"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
@@ -17,6 +20,156 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func TestFormDataPdfSplitMode(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
mandatory bool
expectedSplitMode gotenberg.SplitMode
expectValidationError bool
}{
{
scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{},
expectValidationError: false,
},
{
scenario: "no custom form fields (mandatory)",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: true,
expectedSplitMode: gotenberg.SplitMode{},
expectValidationError: true,
},
{
scenario: "invalid splitMode",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"foo",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{},
expectValidationError: true,
},
{
scenario: "invalid splitSpan (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"1-2",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals},
expectValidationError: true,
},
{
scenario: "splitSpan inferior to 1 (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"-1",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals},
expectValidationError: true,
},
{
scenario: "valid form fields (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"1",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectValidationError: false,
},
{
scenario: "valid form fields (pages)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"pages",
},
"splitSpan": {
"1-2",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
expectValidationError: false,
},
{
scenario: "valid form fields (mandatory)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"1",
},
})
return ctx
}(),
mandatory: true,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectValidationError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
form := tc.ctx.Context.FormData()
actual := FormDataPdfSplitMode(form, tc.mandatory)
if !reflect.DeepEqual(actual, tc.expectedSplitMode) {
t.Fatalf("expected %+v but got: %+v", tc.expectedSplitMode, actual)
}
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
})
}
}
func TestFormDataPdfFormats(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -74,15 +227,24 @@ func TestFormDataPdfMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
mandatory bool
expectedMetadata map[string]interface{}
expectValidationError bool
}{
{
scenario: "no metadata form field",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: false,
expectedMetadata: nil,
expectValidationError: false,
},
{
scenario: "no metadata form field (mandatory)",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: true,
expectedMetadata: nil,
expectValidationError: true,
},
{
scenario: "invalid metadata form field",
ctx: func() *api.ContextMock {
@@ -94,6 +256,7 @@ func TestFormDataPdfMetadata(t *testing.T) {
})
return ctx
}(),
mandatory: false,
expectedMetadata: nil,
expectValidationError: true,
},
@@ -108,6 +271,7 @@ func TestFormDataPdfMetadata(t *testing.T) {
})
return ctx
}(),
mandatory: false,
expectedMetadata: map[string]interface{}{
"foo": "bar",
},
@@ -117,7 +281,7 @@ func TestFormDataPdfMetadata(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
form := tc.ctx.Context.FormData()
actual := FormDataPdfMetadata(form)
actual := FormDataPdfMetadata(form, tc.mandatory)
if !reflect.DeepEqual(actual, tc.expectedMetadata) {
t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
@@ -193,6 +357,128 @@ func TestMergeStub(t *testing.T) {
}
}
func TestSplitPdfStub(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
engine gotenberg.PdfEngine
mode gotenberg.SplitMode
expectError bool
}{
{
scenario: "no split mode",
mode: gotenberg.SplitMode{},
ctx: &api.ContextMock{Context: new(api.Context)},
expectError: false,
},
{
scenario: "cannot create subdirectory",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("cannot create subdirectory")
}})
return ctx
}(),
expectError: true,
},
{
scenario: "split error",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
expectError: true,
},
{
scenario: "rename error",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return errors.New("cannot rename")
}})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: true,
},
{
scenario: "success (intervals)",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: false,
},
{
scenario: "success (pages)",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString())
tc.ctx.SetDirPath(dirPath)
tc.ctx.SetLogger(zap.NewNop())
_, err := SplitPdfStub(tc.ctx.Context, tc.engine, tc.mode, []string{"my.pdf", "my2.pdf"})
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestConvertStub(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -503,6 +789,287 @@ func TestMergeHandler(t *testing.T) {
}
}
func TestSplitHandler(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
engine gotenberg.PdfEngine
expectError bool
expectHttpError bool
expectHttpStatus int
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "missing at least one mandatory file",
ctx: &api.ContextMock{Context: new(api.Context)},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "no split mode",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
return ctx
}(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "error from PDF engine (split)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "error from PDF engine (convert)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"pdfua": {
"true",
},
})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "error from PDF engine (write metadata)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "cannot add output paths",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
ctx.SetCancelled(true)
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "success (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"pdfua": {
"true",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{"file_split_1.pdf", "file_split_2.pdf"}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 2,
expectOutputPaths: []string{"/file/file_0.pdf", "/file/file_1.pdf"},
},
{
scenario: "success (pages)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModePages,
},
"splitSpan": {
"1-2",
},
"pdfua": {
"true",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{"/file/file.pdf"}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
expectOutputPaths: []string{"/file/file.pdf"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := splitRoute(tc.engine).Handler(c)
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var httpErr api.HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
for _, path := range tc.expectOutputPaths {
if !slices.Contains(tc.ctx.OutputPaths(), path) {
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
}
}
})
}
}
func TestConvertHandler(t *testing.T) {
for _, tc := range []struct {
scenario string