PipesForkParserTest.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.pipes.fork;

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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import org.apache.tika.config.OutputLimits;
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.parser.ParseContext;
import org.apache.tika.pipes.api.ParseMode;
import org.apache.tika.pipes.api.PipesResult;
import org.apache.tika.pipes.core.PipesException;
import org.apache.tika.pipes.core.fetcher.InlineBytes;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.apache.tika.sax.ContentHandlerFactory;

public class PipesForkParserTest {

    private static final Path PLUGINS_DIR = Paths.get("target/plugins");

    // PipesForkParser stamps handler and ParseMode from its config onto every request, so one
    // parser per distinct config; each forks lazily on its first parse.
    private static PipesForkParser defaultParser;
    private static PipesForkParser concatenateParser;
    private static PipesForkParser noParseParser;
    private static PipesForkParser xmlParser;
    private static PipesForkParser writeLimitParser;

    @TempDir
    Path tempDir;

    @BeforeAll
    static void setUp() throws Exception {
        if (!Files.isDirectory(PLUGINS_DIR)) {
            System.err.println("WARNING: Plugins directory not found at " + PLUGINS_DIR.toAbsolutePath() +
                    ". Tests may fail. Run 'mvn process-test-resources' first.");
        }
        defaultParser = new PipesForkParser(new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .addJvmArg("-Xmx256m"));
        concatenateParser = new PipesForkParser(new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setHandlerType(BasicContentHandlerFactory.HANDLER_TYPE.TEXT)
                .setParseMode(ParseMode.CONCATENATE));
        noParseParser = new PipesForkParser(new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setHandlerType(BasicContentHandlerFactory.HANDLER_TYPE.TEXT)
                .setParseMode(ParseMode.NO_PARSE));
        xmlParser = new PipesForkParser(new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setHandlerType(BasicContentHandlerFactory.HANDLER_TYPE.XML)
                .setParseMode(ParseMode.RMETA));
        writeLimitParser = new PipesForkParser(new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setHandlerType(BasicContentHandlerFactory.HANDLER_TYPE.TEXT)
                .setParseMode(ParseMode.RMETA)
                .setWriteLimit(100));
    }

    @AfterAll
    static void tearDown() throws Exception {
        for (PipesForkParser p : new PipesForkParser[]{defaultParser, concatenateParser,
                noParseParser, xmlParser, writeLimitParser}) {
            if (p != null) {
                p.close();
            }
        }
    }

    private Path createZipWithEmbeddedFiles(String zipName, String... entries) throws IOException {
        Path zipPath = tempDir.resolve(zipName);
        try (OutputStream fos = Files.newOutputStream(zipPath);
             ZipOutputStream zos = new ZipOutputStream(fos)) {
            for (int i = 0; i < entries.length; i += 2) {
                zos.putNextEntry(new ZipEntry(entries[i]));
                zos.write(entries[i + 1].getBytes(StandardCharsets.UTF_8));
                zos.closeEntry();
            }
        }
        return zipPath;
    }

    @Test
    public void testDefaultConfigIsRmetaUnlimitedText() {
        PipesForkParserConfig config = new PipesForkParserConfig();
        assertEquals(ParseMode.RMETA, config.getParseMode());
        BasicContentHandlerFactory factory =
                (BasicContentHandlerFactory) config.getContentHandlerFactory();
        assertEquals(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, factory.getType());
        assertEquals(-1, factory.getWriteLimit());
    }

    @Test
    public void testResultCategorization() {
        for (PipesResult.RESULT_STATUS status : PipesResult.RESULT_STATUS.values()) {
            PipesForkResult result = new PipesForkResult(new PipesResult(status));
            int trueCount = 0;
            if (result.isSuccess()) trueCount++;
            if (result.isProcessCrash()) trueCount++;
            if (result.isFatal()) trueCount++;
            if (result.isInitializationFailure()) trueCount++;
            if (result.isTaskException()) trueCount++;
            assertEquals(1, trueCount, "Exactly one category should be true for " + status);
        }
    }

