PluginComponentLoaderTest.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.plugins;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.pf4j.PluginManager;

import org.apache.tika.exception.TikaConfigException;

public class PluginComponentLoaderTest {

    private ObjectMapper objectMapper;
    private PluginManager pluginManager;
    private MockExtensionFactory factoryA;
    private MockExtensionFactory factoryB;

    // Concrete implementation of TikaExtension for testing
    static class MockTikaExtension implements TikaExtension {
        private final ExtensionConfig config;

        MockTikaExtension(ExtensionConfig config) {
            this.config = config;
        }

        @Override
        public ExtensionConfig getExtensionConfig() {
            return config;
        }
    }

    // Concrete factory class so we can use it with getExtensions(Class)
    static class MockExtensionFactory implements TikaExtensionFactory<MockTikaExtension> {
        private final String name;
        private MockTikaExtension instanceToReturn;

        MockExtensionFactory(String name) {
            this.name = name;
        }

        void setInstanceToReturn(MockTikaExtension instance) {
            this.instanceToReturn = instance;
        }

        @Override
        public String getName() {
            return name;
        }

        @Override
        public MockTikaExtension buildExtension(ExtensionConfig extensionConfig) {
            return instanceToReturn != null ? instanceToReturn : new MockTikaExtension(extensionConfig);
        }
    }

    @BeforeEach
    @SuppressWarnings("unchecked")
    public void setUp() {
        objectMapper = new ObjectMapper();

        factoryA = new MockExtensionFactory("type-a");
        factoryB = new MockExtensionFactory("type-b");

        pluginManager = mock(PluginManager.class);
        // Return non-empty list so loader doesn't try to load/start plugins
        when(pluginManager.getStartedPlugins()).thenReturn(Arrays.asList(mock(org.pf4j.PluginWrapper.class)));
        when(pluginManager.getExtensions(MockExtensionFactory.class))
                .thenReturn(Arrays.asList(factoryA, factoryB));
    }

    @Test
    public void testLoadSingleInstance() throws Exception {
        String json = """
                {
                    "type-a": {
                        "instance1": {
                            "someConfig": "value"
                        }
                    }
                }
                """;

        MockTikaExtension mockInstance = new MockTikaExtension(null);
        factoryA.setInstanceToReturn(mockInstance);

        JsonNode configNode = objectMapper.readTree(json);
        Map<String, MockTikaExtension> instances =
                PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertEquals(1, instances.size());
        assertSame(mockInstance, instances.get("instance1"));
    }

    @Test
    public void testLoadMultipleInstances() throws Exception {
        String json = """
                {
                    "type-a": {
                        "first": {}
                    },
                    "type-b": {
                        "second": {}
                    }
                }
                """;

        MockTikaExtension instanceA = new MockTikaExtension(null);
        MockTikaExtension instanceB = new MockTikaExtension(null);
        factoryA.setInstanceToReturn(instanceA);
        factoryB.setInstanceToReturn(instanceB);

        JsonNode configNode = objectMapper.readTree(json);
        Map<String, MockTikaExtension> instances =
                PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertEquals(2, instances.size());
        assertSame(instanceA, instances.get("first"));
        assertSame(instanceB, instances.get("second"));
    }

    @Test
    public void testMultipleInstancesSameType() throws Exception {
        String json = """
                {
                    "type-a": {
                        "first": { "id": 1 },
                        "second": { "id": 2 }
                    }
                }
                """;

        // Don't set a specific return - let factory create instances with config
        JsonNode configNode = objectMapper.readTree(json);
        Map<String, MockTikaExtension> instances =
                PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertEquals(2, instances.size());
        // Verify configs were passed correctly
        assertEquals("first", instances.get("first").getExtensionConfig().id());
        assertEquals("type-a", instances.get("first").getExtensionConfig().name());
        assertEquals("second", instances.get("second").getExtensionConfig().id());
    }

    @Test
    public void testUnknownTypeThrows() throws Exception {
        String json = """
                {
                    "unknown-type": {
                        "instance1": {}
                    }
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);

        TikaConfigException ex = assertThrows(TikaConfigException.class,
                () -> PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, configNode));

        assertTrue(ex.getMessage().contains("unknown-type"));
    }

    @Test
    public void testEmptyTypeReturnsNoInstances() throws Exception {
        // A type with no instances is valid - just returns nothing for that type
        String json = """
                {
                    "type-a": {}
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);
        Map<String, MockTikaExtension> instances =
                PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertTrue(instances.isEmpty());
    }

