ConfigLoaderTest.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.config.loader;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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.nio.file.Path;
import java.nio.file.Paths;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.io.SpoolingStrategy;
import org.apache.tika.mime.MediaType;
/**
* Unit tests for {@link ConfigLoader}.
*/
public class ConfigLoaderTest {
private TikaJsonConfig tikaJsonConfig;
private ConfigLoader configLoader;
@BeforeEach
public void setUp() throws Exception {
Path configPath = Paths.get(
getClass().getResource("/configs/test-config-loader.json").toURI());
tikaJsonConfig = TikaJsonConfig.load(configPath);
ObjectMapper objectMapper = TikaObjectMapperFactory.getMapper();
configLoader = new ConfigLoader(tikaJsonConfig, objectMapper);
}
// ==================== Test POJOs ====================
/**
* Simple config POJO with properties for testing config loading.
*/
public static class RetryConfig {
private int timeout;
private int retries;
private boolean enabled;
public int getTimeout() {
return timeout;
}
public void setTimeout(int timeout) {
this.timeout = timeout;
}
public int getRetries() {
return retries;
}
public void setRetries(int retries) {
this.retries = retries;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
/**
* Config class with suffix that should be stripped.
*/
public static class TestTimeoutConfig {
private long millis;
public long getMillis() {
return millis;
}
public void setMillis(long millis) {
this.millis = millis;
}
}
/**
* Config class with "Settings" suffix.
*/
public static class MyFeatureSettings {
private String featureName;
private int priority;
public String getFeatureName() {
return featureName;
}
public void setFeatureName(String featureName) {
this.featureName = featureName;
}
public int getPriority() {
return priority;
}
public void setPriority(int priority) {
this.priority = priority;
}
}
/**
* Interface for testing interface handling.
*/
public interface TestHandler {
String getName();
}
/**
* Simple implementation with no-arg constructor.
*/
public static class SimpleHandlerImpl implements TestHandler {
public SimpleHandlerImpl() {
}
@Override
public String getName() {
return "simple";
}
}
/**
* Implementation with configuration properties.
*/
public static class ConfiguredHandlerImpl implements TestHandler {
private int maxSize;
private String prefix;
public ConfiguredHandlerImpl() {
}
@Override
public String getName() {
return "configured";
}
public int getMaxSize() {
return maxSize;
}
public void setMaxSize(int maxSize) {
this.maxSize = maxSize;
}
public String getPrefix() {
return prefix;
}
public void setPrefix(String prefix) {
this.prefix = prefix;
}
}
/**
* Abstract class for testing abstract class handling.
*/
public abstract static class AbstractHandler implements TestHandler {
public abstract void doSomething();
}
// ==================== Tests ====================
@Test
public void testLoadByExplicitKey() throws Exception {
RetryConfig config = configLoader.load("retry-config", RetryConfig.class);
assertNotNull(config);
assertEquals(5000, config.getTimeout());
assertEquals(3, config.getRetries());
assertTrue(config.isEnabled());
}
@Test
public void testLoadByClassNameKebabCase() throws Exception {
RetryConfig config = configLoader.load(RetryConfig.class);
assertNotNull(config);
assertEquals(5000, config.getTimeout());
}
@Test
public void testLoadByClassNameTestTimeoutConfig() throws Exception {
// TestTimeoutConfig -> "test-timeout-config" (kebab-case)
// JSON has "test-timeout-config"
TestTimeoutConfig timeout = configLoader.load(TestTimeoutConfig.class);
assertNotNull(timeout);
assertEquals(30000, timeout.getMillis());
}
@Test
public void testLoadSpoolingStrategy() throws Exception {
// SpoolingStrategy -> "spooling-strategy"
// JSON has "spooling-strategy" with spoolTypes: ["application/zip", "application/pdf"]
SpoolingStrategy strategy = configLoader.load(SpoolingStrategy.class);
assertNotNull(strategy);
assertEquals(2, strategy.getSpoolTypes().size());
assertTrue(strategy.getSpoolTypes().contains(MediaType.application("zip")));
assertTrue(strategy.getSpoolTypes().contains(MediaType.application("pdf")));
// Verify default types are NOT present (we replaced the set)
assertFalse(strategy.getSpoolTypes().contains(MediaType.application("x-tika-msoffice")));
}
@Test
public void testLoadByClassNameMyFeatureSettings() throws Exception {
// MyFeatureSettings -> "my-feature-settings" (full name, no suffix stripping)
// JSON has "my-feature-settings"
MyFeatureSettings settings = configLoader.load(MyFeatureSettings.class);
assertNotNull(settings);
assertEquals("test-feature", settings.getFeatureName());
assertEquals(10, settings.getPriority());
}
@Test
public void testLoadWithDefaultValue() throws Exception {
RetryConfig config = configLoader.load("retry-config", RetryConfig.class);
assertNotNull(config);
// Non-existent key with default
RetryConfig defaultConfig = new RetryConfig();
defaultConfig.setTimeout(9999);
RetryConfig result = configLoader.load("non-existent", RetryConfig.class, defaultConfig);
assertEquals(9999, result.getTimeout());
}
@Test
public void testLoadMissingKeyReturnsNull() throws Exception {
RetryConfig config = configLoader.load("non-existent-key", RetryConfig.class);
assertNull(config);
}
@Test
public void testLoadInterfaceAsString() throws Exception {
// JSON: "simple-handler": "org.apache.tika.config.loader.ConfigLoaderTest$SimpleHandlerImpl"
TestHandler handler = configLoader.load("simple-handler", TestHandler.class);
assertNotNull(handler);
assertTrue(handler instanceof SimpleHandlerImpl);
assertEquals("simple", handler.getName());
}
@Test
public void testLoadConcreteClassWithProperties() throws Exception {
// JSON: "configured-handler-impl": { "maxSize": 100000, ... }
// Load directly as concrete class (kebab-case matches class name)
ConfiguredHandlerImpl impl = configLoader.load("configured-handler-impl",
ConfiguredHandlerImpl.class);
assertNotNull(impl);
assertEquals("configured", impl.getName());
assertEquals(100000, impl.getMaxSize());
assertEquals("test-", impl.getPrefix());
}
@Test
public void testLoadInterfaceWithoutClassNameFails() throws Exception {
// Loading an interface with properties (not a class name string) should fail
// because Jackson can't instantiate interfaces directly
Path configPath = Paths.get(
getClass().getResource("/configs/test-interface-no-type.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
loader.load("handler-no-type", TestHandler.class));
assertTrue(ex.getMessage().contains("Failed to deserialize"));
}
@Test
public void testLoadAbstractClassFails() throws Exception {
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
configLoader.load("abstract-handler", AbstractHandler.class));
assertTrue(ex.getMessage().contains("abstract"));
}
@Test
public void testLoadProhibitedKeyParsers() throws Exception {
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
configLoader.load("parsers", Object.class));
assertTrue(ex.getMessage().contains("Cannot load 'parsers'"));
assertTrue(ex.getMessage().contains("TikaLoader"));
}
@Test
public void testLoadProhibitedKeyDetectors() throws Exception {
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
configLoader.load("detectors", Object.class));
assertTrue(ex.getMessage().contains("Cannot load 'detectors'"));
}
@Test
public void testLoadProhibitedKeyMetadataFilters() throws Exception {
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
configLoader.load("metadata-filters", Object.class));
assertTrue(ex.getMessage().contains("Cannot load 'metadata-filters'"));
}
@Test
public void testHasKey() throws Exception {
assertTrue(configLoader.hasKey("retry-config"));
assertTrue(configLoader.hasKey("simple-handler"));
assertFalse(configLoader.hasKey("non-existent"));
}
@Test
public void testLoadInvalidClassName() throws Exception {
Path configPath = Paths.get(
getClass().getResource("/configs/test-invalid-class.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
loader.load("handler", TestHandler.class));
assertTrue(ex.getMessage().contains("Class not found"));
}
@Test
public void testLoadWrongTypeAssignment() throws Exception {
// String class name that doesn't implement the interface
Path configPath = Paths.get(
getClass().getResource("/configs/test-wrong-type.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
loader.load("handler", TestHandler.class));
assertTrue(ex.getMessage().contains("not assignable"));
}
@Test
public void testLoadWithUnexpectedFieldFails() throws Exception {
// Verify that unexpected/unrecognized fields cause an exception
Path configPath = Paths.get(
getClass().getResource("/configs/test-unexpected-field.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
loader.load("retry-config", RetryConfig.class));
// Should contain information about the unrecognized field
assertTrue(ex.getMessage().contains("retry-config") ||
ex.getCause().getMessage().contains("Unrecognized") ||
ex.getCause().getMessage().contains("unexpectedField"),
"Exception should mention the unrecognized field");
}
@Test
public void testKebabCaseConversion() throws Exception {
// Test that kebab-case conversion works correctly
// MyFeatureSettings should look for "my-feature-settings" (full kebab-case, no stripping)
MyFeatureSettings settings = configLoader.load(MyFeatureSettings.class);
assertNotNull(settings);
assertEquals("test-feature", settings.getFeatureName());
}
@Test
public void testLoadByClassWithDefault() throws Exception {
RetryConfig config = configLoader.load(RetryConfig.class);
assertNotNull(config);
// Non-existent class
TestTimeoutConfig defaultTimeout = new TestTimeoutConfig();
defaultTimeout.setMillis(60000);
// Use a class name that won't match
TestTimeoutConfig result = configLoader.load("NonExistentConfig.class",
TestTimeoutConfig.class,
defaultTimeout);
assertEquals(60000, result.getMillis());
}
// ==================== Tests for loadWithDefaults (Partial Config) ====================
@Test
public void testLoadWithDefaultsPartialConfig() throws Exception {
// Load config that merges defaults with partial JSON
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
// Set up defaults
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
// JSON only has: { "enabled": true }
RetryConfig result = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
assertNotNull(result);
assertEquals(30000, result.getTimeout()); // ��� From defaults
assertEquals(2, result.getRetries()); // ��� From defaults
assertTrue(result.isEnabled()); // ��� From JSON (overridden)
}
@Test
public void testLoadWithDefaultsFullOverride() throws Exception {
// Test that JSON can override all defaults
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
// JSON has: { "timeout": 10000, "retries": 5, "enabled": false }
RetryConfig result = loader.loadWithDefaults("retry-config-full",
RetryConfig.class,
defaults);
assertNotNull(result);
assertEquals(10000, result.getTimeout()); // All overridden
assertEquals(5, result.getRetries());
assertFalse(result.isEnabled());
}
@Test
public void testLoadWithDefaultsMissingKey() throws Exception {
// When key doesn't exist, should return original defaults unchanged
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
RetryConfig config = configLoader.loadWithDefaults("non-existent-key",
RetryConfig.class,
defaults);
assertNotNull(config);
assertEquals(30000, config.getTimeout());
assertEquals(2, config.getRetries());
assertFalse(config.isEnabled());
}
@Test
public void testLoadWithDefaultsByClass() throws Exception {
// Test the class-name version
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
// Uses kebab-case: RetryConfig -> "retry-config"
RetryConfig result = loader.loadWithDefaults(RetryConfig.class, defaults);
assertNotNull(result);
assertEquals(30000, result.getTimeout());
assertEquals(2, result.getRetries());
assertTrue(result.isEnabled()); // Overridden from JSON
}
@Test
public void testLoadVsLoadWithDefaults() throws Exception {
// Demonstrate difference between load() and loadWithDefaults()
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
// Using load() - creates new object, loses defaults
RetryConfig config1 = loader.load("retry-config", RetryConfig.class);
assertEquals(0, config1.getTimeout()); // ��� Lost default!
assertEquals(0, config1.getRetries()); // ��� Lost default!
assertTrue(config1.isEnabled()); // ��� From JSON
// Using loadWithDefaults() - merges into defaults
RetryConfig config2 = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
assertEquals(30000, config2.getTimeout()); // ��� Kept default!
assertEquals(2, config2.getRetries()); // ��� Kept default!
assertTrue(config2.isEnabled()); // ��� From JSON
}
// ==================== Immutability Tests ====================
@Test
public void testLoadWithDefaultsDoesNotMutateOriginal() throws Exception {
// Verify that the original defaults object is NOT modified
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
// Load config with partial override (JSON only has "enabled": true)
RetryConfig result = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
// Verify result has merged values
assertEquals(30000, result.getTimeout());
assertEquals(2, result.getRetries());
assertTrue(result.isEnabled()); // Overridden from JSON
// CRITICAL: Verify original defaults object is unchanged
assertEquals(30000, defaults.getTimeout()); // ��� Still original value
assertEquals(2, defaults.getRetries()); // ��� Still original value
assertFalse(defaults.isEnabled()); // ��� Still original value (NOT changed!)
// Verify they are different objects
assertNotEquals(System.identityHashCode(defaults),
System.identityHashCode(result),
"Result should be a different object than defaults");
}
@Test
public void testLoadWithDefaultsReusableDefaults() throws Exception {
// Verify defaults can be safely reused for multiple loads
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
// Load multiple times with same defaults
RetryConfig config1 = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
RetryConfig config2 = loader.loadWithDefaults("retry-config-full",
RetryConfig.class,
defaults);
// Verify results are different
assertTrue(config1.isEnabled()); // From partial config
assertFalse(config2.isEnabled()); // From full config
// Verify defaults still unchanged and can be used again
assertEquals(30000, defaults.getTimeout());
assertEquals(2, defaults.getRetries());
assertFalse(defaults.isEnabled());
// Use defaults one more time
RetryConfig config3 = loader.loadWithDefaults("non-existent",
RetryConfig.class,
defaults);
assertEquals(defaults, config3); // Should return original when key missing
}
@Test
public void testLoadWithDefaultsComplexObjectImmutability() throws Exception {
// Test with nested/complex objects to ensure deep copy works
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TestTimeoutConfig defaults = new TestTimeoutConfig();
defaults.setMillis(60000);
// Note: test-timeout-config in JSON has millis: 30000
TestTimeoutConfig result = loader.loadWithDefaults("test-timeout-config",
TestTimeoutConfig.class,
defaults);
// Result should have JSON value
assertEquals(30000, result.getMillis());
// Original should be unchanged
assertEquals(60000, defaults.getMillis());
}
@Test
public void testLoadWithDefaultsMissingKeyDoesNotClone() throws Exception {
// When key is missing, should return the original object (no unnecessary cloning)
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
defaults.setRetries(2);
defaults.setEnabled(false);
RetryConfig result = configLoader.loadWithDefaults("non-existent-key",
RetryConfig.class,
defaults);
// Should return the exact same object when key is missing
assertEquals(defaults, result);
assertEquals(System.identityHashCode(defaults),
System.identityHashCode(result),
"Should return same object when key missing (no unnecessary clone)");
}
@Test
public void testLoadWithDefaultsThreadSafety() throws Exception {
// Demonstrate that defaults can be safely shared across threads
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
TikaJsonConfig config = TikaJsonConfig.load(configPath);
ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
// Shared defaults object
RetryConfig sharedDefaults = new RetryConfig();
sharedDefaults.setTimeout(30000);
sharedDefaults.setRetries(2);
sharedDefaults.setEnabled(false);
// Simulate concurrent usage (not a real concurrency test, just demonstrates safety)
RetryConfig result1 = loader.loadWithDefaults("retry-config",
RetryConfig.class,
sharedDefaults);
RetryConfig result2 = loader.loadWithDefaults("retry-config-full",
RetryConfig.class,
sharedDefaults);
// Both results should be valid
assertNotNull(result1);
assertNotNull(result2);
// Shared defaults should still be unchanged
assertEquals(30000, sharedDefaults.getTimeout());
assertEquals(2, sharedDefaults.getRetries());
assertFalse(sharedDefaults.isEnabled());
}
}