    @Test
    public void testClasspathPluginsOnlyWhenOptedIn() throws Exception {
        Path testFile = tempDir.resolve("test.txt");
        Files.writeString(testFile, "classpath plugin");
        Path noPlugins = Files.createDirectories(tempDir.resolve("no-plugins"));

        PipesForkParserConfig optedIn = new PipesForkParserConfig()
                .setPluginsDir(noPlugins)
                .addJvmArg("-Dtika.plugins.classpath=true")
                .addJvmArg("-Xmx256m");
        try (PipesForkParser parser = new PipesForkParser(optedIn)) {
            PipesForkResult result = parser.parse(testFile);
            assertTrue(result.isSuccess(), "Status: " + result.getStatus()
                    + ", message: " + result.getMessage());
            assertTrue(result.getContent().contains("classpath plugin"));
        }

        PipesForkParserConfig zipsOnly = new PipesForkParserConfig()
                .setPluginsDir(noPlugins)
                .addJvmArg("-Xmx256m");
        try (PipesForkParser parser = new PipesForkParser(zipsOnly)) {
            PipesForkResult result = parser.parse(testFile);
            assertFalse(result.isSuccess(), "the file-system plugin jar is on the classpath, "
                    + "but without the opt-in the fork must not find it: " + result.getStatus());
        } catch (PipesForkParserException | PipesException e) {
            // fork refused to start without its fetcher: also the right answer
        }
    }

    @Test
    public void testParseTextFile() throws Exception {
        Path testFile = tempDir.resolve("test.txt");
        String content = "Hello, this is a test document.\nIt has multiple lines.";
        Files.writeString(testFile, content);

        try (TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkResult result = defaultParser.parse(tis);

            assertTrue(result.isSuccess(), "Parse should succeed. Status: " + result.getStatus()
                    + ", message: " + result.getMessage());
            assertFalse(result.isProcessCrash(), "Should not be a process crash");

            List<Metadata> metadataList = result.getMetadataList();
            assertNotNull(metadataList, "Metadata list should not be null");
            assertFalse(metadataList.isEmpty(), "Metadata list should not be empty");

            String extractedContent = result.getContent();
            assertNotNull(extractedContent, "Content should not be null");
            assertTrue(extractedContent.contains("Hello"), "Content should contain 'Hello'");
            assertTrue(extractedContent.contains("test document"), "Content should contain 'test document'");
        }
    }

    /** TIKA-4932: a Content-Type hint the detector rejected must not replace the detected type. */
    @Test
    public void testRejectedContentTypeHintDoesNotOverwriteDetectedType() throws Exception {
        Path testFile = tempDir.resolve("test.txt");
        Files.writeString(testFile, "plain text, not a pdf");
        PipesForkParserConfig config = new PipesForkParserConfig().setPluginsDir(PLUGINS_DIR);

        Metadata hints = new Metadata();
        hints.set(HttpHeaders.CONTENT_TYPE, "application/pdf");
        try (PipesForkParser parser = new PipesForkParser(config);
             TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkResult result = parser.parse(tis, hints, new ParseContext());
            assertTrue(result.isSuccess(), "status: " + result.getStatus());
            String detected = result.getMetadata().get(HttpHeaders.CONTENT_TYPE);
            assertTrue(detected.startsWith("text/plain"), detected);
        }
    }

    /** TIKA-4931: javaPath set in code must reach the process that starts the fork. */
    @Test
    public void testJavaPathReachesFork() throws Exception {
        Path testFile = tempDir.resolve("test.txt");
        Files.writeString(testFile, "hello");
        PipesForkParserConfig config = new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setJavaPath(tempDir.resolve("no-such-java").toString());

        try (PipesForkParser parser = new PipesForkParser(config);
             TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkParserException e =
                    assertThrows(PipesForkParserException.class, () -> parser.parse(tis));
            assertTrue(e.getMessage().contains("no-such-java"), e.getMessage());
        }
    }

    /** TIKA-4931: a code setting equal to the default still overrides the user config file. */
    @Test
    public void testStartBeforeFirstParse() throws Exception {
        Path testFile = tempDir.resolve("test.txt");
        Files.writeString(testFile, "hello");
        PipesForkParserConfig config = new PipesForkParserConfig().setPluginsDir(PLUGINS_DIR);

        try (PipesForkParser parser = new PipesForkParser(config);
             TikaInputStream tis = TikaInputStream.get(testFile)) {
            parser.start();
            assertTrue(parser.parse(tis).isSuccess());
        }
    }

