ContentEnrichersTest.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.tika.parser.enricher;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Set;

import org.junit.jupiter.api.Test;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import org.apache.tika.config.ServiceLoader;
import org.apache.tika.exception.TikaException;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.mime.MediaType;
import org.apache.tika.mime.MediaTypeRegistry;
import org.apache.tika.parser.CompositeParser;
import org.apache.tika.parser.DefaultParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.parser.ParserDecorator;

public class ContentEnrichersTest {

    private static final MediaType PNG = MediaType.image("png");
    private static final MediaType OCR_PNG = MediaType.image("ocr-png");

    private static class RecordingParser implements Parser {
        private static final long serialVersionUID = 1L;
        private final Set<MediaType> types;
        int calls = 0;
        String overrideSeenDuringParse;

        RecordingParser(Set<MediaType> types) {
            this.types = types;
        }

        @Override
        public Set<MediaType> getSupportedTypes(ParseContext context) {
            return types;
        }

        @Override
        public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                          ParseContext context) {
            calls++;
            overrideSeenDuringParse =
                    metadata.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE);
        }
    }

    private static class RecognizingParser extends RecordingParser implements TextRecognizer {
        private static final long serialVersionUID = 1L;
        private final boolean recognizes;

        RecognizingParser(Set<MediaType> types, boolean recognizes) {
            super(types);
            this.recognizes = recognizes;
        }

        @Override
        public boolean recognizesText(ParseContext context) {
            return recognizes;
        }
    }

    private static class AnnotatingParser extends RecordingParser implements ContentEnricher {
        private static final long serialVersionUID = 1L;

        AnnotatingParser(Set<MediaType> types) {
            super(types);
        }
    }

    private static CompositeContentEnricher listOf(Parser... enrichers) {
        return new CompositeContentEnricher(List.of(enrichers));
    }

    private static Metadata target(MediaType type) {
        Metadata target = new Metadata();
        if (type != null) {
            target.set(HttpHeaders.CONTENT_TYPE, type.toString());
        }
        return target;
    }

    private static CompositeParser compositeOf(Parser... parsers) {
        return new CompositeParser(MediaTypeRegistry.getDefaultRegistry(), parsers);
    }

    @Test
    public void testHasTextRecognizer() {
        ParseContext context = new ParseContext();
        RecordingParser annotator = new RecordingParser(Collections.singleton(PNG));
        assertFalse(ContentEnrichers.hasTextRecognizer(listOf(annotator), PNG, target(PNG), context),
                "an enricher that does not declare the capability is not a recognizer");
        assertTrue(ContentEnrichers.hasTextRecognizer(
                listOf(annotator, new RecognizingParser(Collections.singleton(PNG), true)),
                PNG, target(PNG), context));
        assertFalse(ContentEnrichers.hasTextRecognizer(
                listOf(new RecognizingParser(Collections.singleton(PNG), false)), PNG, target(PNG), context),
                "a recognizer that declines for this parse does not count");
        assertFalse(ContentEnrichers.hasTextRecognizer(
                listOf(new RecognizingParser(Collections.singleton(PNG), true)),
                MediaType.image("tiff"), target(MediaType.image("tiff")), context));
        assertFalse(ContentEnrichers.hasTextRecognizer(null, PNG, target(PNG), context));
        assertFalse(ContentEnrichers.hasTextRecognizer(null, null, target(null), context));
        // no list: found by interface
        context.set(Parser.class, compositeOf(new RecognizingParser(Set.of(PNG), true)));
        assertTrue(ContentEnrichers.hasTextRecognizer(null, PNG, target(PNG), context));
        assertFalse(ContentEnrichers.hasTextRecognizer(null, MediaType.image("tiff"),
                target(MediaType.image("tiff")), context));
        context.set(Parser.class, compositeOf(new RecognizingParser(Set.of(PNG), false)));
        assertFalse(ContentEnrichers.hasTextRecognizer(null, PNG, target(PNG), context),
                "a discovered recognizer that declines for this parse does not count");
        context.set(Parser.class, compositeOf(new AnnotatingParser(Set.of(PNG))));
        assertFalse(ContentEnrichers.hasTextRecognizer(null, PNG, target(PNG), context),
                "a discovered annotator is not a recognizer");
        // a legacy image/ocr-* claimant counts as a recognizer
        context.set(Parser.class, compositeOf(new RecordingParser(Set.of(OCR_PNG))));
        assertTrue(ContentEnrichers.hasTextRecognizer(null, PNG, target(PNG), context));
        // a list is authoritative
        assertFalse(ContentEnrichers.hasTextRecognizer(listOf(annotator), PNG, target(PNG), context));
    }

    @Test
    public void testSuspendedRecognizers() throws Exception {
        ParseContext context = new ParseContext();
        RecordingParser annotator = new RecordingParser(Set.of(PNG));
        RecognizingParser recognizer = new RecognizingParser(Set.of(PNG), true);
        RecognizingParser declining = new RecognizingParser(Set.of(PNG), false);
        RecordingParser legacy = new RecordingParser(Set.of(OCR_PNG));
        CompositeContentEnricher list = listOf(recognizer, annotator, declining, legacy);

        Parser annotators;
        try (ContentEnrichers.Suspension scope = ContentEnrichers.suspendRecognizers(context)) {
            annotators = ContentEnrichers.get(list, PNG, target(PNG), context);
            assertNotNull(annotators);
            assertFalse(ContentEnrichers.hasTextRecognizer(list, PNG, target(PNG), context));
            assertNull(ContentEnrichers.get(listOf(recognizer), PNG, target(PNG), context));
        }
        invoke(annotators, new Metadata(), context);
        assertEquals(1, annotator.calls);
        assertEquals(0, recognizer.calls);
        assertEquals(0, declining.calls, "a recognizer that declines is not an annotator");
        assertEquals(0, legacy.calls, "a legacy claimant is a recognizer");
        assertNotNull(ContentEnrichers.get(listOf(recognizer), PNG, target(PNG), context));
        assertTrue(ContentEnrichers.hasTextRecognizer(list, PNG, target(PNG), context));

        // no list: the discovered engine is filtered the same way
        context.set(Parser.class, compositeOf(new AnnotatingParser(Set.of(PNG))));
        try (ContentEnrichers.Suspension scope = ContentEnrichers.suspendRecognizers(context)) {
            assertNotNull(ContentEnrichers.get(null, PNG, target(PNG), context));
        }
        context.set(Parser.class, compositeOf(new RecognizingParser(Set.of(PNG), true)));
        try (ContentEnrichers.Suspension scope = ContentEnrichers.suspendRecognizers(context)) {
            assertNull(ContentEnrichers.get(null, PNG, target(PNG), context));
        }
        assertNotNull(ContentEnrichers.get(null, PNG, target(PNG), context));
    }

    @Test
    public void testSuspendedDispatch() throws Exception {
        ParseContext context = new ParseContext();
        CompositeContentEnricher list = listOf(new RecognizingParser(Set.of(PNG), true),
                new RecordingParser(Set.of(PNG)));
        try (ContentEnrichers.Suspension outer = ContentEnrichers.suspend(context)) {
            assertNull(ContentEnrichers.get(list, PNG, target(PNG), context));
            assertFalse(ContentEnrichers.hasTextRecognizer(list, PNG, target(PNG), context));
            try (ContentEnrichers.Suspension inner = ContentEnrichers.suspend(context)) {
                assertNull(ContentEnrichers.get(list, PNG, target(PNG), context));
            }
            assertNull(ContentEnrichers.get(list, PNG, target(PNG), context),
                    "an inner scope restores the outer suspension, it does not lift it");
            try (ContentEnrichers.Suspension inner =
                         ContentEnrichers.suspendRecognizers(context)) {
                assertNull(ContentEnrichers.get(list, PNG, target(PNG), context));
            }
            assertNull(ContentEnrichers.get(list, PNG, target(PNG), context),
                    "a recognizer scope inside a full suspension changes nothing on close");
        }
        assertNotNull(ContentEnrichers.get(list, PNG, target(PNG), context));
        assertTrue(ContentEnrichers.hasTextRecognizer(list, PNG, target(PNG), context));
    }

    @Test
    public void testHasTextRecognizerRefusedDuringEnrichment() throws Exception {
        ParseContext context = new ParseContext();
        Parser reentrant = new Parser() {
            private static final long serialVersionUID = 1L;

            @Override
            public Set<MediaType> getSupportedTypes(ParseContext ctx) {
                return Collections.singleton(PNG);
            }

            @Override
            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                              ParseContext ctx) {
                metadata.set("nested-recognizer", ContentEnrichers.hasTextRecognizer(
                        ctx.get(CompositeContentEnricher.class), PNG, target(PNG), ctx) ? "yes" : "no");
            }
        };
        CompositeContentEnricher enrichers = listOf(
                reentrant, new RecognizingParser(Collections.singleton(PNG), true));
        context.set(CompositeContentEnricher.class, enrichers);
        assertTrue(ContentEnrichers.hasTextRecognizer(enrichers, PNG, target(PNG), context));
        Metadata metadata = new Metadata();
        invoke(ContentEnrichers.get(enrichers, PNG, target(PNG), context), metadata, context);
        assertEquals("no", metadata.get("nested-recognizer"));
    }

    private static void invoke(Parser enricher, Metadata metadata, ParseContext context)
            throws IOException, SAXException, TikaException {
        try (TikaInputStream tis = TikaInputStream.get(new byte[0])) {
            enricher.parse(tis, new DefaultHandler(), metadata, context);
        }
    }

    @Test
    public void testExplicitListWinsOverDiscovered() throws Exception {
        RecordingParser explicit = new RecordingParser(Collections.singleton(PNG));
        RecognizingParser discovered = new RecognizingParser(Collections.singleton(PNG), true);
        CompositeContentEnricher enrichers =
                new CompositeContentEnricher(List.of(explicit));
        ParseContext context = new ParseContext();
        context.set(Parser.class, compositeOf(discovered));

        Parser enricher = ContentEnrichers.get(enrichers, PNG, target(PNG), context);
        assertNotNull(enricher);
        invoke(enricher, new Metadata(), context);
        assertEquals(1, explicit.calls);
        assertEquals(0, discovered.calls);
        assertNull(explicit.overrideSeenDuringParse);
    }

    /** No list: invoked directly on the real type. */
    @Test
    public void testDiscoveredRecognizerInvokedDirectly() throws Exception {
        RecognizingParser engine = new RecognizingParser(Collections.singleton(PNG), true);
        ParseContext context = new ParseContext();
        context.set(Parser.class, compositeOf(new RecordingParser(Set.of(PNG)), engine));

        Parser enricher = ContentEnrichers.get(null, PNG, target(PNG), context);
        assertNotNull(enricher);
        Metadata metadata = new Metadata();
        metadata.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
        invoke(enricher, metadata, context);
        assertEquals(1, engine.calls);
        assertNull(engine.overrideSeenDuringParse);
        assertNull(metadata.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE));
        assertEquals(PNG.toString(), metadata.get(HttpHeaders.CONTENT_TYPE));
        assertEquals(RecognizingParser.class.getName(),
                metadata.get(TikaCoreProperties.TIKA_PARSED_BY));
        // a plain parser is dispatch, not enrichment
        context.set(Parser.class, compositeOf(new RecordingParser(Set.of(PNG))));
        assertNull(ContentEnrichers.get(null, PNG, target(PNG), context));
    }

    /**
     * No list: an engine under "parsers" beats a discovered one, the last claimant wins
     * within a tier, and a _mime-exclude on the entry applies.
     */
    @Test
    public void testDiscoveryPrecedence() throws Exception {
        RecognizingParser spiFirst = new RecognizingParser(Set.of(PNG), true);
        RecognizingParser spiLast = new RecognizingParser(Set.of(PNG), true);
        DefaultParser defaults = new DefaultParser(MediaTypeRegistry.getDefaultRegistry(),
                new ServiceLoader(new ClassLoader(null) { })) {
            private static final long serialVersionUID = 1L;

            @Override
            public List<Parser> getAllComponentParsers() {
                return List.of(spiFirst, spiLast);
            }
        };
        ParseContext context = new ParseContext();
        context.set(Parser.class, compositeOf(defaults));
        invoke(ContentEnrichers.get(null, PNG, target(PNG), context), new Metadata(), context);
        assertEquals(1, spiLast.calls, "within a tier the last claimant wins");
        assertEquals(0, spiFirst.calls);

        RecognizingParser configured = new RecognizingParser(Set.of(PNG), true);
        context.set(Parser.class, compositeOf(configured, defaults));
        invoke(ContentEnrichers.get(null, PNG, target(PNG), context), new Metadata(), context);
        assertEquals(1, configured.calls, "an engine configured under parsers wins");
        assertEquals(1, spiLast.calls);

        Parser excluded = ParserDecorator.withoutTypes(configured, Set.of(PNG));
        context.set(Parser.class, compositeOf(excluded, defaults));
        invoke(ContentEnrichers.get(null, PNG, target(PNG), context), new Metadata(), context);
        assertEquals(1, configured.calls, "an excluded type falls through to the next tier");
        assertEquals(2, spiLast.calls);
    }

    private static DefaultParser spiDefaults(Parser... found) {
        return new DefaultParser(MediaTypeRegistry.getDefaultRegistry(),
                new ServiceLoader(new ClassLoader(null) { })) {
            private static final long serialVersionUID = 1L;

            @Override
            public List<Parser> getAllComponentParsers() {
                return List.of(found);
            }
        };
    }

    /** A filter on the default-parser entry applies to the engines discovered inside it. */
    @Test
    public void testDefaultParserFilterAppliesToDiscovery() {
        MediaType tiff = MediaType.image("tiff");
        DefaultParser defaults = spiDefaults(new RecognizingParser(Set.of(PNG, tiff), true));
        ParseContext context = new ParseContext();
        context.set(Parser.class, compositeOf(
                ParserDecorator.withMimeFilters(defaults, null, Set.of(PNG))));
        assertNull(ContentEnrichers.get(null, PNG, target(PNG), context), "excluded on default-parser");
        assertNotNull(ContentEnrichers.get(null, tiff, target(tiff), context));
        context.set(Parser.class, compositeOf(
                ParserDecorator.withMimeFilters(defaults, Set.of(tiff), null)));
        assertNull(ContentEnrichers.get(null, PNG, target(PNG), context), "not included on default-parser");
        assertNotNull(ContentEnrichers.get(null, tiff, target(tiff), context));
    }

    /** The capability is asked of the engine, not the decorator around it. */
    @Test
    public void testTextRecognizerSeenThroughDecorator() {
        MediaType tiff = MediaType.image("tiff");
        Parser decorated = ParserDecorator.withMimeFilters(
                new RecognizingParser(Set.of(PNG, tiff), true), null, Set.of(tiff));
        ParseContext context = new ParseContext();
        assertTrue(ContentEnrichers.hasTextRecognizer(listOf(decorated), PNG, target(PNG), context));
        assertFalse(ContentEnrichers.hasTextRecognizer(listOf(decorated), tiff, target(tiff), context));
        context.set(Parser.class, compositeOf(decorated));
        assertTrue(ContentEnrichers.hasTextRecognizer(null, PNG, target(PNG), context));
        assertFalse(ContentEnrichers.hasTextRecognizer(null, tiff, target(tiff), context));
    }

    /** A real-type filter on a legacy engine's entry applies in discovery. */
    @Test
    public void testLegacyClaimantHonorsRealTypeFilter() {
        MediaType tiff = MediaType.image("tiff");
        RecordingParser legacy =
                new RecordingParser(Set.of(OCR_PNG, MediaType.image("ocr-tiff")));
        ParseContext context = new ParseContext();
        context.set(Parser.class, compositeOf(
                ParserDecorator.withMimeFilters(legacy, null, Set.of(tiff))));
        assertNull(ContentEnrichers.get(null, tiff, target(tiff), context));
        assertNotNull(ContentEnrichers.get(null, PNG, target(PNG), context));
        context.set(Parser.class, compositeOf(
                ParserDecorator.withMimeFilters(legacy, Set.of(tiff), null)));
        assertNotNull(ContentEnrichers.get(null, tiff, target(tiff), context));
        assertNull(ContentEnrichers.get(null, PNG, target(PNG), context));
    }

    @Test
    public void testExcludeAppliesToLegacyPseudoType() {
        // either spelling of the excluded type must work
        Set<MediaType> legacy = Set.of(OCR_PNG, MediaType.image("ocr-tiff"));
        for (String spelling : new String[]{"image/tiff", "image/ocr-tiff"}) {
            Parser excluded = ParserDecorator.withoutTypes(new RecordingParser(legacy),
                    Collections.singleton(MediaType.parse(spelling)));
            CompositeContentEnricher enrichers =
                    new CompositeContentEnricher(List.of(excluded));
            assertEquals(Set.of(PNG), enrichers.getSupportedTypes(), spelling);
            assertTrue(enrichers.getEnrichers(MediaType.image("tiff")).isEmpty(), spelling);
        }
    }

    /** A legacy image/ocr-* engine is discovered for the real type. */
    @Test
    public void testLegacyClaimantDiscovered() throws Exception {
        RecordingParser legacy = new RecordingParser(Collections.singleton(OCR_PNG));
        ParseContext context = new ParseContext();
        context.set(Parser.class, compositeOf(legacy));

        Parser enricher = ContentEnrichers.get(null, PNG, target(PNG), context);
        assertNotNull(enricher);

        Metadata metadata = new Metadata();
        metadata.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
        invoke(enricher, metadata, context);

        assertEquals(1, legacy.calls);
        assertNull(legacy.overrideSeenDuringParse, "nothing is minted any more");
        assertEquals(PNG.toString(), metadata.get(HttpHeaders.CONTENT_TYPE));
        assertNull(ContentEnrichers.get(null, MediaType.image("tiff"), target(MediaType.image("tiff")), context));
    }

    @Test
    public void testNoneAvailable() {
        ParseContext context = new ParseContext();
        assertNull(ContentEnrichers.get(null, PNG, target(PNG), context));
        // composite that claims nothing
        context.set(Parser.class, compositeOf(new RecognizingParser(Collections.emptySet(), true)));
        assertNull(ContentEnrichers.get(null, PNG, target(PNG), context));
        assertNull(ContentEnrichers.get(null, null, target(null), context));
    }

    @Test
    public void testConfiguredListIsAuthoritative() throws Exception {
        RecordingParser explicit = new RecordingParser(Collections.singleton(PNG));
        RecognizingParser discovered =
                new RecognizingParser(Collections.singleton(MediaType.image("tiff")), true);
        CompositeContentEnricher enrichers = new CompositeContentEnricher(List.of(explicit));
        ParseContext context = new ParseContext();
        context.set(Parser.class, compositeOf(discovered));

        assertNull(ContentEnrichers.get(enrichers, MediaType.image("tiff"),
                target(MediaType.image("tiff")), context));
        assertNotNull(ContentEnrichers.get(null, MediaType.image("tiff"),
                target(MediaType.image("tiff")), context));
    }

    @Test
    public void testParametersIgnoredInMatching() throws Exception {
        RecordingParser explicit = new RecordingParser(Collections.singleton(PNG));
        CompositeContentEnricher enrichers = new CompositeContentEnricher(List.of(explicit));
        ParseContext context = new ParseContext();

        Parser enricher = ContentEnrichers.get(enrichers,
                MediaType.parse("image/png; charset=binary"),
                        target(MediaType.parse("image/png; charset=binary")), context);
        assertNotNull(enricher, "parameterized type must match the base-type registration");
        invoke(enricher, new Metadata(), context);
        assertEquals(1, explicit.calls);
    }

    @Test
    public void testEnricherCannotRewriteContentType() throws Exception {
        Parser rewriting = new Parser() {
            private static final long serialVersionUID = 1L;

            @Override
            public Set<MediaType> getSupportedTypes(ParseContext context) {
                return Collections.singleton(PNG);
            }

            @Override
            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                              ParseContext context) {
                metadata.set(HttpHeaders.CONTENT_TYPE, "application/pdf");
            }
        };
        CompositeContentEnricher enrichers = new CompositeContentEnricher(List.of(rewriting));
        ParseContext context = new ParseContext();

        Metadata metadata = new Metadata();
        metadata.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
        invoke(ContentEnrichers.get(enrichers, PNG, target(PNG), context), metadata, context);
        assertEquals(PNG.toString(), metadata.get(HttpHeaders.CONTENT_TYPE));

        Metadata unset = new Metadata();
        invoke(ContentEnrichers.get(enrichers, PNG, target(PNG), context), unset, context);
        assertNull(unset.get(HttpHeaders.CONTENT_TYPE));
    }

    @Test
    public void testRuntimeFailureAbortsChainWithEarlierFailureSuppressed() throws Exception {
        List<String> order = new java.util.ArrayList<>();
        Parser failing = namedEnricher("failing", order, true);
        Parser blowingUp = new Parser() {
            private static final long serialVersionUID = 1L;

            @Override
            public Set<MediaType> getSupportedTypes(ParseContext context) {
                return Collections.singleton(PNG);
            }

            @Override
            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                              ParseContext context) {
                order.add("blowingUp");
                throw new NullPointerException("boom");
            }
        };
        Parser third = namedEnricher("third", order, false);
        CompositeContentEnricher enrichers =
                new CompositeContentEnricher(List.of(failing, blowingUp, third));
        ParseContext context = new ParseContext();

        Parser enricher = ContentEnrichers.get(enrichers, PNG, target(PNG), context);
        assertNotNull(enricher);
        NullPointerException thrown = org.junit.jupiter.api.Assertions.assertThrows(
                NullPointerException.class, () -> invoke(enricher, new Metadata(), context));
        assertEquals(List.of("failing", "blowingUp"), order);
        // the recorded checked failure rides along instead of vanishing
        assertEquals(1, thrown.getSuppressed().length);
        assertEquals("failing failed", thrown.getSuppressed()[0].getMessage());
    }

    @Test
    public void testAllMatchingEnrichersRunInOrder() throws Exception {
        List<String> order = new java.util.ArrayList<>();
        Parser first = namedEnricher("first", order, false);
        Parser second = namedEnricher("second", order, false);
        CompositeContentEnricher enrichers = new CompositeContentEnricher(List.of(first, second));
        ParseContext context = new ParseContext();

        Parser enricher = ContentEnrichers.get(enrichers, PNG, target(PNG), context);
        assertNotNull(enricher);
        Metadata metadata = new Metadata();
        invoke(enricher, metadata, context);
        assertEquals(List.of("first", "second"), order);
        // both members share one class: recorded once
        assertEquals(List.of(first.getClass().getName()),
                List.of(metadata.getValues(TikaCoreProperties.TIKA_PARSED_BY)),
                "an invoked enricher is recorded as the composite would record it");
    }

    @Test
    public void testChainIsBestEffortAndStillReportsFailure() throws Exception {
        List<String> order = new java.util.ArrayList<>();
        Parser failing = namedEnricher("failing", order, true);
        Parser second = namedEnricher("second", order, false);
        CompositeContentEnricher enrichers = new CompositeContentEnricher(List.of(failing, second));
        ParseContext context = new ParseContext();

        Parser enricher = ContentEnrichers.get(enrichers, PNG, target(PNG), context);
        assertNotNull(enricher);
        TikaException thrown = org.junit.jupiter.api.Assertions.assertThrows(TikaException.class,
                () -> invoke(enricher, new Metadata(), context));
        // the failure did not stop the second enricher, and was still rethrown at the end
        assertEquals(List.of("failing", "second"), order);
        assertEquals("failing failed", thrown.getMessage());
        // the guard is released even when the chain throws
        assertNotNull(ContentEnrichers.get(enrichers, PNG, target(PNG), context));
    }

    @Test
    public void testTimeoutAbortsChainImmediately() throws Exception {
        List<String> order = new java.util.ArrayList<>();
        Parser timingOut = new Parser() {
            private static final long serialVersionUID = 1L;

            @Override
            public Set<MediaType> getSupportedTypes(ParseContext context) {
                return Collections.singleton(PNG);
            }

            @Override
            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                              ParseContext context) throws TikaException {
                order.add("timingOut");
                throw new org.apache.tika.exception.TikaTimeoutException("budget spent", 1, 1);
            }
        };
        Parser second = namedEnricher("second", order, false);
        CompositeContentEnricher enrichers =
                new CompositeContentEnricher(List.of(timingOut, second));
        ParseContext context = new ParseContext();

        Parser enricher = ContentEnrichers.get(enrichers, PNG, target(PNG), context);
        assertNotNull(enricher);
        org.junit.jupiter.api.Assertions.assertThrows(
                org.apache.tika.exception.TikaTimeoutException.class,
                () -> invoke(enricher, new Metadata(), context));
        assertEquals(List.of("timingOut"), order);
    }

    private static Parser namedEnricher(String name, List<String> order, boolean fail) {
        return new Parser() {
            private static final long serialVersionUID = 1L;

            @Override
            public Set<MediaType> getSupportedTypes(ParseContext context) {
                return Collections.singleton(PNG);
            }

            @Override
            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                              ParseContext context) throws TikaException {
                order.add(name);
                if (fail) {
                    throw new TikaException(name + " failed");
                }
            }
        };
    }

    /**
     * A legacy engine's image/ocr-* advertisement must match the real type, and an engine
     * advertising both forms must run once, not twice.
     */
    @Test
    public void testLegacyOcrTypeAdvertisementsMatchRealTypes() throws Exception {
        List<String> order = new java.util.ArrayList<>();
        Parser legacyEngine = new Parser() {
            private static final long serialVersionUID = 1L;

            @Override
            public Set<MediaType> getSupportedTypes(ParseContext context) {
                return Set.of(OCR_PNG, MediaType.image("jp2"), MediaType.image("ocr-jp2"));
            }

            @Override
            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                              ParseContext context) {
                order.add("legacyEngine");
            }
        };
        CompositeContentEnricher enrichers =
                new CompositeContentEnricher(List.of(legacyEngine));
        ParseContext context = new ParseContext();

        Parser forPng = ContentEnrichers.get(enrichers, PNG, target(PNG), context);
        assertNotNull(forPng, "ocr-png advertisement must be nameable for image/png");
        invoke(forPng, new Metadata(), context);
        assertEquals(List.of("legacyEngine"), order);

        order.clear();
        Parser forJp2 = ContentEnrichers.get(enrichers, MediaType.image("jp2"),
                target(MediaType.image("jp2")), context);
        assertNotNull(forJp2);
        invoke(forJp2, new Metadata(), context);
        assertEquals(List.of("legacyEngine"), order,
                "real + pseudo advertisement of the same type must run once");
    }

    @Test
    public void testRecursionGuard() throws Exception {
        ParseContext context = new ParseContext();
        // an enricher that tries to re-enter enrichment from inside its own parse
        Parser reentrant = new Parser() {
            private static final long serialVersionUID = 1L;

            @Override
            public Set<MediaType> getSupportedTypes(ParseContext ctx) {
                return Collections.singleton(PNG);
            }

            @Override
            public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                              ParseContext ctx) {
                metadata.set("nested-enricher",
                        ContentEnrichers.get(
                                ctx.get(CompositeContentEnricher.class), PNG, target(PNG), ctx) == null
                                ? "refused" : "allowed");
            }
        };
        CompositeContentEnricher enrichers = new CompositeContentEnricher(List.of(reentrant));
        context.set(CompositeContentEnricher.class, enrichers);

        Parser enricher = ContentEnrichers.get(enrichers, PNG, target(PNG), context);
        assertNotNull(enricher);
        Metadata metadata = new Metadata();
        invoke(enricher, metadata, context);
        assertEquals("refused", metadata.get("nested-enricher"));

        // and enrichment is available again once the first one completes
        assertNotNull(ContentEnrichers.get(enrichers, PNG, target(PNG), context));
    }

    /** Load-time resolution applies the discovery rules once, for every type. */
    @Test
    public void testResolveOneEnginePerType() throws Exception {
        MediaType tiff = MediaType.image("tiff");
        RecognizingParser spiFirst = new RecognizingParser(Set.of(PNG, tiff), true);
        RecognizingParser spiLast = new RecognizingParser(Set.of(tiff), true);
        RecognizingParser configured = new RecognizingParser(Set.of(PNG), true);
        CompositeContentEnricher resolved = ContentEnrichers.resolve(
                compositeOf(configured, spiDefaults(spiFirst, spiLast)));
        assertEquals(Set.of(PNG, tiff), resolved.getSupportedTypes());
        assertEquals(List.of(configured), resolved.getEnrichers(PNG),
                "an engine configured under parsers wins its types");
        assertEquals(List.of(spiLast), resolved.getEnrichers(tiff),
                "within the default tier the last claimant wins");
        assertFalse(resolved.isLegacyClaimant(configured));

        ParseContext context = new ParseContext();
        assertTrue(ContentEnrichers.hasTextRecognizer(resolved, PNG, target(PNG), context));
        invoke(ContentEnrichers.get(resolved, tiff, target(tiff), context), new Metadata(), context);
        assertEquals(1, spiLast.calls);
        assertEquals(0, spiFirst.calls);

        // a filter on the default-parser entry applies to the engines inside it
        resolved = ContentEnrichers.resolve(compositeOf(ParserDecorator.withMimeFilters(
                spiDefaults(spiFirst, spiLast), null, Set.of(tiff))));
        assertEquals(Set.of(PNG), resolved.getSupportedTypes());
        assertEquals(List.of(spiFirst), resolved.getEnrichers(PNG));

        assertTrue(ContentEnrichers.resolve(compositeOf(new RecordingParser(Set.of(PNG))))
                .isEmpty(), "a tree without enrichers resolves to nothing");
    }

    /** A legacy image/ocr-* claimant counts as a text recognizer on both paths until 5.0. */
    @Test
    public void testLegacyClaimantIsRecognizerOnBothPaths() {
        RecordingParser legacy = new RecordingParser(Set.of(OCR_PNG));
        ParseContext context = new ParseContext();
        assertTrue(ContentEnrichers.hasTextRecognizer(listOf(legacy), PNG, target(PNG), context),
                "named in the list");
        CompositeContentEnricher resolved =
                ContentEnrichers.resolve(compositeOf(spiDefaults(legacy)));
        assertEquals(List.of(legacy), resolved.getEnrichers(PNG));
        assertTrue(resolved.isLegacyClaimant(legacy));
        assertTrue(ContentEnrichers.hasTextRecognizer(resolved, PNG, target(PNG), context), "resolved");
        assertFalse(ContentEnrichers.hasTextRecognizer(
                listOf(new AnnotatingParser(Set.of(PNG))), PNG, target(PNG), context),
                "an enricher advertising real types without the capability is not one");
    }
}