    @Test
    public void testDuplicateInstanceIdAcrossTypesThrows() throws Exception {
        // Same instance ID under different types should throw
        String json = """
                {
                    "type-a": {
                        "same-id": {}
                    },
                    "type-b": {
                        "same-id": {}
                    }
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);

        TikaConfigException ex = assertThrows(TikaConfigException.class,
                () -> PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, configNode));

        assertTrue(ex.getMessage().contains("same-id"));
        assertTrue(ex.getMessage().contains("Duplicate"));
    }

    @Test
    public void testNullConfigReturnsEmpty() throws Exception {
        Map<String, MockTikaExtension> instances =
                PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, null);
        assertTrue(instances.isEmpty());
    }

    @Test
    public void testEmptyConfigReturnsEmpty() throws Exception {
        JsonNode configNode = objectMapper.readTree("{}");
        Map<String, MockTikaExtension> instances =
                PluginComponentLoader.loadInstances(pluginManager, MockExtensionFactory.class, configNode);
        assertTrue(instances.isEmpty());
    }

    @Test
    @SuppressWarnings("unchecked")
    public void testDuplicateFactoryNamesSkipsDuplicate() throws Exception {
        // Duplicates are silently skipped (first one wins, or plugin version preferred over classpath)
        MockExtensionFactory duplicateFactory = new MockExtensionFactory("type-a"); // same name as factoryA

        PluginManager pmWithDupes = mock(PluginManager.class);
        when(pmWithDupes.getStartedPlugins()).thenReturn(Arrays.asList(mock(org.pf4j.PluginWrapper.class)));
        when(pmWithDupes.getExtensions(MockExtensionFactory.class))
                .thenReturn(Arrays.asList(factoryA, duplicateFactory));

        String json = """
                {
                    "type-a": {
                        "instance1": {}
                    }
                }
                """;
        JsonNode configNode = objectMapper.readTree(json);

        // Should not throw - duplicates are skipped
        Map<String, MockTikaExtension> instances =
                PluginComponentLoader.loadInstances(pmWithDupes, MockExtensionFactory.class, configNode);

        assertEquals(1, instances.size());
    }

    @Test
    public void testNoFactoriesButConfigExistsThrows() throws Exception {
        String json = """
                {
                    "some-type": {
                        "myInstance": {
                            "basePath": "/input"
                        }
                    }
                }
                """;

        // Plugin manager returns no factories
        PluginManager emptyPm = mock(PluginManager.class);
        when(emptyPm.getStartedPlugins()).thenReturn(Arrays.asList(mock(org.pf4j.PluginWrapper.class)));
        when(emptyPm.getExtensions(MockExtensionFactory.class))
                .thenReturn(java.util.Collections.emptyList());

        JsonNode configNode = objectMapper.readTree(json);

        TikaConfigException ex = assertThrows(TikaConfigException.class,
                () -> PluginComponentLoader.loadInstances(emptyPm, MockExtensionFactory.class, configNode));

        assertTrue(ex.getMessage().contains("some-type"));
        assertTrue(ex.getMessage().contains("Unknown type"));
    }

    // ---- Singleton tests ----

    @Test
    public void testLoadSingleton() throws Exception {
        String json = """
                {
                    "type-a": {
                        "someConfig": "value"
                    }
                }
                """;

        MockTikaExtension mockInstance = new MockTikaExtension(null);
        factoryA.setInstanceToReturn(mockInstance);

        JsonNode configNode = objectMapper.readTree(json);
        Optional<MockTikaExtension> result =
                PluginComponentLoader.loadSingleton(pluginManager, MockExtensionFactory.class, configNode);

        assertTrue(result.isPresent());
        assertSame(mockInstance, result.get());
    }

    @Test
    public void testLoadSingletonPassesConfig() throws Exception {
        String json = """
                {
                    "type-a": {
                        "basePath": "/input"
                    }
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);
        Optional<MockTikaExtension> result =
                PluginComponentLoader.loadSingleton(pluginManager, MockExtensionFactory.class, configNode);

        assertTrue(result.isPresent());
        // For singletons, id and name are both the typeName
        assertEquals("type-a", result.get().getExtensionConfig().id());
        assertEquals("type-a", result.get().getExtensionConfig().name());
        JsonNode parsedConfig = objectMapper.readTree(result.get().getExtensionConfig().json());
        assertEquals("/input", parsedConfig.get("basePath").asText());
    }