    /** start() reports a fork that can't start, without a parse to provoke it. */
    @Test
    public void testStartFailsFast() throws Exception {
        PipesForkParserConfig config = new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setJavaPath(tempDir.resolve("no-such-java").toString());

        try (PipesForkParser parser = new PipesForkParser(config)) {
            PipesForkParserException e = assertThrows(PipesForkParserException.class, parser::start);
            assertEquals(PipesResult.RESULT_STATUS.FAILED_TO_INITIALIZE, e.getStatus());
            assertTrue(e.getMessage().contains("no-such-java"), e.getMessage());
        }
    }

    @Test
    public void testExplicitDefaultJavaPathBeatsUserConfig() throws Exception {
        Path userConfig = tempDir.resolve("user-config.json");
        Files.writeString(userConfig, "{\"pipes\":{\"javaPath\":\""
                + tempDir.resolve("no-such-java").toString().replace("\\", "\\\\") + "\"}}");
        Path testFile = tempDir.resolve("test.txt");
        Files.writeString(testFile, "hello");
        PipesForkParserConfig config = new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setUserConfigPath(userConfig)
                .setJavaPath("java");

        try (PipesForkParser parser = new PipesForkParser(config);
             TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkResult result = parser.parse(tis);
            assertTrue(result.isSuccess(), "status: " + result.getStatus());
        }
    }

    /** TIKA-4931: javaPath never set in code leaves the user config file's value in force. */
    @Test
    public void testUnsetJavaPathLeavesUserConfig() throws Exception {
        Path userConfig = tempDir.resolve("user-config.json");
        Files.writeString(userConfig, "{\"pipes\":{\"javaPath\":\""
                + tempDir.resolve("no-such-java").toString().replace("\\", "\\\\") + "\"}}");
        Path testFile = tempDir.resolve("test.txt");
        Files.writeString(testFile, "hello");
        PipesForkParserConfig config = new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setUserConfigPath(userConfig);

        try (PipesForkParser parser = new PipesForkParser(config);
             TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkParserException e =
                    assertThrows(PipesForkParserException.class, () -> parser.parse(tis));
            assertTrue(e.getMessage().contains("no-such-java"), e.getMessage());
        }
    }

    /**
     * The inline payload is request-owned: a caller reusing the ParseContext must not have this
     * document's bytes retained and re-serialized into later requests.
     */
    @Test
    public void testInlinePayloadNotRetainedInCallerContext() throws Exception {
        ParseContext parseContext = new ParseContext();
        byte[] content = "inline body".getBytes(StandardCharsets.UTF_8);
        try (TikaInputStream tis = TikaInputStream.get(new ByteArrayInputStream(content))) {
            PipesForkResult result = defaultParser.parse(tis, new Metadata(), parseContext);
            assertTrue(result.isSuccess(), "Parse should succeed. Status: " + result.getStatus()
                    + ", message: " + result.getMessage());
            assertTrue(result.getContent().contains("inline body"),
                    "worker did not parse the inline payload; content: " + result.getContent());
            assertNull(parseContext.get(InlineBytes.class),
                    "inline payload must not outlive its request in the caller's context");
        }
    }

    // TIKA-4981: per-call values beat the config's defaults, and the caller's context is not written
    @Test
    public void testPerCallParseModeOverridesConfig() throws Exception {
        Path testZip = createZipWithEmbeddedFiles("per_call_mode.zip",
                "embedded1.txt", "Content from first embedded file",
                "embedded2.txt", "Content from second embedded file");
        ParseContext parseContext = new ParseContext();
        parseContext.set(ParseMode.class, ParseMode.CONCATENATE);

        PipesForkResult result = defaultParser.parse(testZip, new Metadata(), parseContext);

        assertTrue(result.isSuccess(), "Parse should succeed. Status: " + result.getStatus());
        assertEquals(1, result.getMetadataList().size(), "per-call CONCATENATE was ignored");
        assertTrue(result.getContent().contains("second embedded"), result.getContent());
        assertNull(parseContext.get(ContentHandlerFactory.class),
                "the config's handler factory was written into the caller's context");
    }

    @Test
    public void testPerCallHandlerFactoryOverridesConfig() throws Exception {
        ParseContext parseContext = new ParseContext();
        parseContext.set(ContentHandlerFactory.class,
                new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.XML, -1));
        byte[] html = "<html><body><p>per call</p></body></html>".getBytes(StandardCharsets.UTF_8);
        Metadata metadata = new Metadata();
        metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, "per_call.html");

        try (TikaInputStream tis = TikaInputStream.get(new ByteArrayInputStream(html))) {
            PipesForkResult result = defaultParser.parse(tis, metadata, parseContext);

            assertTrue(result.isSuccess(), "Parse should succeed. Status: " + result.getStatus());
            assertTrue(result.getContent().contains("<p>per call</p>"),
                    "per-call XML handler was ignored; content: " + result.getContent());
        }
        assertNull(parseContext.get(ParseMode.class),
                "the config's parse mode was written into the caller's context");
    }

    @Test
    public void testParseWithMetadata() throws Exception {
        Path testFile = tempDir.resolve("test.html");
        String html = "<html><head><title>Test Title</title></head>" +
                "<body><p>Test paragraph content.</p></body></html>";
        Files.writeString(testFile, html);

        try (TikaInputStream tis = TikaInputStream.get(testFile)) {
            Metadata initialMetadata = new Metadata();
            PipesForkResult result = defaultParser.parse(tis, initialMetadata);

            assertTrue(result.isSuccess(), "Parse should succeed");

            Metadata metadata = result.getMetadata();
            assertNotNull(metadata, "Metadata should not be null");

            String extractedContent = result.getContent();
            assertNotNull(extractedContent, "Content should not be null");
            assertTrue(extractedContent.contains("Test paragraph"), "Content should contain paragraph text");
        }
    }

    @Test
    public void testConcatenateMode() throws Exception {
        Path testZip = createZipWithEmbeddedFiles("test_with_embedded.zip",
                "embedded1.txt", "Content from first embedded file",
                "embedded2.txt", "Content from second embedded file");

        try (TikaInputStream tis = TikaInputStream.get(testZip)) {
            PipesForkResult result = concatenateParser.parse(tis);

            assertTrue(result.isSuccess(), "Parse should succeed");

            // In CONCATENATE mode, there should be exactly one metadata object
            // even though the zip contains multiple embedded files
            List<Metadata> metadataList = result.getMetadataList();
            assertEquals(1, metadataList.size(), "CONCATENATE mode should return single metadata");

            String content = result.getContent();
            assertNotNull(content);
            assertTrue(content.contains("first embedded"),
                    "Content should contain text from first embedded file");
            assertTrue(content.contains("second embedded"),
                    "Content should contain text from second embedded file");
        }
    }

    @Test
    public void testNoParseMode() throws Exception {
        Path testFile = tempDir.resolve("test_no_parse.txt");
        String content = "This content should NOT be extracted in NO_PARSE mode.";
        Files.writeString(testFile, content);

        try (TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkResult result = noParseParser.parse(tis);

            assertTrue(result.isSuccess(), "Parse should succeed. Status: " + result.getStatus()
                    + ", message: " + result.getMessage());

            List<Metadata> metadataList = result.getMetadataList();
            assertEquals(1, metadataList.size(), "NO_PARSE mode should return single metadata");

            Metadata metadata = metadataList.get(0);
            String contentType = metadata.get(HttpHeaders.CONTENT_TYPE);
            assertNotNull(contentType, "Content type should be detected");
            assertTrue(contentType.contains("text/plain"),
                    "Content type should be text/plain, got: " + contentType);

            String extractedContent = result.getContent();
            assertTrue(extractedContent == null || extractedContent.isBlank(),
                    "NO_PARSE mode should not extract content, got: " + extractedContent);
        }
    }

    @Test
    public void testNoParseModeWithZip() throws Exception {
        // NO_PARSE must not extract embedded files
        Path testZip = createZipWithEmbeddedFiles("test_no_parse.zip",
                "embedded1.txt", "Content from first embedded file",
                "embedded2.txt", "Content from second embedded file");

        try (TikaInputStream tis = TikaInputStream.get(testZip)) {
            PipesForkResult result = noParseParser.parse(tis);

            assertTrue(result.isSuccess(), "Parse should succeed");

            List<Metadata> metadataList = result.getMetadataList();
            assertEquals(1, metadataList.size(),
                    "NO_PARSE mode should return only container metadata, not embedded files");

            Metadata metadata = metadataList.get(0);
            String contentType = metadata.get(HttpHeaders.CONTENT_TYPE);
            assertNotNull(contentType, "Content type should be detected");
            assertTrue(contentType.contains("zip"),
                    "Content type should be zip, got: " + contentType);

            String extractedContent = result.getContent();
            assertTrue(extractedContent == null || extractedContent.isBlank(),
                    "NO_PARSE mode should not extract content");
        }
    }

    // defaultParser sets only pluginsDir, so this also checks the default mode is RMETA end to end
    @Test
    public void testRmetaModeWithEmbedded() throws Exception {
        Path testZip = createZipWithEmbeddedFiles("test_rmeta_embedded.zip",
                "file1.txt", "First file content",
                "file2.txt", "Second file content");

        try (TikaInputStream tis = TikaInputStream.get(testZip)) {
            PipesForkResult result = defaultParser.parse(tis);

            assertTrue(result.isSuccess(), "Parse should succeed");

            // container (zip) + one per embedded file
            List<Metadata> metadataList = result.getMetadataList();
            assertTrue(metadataList.size() >= 3,
                    "RMETA mode should return metadata for container + embedded files, got: "
                    + metadataList.size());
        }
    }

    @Test
    public void testTextVsXhtmlHandlerType() throws Exception {
        Path testFile = tempDir.resolve("test_handler.html");
        String html = "<html><head><title>Test Title</title></head>" +
                "<body><p>Paragraph one.</p><p>Paragraph two.</p></body></html>";
        Files.writeString(testFile, html);

        String textContent;
        try (TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkResult result = defaultParser.parse(tis);
            assertTrue(result.isSuccess(), "TEXT parse should succeed");
            textContent = result.getContent();
            assertNotNull(textContent, "TEXT content should not be null");
            assertFalse(textContent.contains("<p>"), "TEXT content should not contain <p> tags");
            assertFalse(textContent.contains("<html>"), "TEXT content should not contain <html> tags");
            assertTrue(textContent.contains("Paragraph one"), "TEXT content should contain text");
        }

        String xmlContent;
        try (TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkResult result = xmlParser.parse(tis);
            assertTrue(result.isSuccess(), "XML parse should succeed");
            xmlContent = result.getContent();
            assertNotNull(xmlContent, "XML content should not be null");
            assertTrue(xmlContent.contains("<p>") || xmlContent.contains("<p "),
                    "XML content should contain <p> tags");
            assertTrue(xmlContent.contains("Paragraph one"), "XML content should contain text");
        }

        assertTrue(xmlContent.length() > textContent.length(),
                "XML content should be longer than TEXT content due to markup");
    }

    @Test
    public void testWriteLimit() throws Exception {
        Path testFile = tempDir.resolve("longfile.txt");
        StringBuilder longContent = new StringBuilder();
        for (int i = 0; i < 1000; i++) {
            longContent.append("This is line ").append(i).append(" of the test document.\n");
        }
        Files.writeString(testFile, longContent.toString());

        try (TikaInputStream tis = TikaInputStream.get(testFile)) {
            PipesForkResult result = writeLimitParser.parse(tis);

            assertTrue(result.isSuccess(), "status: " + result.getStatus());
            String content = result.getContent();
            assertTrue(content.contains("line 0 "), content);
            assertFalse(content.contains("line 999 "), "write limit ignored: " + content.length() + " chars");
        }
    }

    // the caller is the operator: a per-call limit may loosen the config's
    @Test
    public void testPerCallWriteLimitMayLoosenConfig() throws Exception {
        Path testFile = tempDir.resolve("longfile_loosened.txt");
        StringBuilder longContent = new StringBuilder();
        for (int i = 0; i < 1000; i++) {
            longContent.append("This is line ").append(i).append(" of the test document.\n");
        }
        Files.writeString(testFile, longContent.toString());
        Path userConfig = tempDir.resolve("tight-config.json");
        Files.writeString(userConfig,
                "{\"parse-context\": {\"output-limits\": {\"writeLimit\": 100}}}");
        ParseContext parseContext = new ParseContext();
        OutputLimits outputLimits = new OutputLimits();
        outputLimits.setWriteLimit(-1);
        parseContext.set(OutputLimits.class, outputLimits);
        parseContext.set(ContentHandlerFactory.class,
                new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));

        try (PipesForkParser parser = new PipesForkParser(new PipesForkParserConfig()
                .setPluginsDir(PLUGINS_DIR)
                .setUserConfigPath(userConfig))) {
            PipesForkResult result = parser.parse(testFile, new Metadata(), parseContext);
            assertTrue(result.isSuccess(), "status: " + result.getStatus());
            assertTrue(result.getContent().contains("line 999 "),
                    "per-call write limit was clamped: " + result.getContent().length() + " chars");
        }
    }

    // the caller is the operator: per-call config may set @OperatorOnly properties
    @Test
    public void testPerCallOperatorOnlyPropertyIsAllowed() throws Exception {
        Path pdf = tempDir.resolve("operator-only.pdf");
        try (PDDocument doc = new PDDocument()) {
            doc.addPage(new PDPage());
            doc.save(pdf.toFile());
        }
        ParseContext parseContext = new ParseContext();
        parseContext.setJsonConfig("pdf-parser", "{\"maxMainMemoryBytes\": 100000000}");

        PipesForkResult result = defaultParser.parse(pdf, new Metadata(), parseContext);

        assertTrue(result.isSuccess(), "status: " + result.getStatus());
        assertNull(result.getMetadata().get(TikaCoreProperties.CONTAINER_EXCEPTION),
                result.getMetadata().get(TikaCoreProperties.CONTAINER_EXCEPTION));
    }

    @Test
    public void testFileNotFoundThrowsException() throws Exception {
        Path nonExistentFile = tempDir.resolve("does_not_exist.txt");

        // TikaInputStream.get(Path) reads file attributes (size), so it throws before any parse
        assertThrows(java.nio.file.NoSuchFileException.class, () -> {
            TikaInputStream.get(nonExistentFile);
        });
    }

    @Test
    public void testParseWithPathAndMetadata() throws Exception {
        Path testFile = tempDir.resolve("test_path_metadata.txt");
        Files.writeString(testFile, "Content for metadata test");

        Metadata initialMetadata = new Metadata();
        initialMetadata.set("custom-key", "custom-value");

        PipesForkResult result = defaultParser.parse(testFile, initialMetadata);

        assertTrue(result.isSuccess(), "Parse should succeed");
        assertNotNull(result.getMetadata(), "Metadata should not be null");
        assertTrue(result.getContent().contains("metadata test"));
        assertEquals("custom-value", result.getMetadata().get("custom-key"));
    }

    // Two files in sequence through parse(Path) and parse(TikaInputStream) on one fork
    @Test
    public void testParsePathMatchesTikaInputStream() throws Exception {
        Path testFile1 = tempDir.resolve("path1.txt");
        Path testFile2 = tempDir.resolve("path2.txt");
        Files.writeString(testFile1, "Content of first path file");
        Files.writeString(testFile2, "Content of second path file");

        PipesForkResult pathResult1 = defaultParser.parse(testFile1);
        assertTrue(pathResult1.isSuccess(), "Parse should succeed. Status: " + pathResult1.getStatus()
                + ", message: " + pathResult1.getMessage());
        assertFalse(pathResult1.isProcessCrash(), "Should not be a process crash");
        assertNotNull(pathResult1.getMetadataList(), "Metadata list should not be null");
        assertFalse(pathResult1.getMetadataList().isEmpty(), "Metadata list should not be empty");
        assertTrue(pathResult1.getContent().contains("first path file"), pathResult1.getContent());

        PipesForkResult pathResult2 = defaultParser.parse(testFile2);
        assertTrue(pathResult2.isSuccess());
        assertTrue(pathResult2.getContent().contains("second path file"), pathResult2.getContent());

        try (TikaInputStream tis1 = TikaInputStream.get(testFile1);
             TikaInputStream tis2 = TikaInputStream.get(testFile2)) {
            PipesForkResult tisResult1 = defaultParser.parse(tis1);
            assertTrue(tisResult1.isSuccess());
            assertEquals(pathResult1.getContent(), tisResult1.getContent());
            PipesForkResult tisResult2 = defaultParser.parse(tis2);
            assertTrue(tisResult2.isSuccess());
            assertEquals(pathResult2.getContent(), tisResult2.getContent());
        }
    }
}