    @Test
    public void testLoadSingletonNullConfigReturnsEmpty() throws Exception {
        Optional<MockTikaExtension> result =
                PluginComponentLoader.loadSingleton(pluginManager, MockExtensionFactory.class, null);

        assertTrue(result.isEmpty());
    }

    @Test
    public void testLoadSingletonEmptyConfigReturnsEmpty() throws Exception {
        JsonNode configNode = objectMapper.readTree("{}");
        Optional<MockTikaExtension> result =
                PluginComponentLoader.loadSingleton(pluginManager, MockExtensionFactory.class, configNode);

        assertTrue(result.isEmpty());
    }

    @Test
    public void testLoadSingletonUnknownTypeThrows() throws Exception {
        String json = """
                {
                    "unknown-type": {
                        "foo": "bar"
                    }
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);

        TikaConfigException ex = assertThrows(TikaConfigException.class,
                () -> PluginComponentLoader.loadSingleton(pluginManager, MockExtensionFactory.class, configNode));

        assertTrue(ex.getMessage().contains("unknown-type"));
        assertTrue(ex.getMessage().contains("Unknown type"));
    }

    @Test
    public void testLoadSingletonMultipleTypesThrows() throws Exception {
        String json = """
                {
                    "type-a": {},
                    "type-b": {}
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);

        TikaConfigException ex = assertThrows(TikaConfigException.class,
                () -> PluginComponentLoader.loadSingleton(pluginManager, MockExtensionFactory.class, configNode));

        assertTrue(ex.getMessage().contains("multiple"));
    }

    // ---- Unnamed instances tests (for composite components like reporters) ----

    @Test
    public void testLoadUnnamedInstances() throws Exception {
        String json = """
                {
                    "type-a": {
                        "setting": "value1"
                    },
                    "type-b": {
                        "setting": "value2"
                    }
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);
        List<MockTikaExtension> instances =
                PluginComponentLoader.loadUnnamedInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertEquals(2, instances.size());
        // Verify order is preserved
        assertEquals("type-a", instances.get(0).getExtensionConfig().name());
        assertEquals("type-b", instances.get(1).getExtensionConfig().name());
        // For unnamed instances, id equals typeName
        assertEquals("type-a", instances.get(0).getExtensionConfig().id());
        assertEquals("type-b", instances.get(1).getExtensionConfig().id());
    }

    @Test
    public void testLoadUnnamedInstancesSingleItem() throws Exception {
        String json = """
                {
                    "type-a": {
                        "config": "test"
                    }
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);
        List<MockTikaExtension> instances =
                PluginComponentLoader.loadUnnamedInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertEquals(1, instances.size());
        assertEquals("type-a", instances.get(0).getExtensionConfig().name());
    }

    @Test
    public void testLoadUnnamedInstancesNullConfigReturnsEmpty() throws Exception {
        List<MockTikaExtension> instances =
                PluginComponentLoader.loadUnnamedInstances(pluginManager, MockExtensionFactory.class, null);

        assertTrue(instances.isEmpty());
    }

    @Test
    public void testLoadUnnamedInstancesEmptyConfigReturnsEmpty() throws Exception {
        JsonNode configNode = objectMapper.readTree("{}");
        List<MockTikaExtension> instances =
                PluginComponentLoader.loadUnnamedInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertTrue(instances.isEmpty());
    }

    @Test
    public void testLoadUnnamedInstancesUnknownTypeThrows() throws Exception {
        String json = """
                {
                    "type-a": {},
                    "unknown-type": {}
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);

        TikaConfigException ex = assertThrows(TikaConfigException.class,
                () -> PluginComponentLoader.loadUnnamedInstances(pluginManager, MockExtensionFactory.class, configNode));

        assertTrue(ex.getMessage().contains("unknown-type"));
        assertTrue(ex.getMessage().contains("Unknown type"));
    }

    @Test
    public void testLoadUnnamedInstancesPassesConfig() throws Exception {
        String json = """
                {
                    "type-a": {
                        "basePath": "/reports",
                        "enabled": true
                    }
                }
                """;

        JsonNode configNode = objectMapper.readTree(json);
        List<MockTikaExtension> instances =
                PluginComponentLoader.loadUnnamedInstances(pluginManager, MockExtensionFactory.class, configNode);

        assertEquals(1, instances.size());
        JsonNode config = objectMapper.readTree(instances.get(0).getExtensionConfig().json());
        assertEquals("/reports", config.get("basePath").asText());
        assertTrue(config.get("enabled").asBoolean());
    }
}