/*
 * 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.struts2.validator;

import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ActionProxy;
import org.apache.struts2.FileManager;
import org.apache.struts2.FileManagerFactory;
import org.apache.struts2.SimpleAction;
import org.apache.struts2.TestBean;
import org.apache.struts2.ValidationOrderAction;
import org.apache.struts2.ValidationAwareSupport;
import org.apache.struts2.XWorkTestCase;
import org.apache.struts2.config.entities.ActionConfig;
import org.apache.struts2.conversion.impl.ConversionData;
import org.apache.struts2.interceptor.ValidationAware;
import org.apache.struts2.test.DataAware2;
import org.apache.struts2.test.SimpleAction3;
import org.apache.struts2.test.User;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.ValueStackFactory;
import org.apache.struts2.validator.validators.DateRangeFieldValidator;
import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
import org.apache.struts2.validator.validators.ExpressionValidator;
import org.apache.struts2.validator.validators.IntRangeFieldValidator;
import org.apache.struts2.validator.validators.LongRangeFieldValidator;
import org.apache.struts2.validator.validators.RequiredFieldValidator;
import org.apache.struts2.validator.validators.RequiredStringValidator;
import org.apache.struts2.validator.validators.ShortRangeFieldValidator;
import org.apache.struts2.validator.validators.VisitorFieldValidator;
import org.apache.struts2.StrutsException;
import org.assertj.core.api.Assertions;
import org.easymock.EasyMock;
import org.xml.sax.SAXParseException;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class DefaultActionValidatorManagerTest extends XWorkTestCase {

    protected final String alias = "validationAlias";

    DefaultActionValidatorManager actionValidatorManager;

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        actionValidatorManager = container.inject(DefaultActionValidatorManager.class);
    }

    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
        actionValidatorManager = null;
    }

    public void testBuildValidatorKey() {
        String validatorKey = actionValidatorManager.buildValidatorKey(SimpleAction.class, alias);
        assertEquals(SimpleAction.class.getName() + "/" + alias, validatorKey);
    }

    public void testBuildsValidatorsForAlias() {
        List<Validator> validators = actionValidatorManager.getValidators(SimpleAction.class, alias);

        Assertions.assertThat(validators).hasSize(11).map(Validator::getClass).containsExactly(
                ExpressionValidator.class,
                RequiredFieldValidator.class,
                IntRangeFieldValidator.class,
                DoubleRangeFieldValidator.class,
                DateRangeFieldValidator.class,
                IntRangeFieldValidator.class,
                IntRangeFieldValidator.class,
                LongRangeFieldValidator.class,
                ShortRangeFieldValidator.class,
                RequiredFieldValidator.class,
                IntRangeFieldValidator.class
        );
        Assertions.assertThat(validators).hasSize(11).map(Validator::getDefaultMessage).containsExactly(
                "Foo must be greater than Bar. Foo = ${foo}, Bar = ${bar}.",
                "You must enter a value for bar.",
                "bar must be between ${min} and ${max}, current value is ${bar}.",
                "percentage must be between ${minExclusive} and ${maxExclusive}, current value is ${percentage}.",
                "The date must be between 12-22-2002 and 12-25-2002.",
                "Could not find foo.range!",
                "Could not find baz.range!",
                "Could not find foo.range!",
                "Could not find foo.range!",
                "You must enter a value for baz.",
                "baz out of range."
        );
    }

    public void testBuildsValidatorsForAliasError() {
        assertThatThrownBy(() -> actionValidatorManager.getValidators(TestBean.class, "badtest"))
                .isInstanceOf(StrutsException.class)
                .hasCause(new SAXParseException("Attribute \"foo\" must be declared for element type \"field-validator\".", null));
    }


    public void testGetValidatorsForInterface() {
        List<Validator> validators = actionValidatorManager.getValidators(DataAware2.class, alias);

        Assertions.assertThat(validators).hasSize(3).map(Validator::getClass).containsExactly(
                RequiredFieldValidator.class,
                RequiredStringValidator.class,
                RequiredStringValidator.class
        );
        Assertions.assertThat(validators).hasSize(3).map(Validator::getValidatorType).containsExactly(
                "required",
                "requiredstring",
                "requiredstring"
        );
        Assertions.assertThat(validators).hasSize(3).map(Validator::getDefaultMessage).containsExactly(
                "You must enter a value for data.",
                "You must enter a value for data.",
                "You must enter a value for data."
        );
    }

    public void testGetValidatorsFromInterface() {
        List<Validator> validators = actionValidatorManager.getValidators(SimpleAction3.class, alias);

        Assertions.assertThat(validators).hasSize(13).map(Validator::getClass).containsExactly(
                ExpressionValidator.class,
                RequiredFieldValidator.class,
                IntRangeFieldValidator.class,
                DoubleRangeFieldValidator.class,
                DateRangeFieldValidator.class,
                IntRangeFieldValidator.class,
                IntRangeFieldValidator.class,
                LongRangeFieldValidator.class,
                ShortRangeFieldValidator.class,
                RequiredFieldValidator.class,
                IntRangeFieldValidator.class,
                RequiredFieldValidator.class,
                RequiredStringValidator.class
        );
        Assertions.assertThat(validators).hasSize(13).map(Validator::getValidatorType).containsExactly(
                "expression",
                "required",
                "int",
                "double",
                "date",
                "int",
                "int",
                "long",
                "short",
                "required",
                "int",
                "required",
                "requiredstring"
        );
        Assertions.assertThat(validators).hasSize(13).map(Validator::getDefaultMessage).containsExactly(
                "Foo must be greater than Bar. Foo = ${foo}, Bar = ${bar}.",
                "You must enter a value for bar.",
                "bar must be between ${min} and ${max}, current value is ${bar}.",
                "percentage must be between ${minExclusive} and ${maxExclusive}, current value is ${percentage}.",
                "The date must be between 12-22-2002 and 12-25-2002.",
                "Could not find foo.range!",
                "Could not find baz.range!",
                "Could not find foo.range!",
                "Could not find foo.range!",
                "You must enter a value for baz.",
                "baz out of range.",
                "You must enter a value for data.",
                "You must enter a value for data."
        );
    }

    /**
     * Test to verify WW-3850.
     */
    public void testBuildsValidatorsForClassError() {
        // for this test we need to have a file manager with reloadingConfigs to true
        container.getInstance(FileManagerFactory.class).getFileManager().setReloadingConfigs(true);
        // no validator found, but no check on file since it is not in cache
        actionValidatorManager.getValidators(List.class, null);
        // this second call will try reload a not existing file
        // and causes a NPE (see WW-3850)
        try {
            actionValidatorManager.getValidators(List.class, null);
        } catch (Exception e) {
            fail("Exception occurred " + e);
        }
    }

    public void testSkipUserMarkerActionLevelShortCircuit() {
        List<Validator> validatorList = actionValidatorManager.getValidators(User.class, null);
        assertEquals(10, validatorList.size());

        try {
            User user = new User();
            user.setName("Mark");
            user.setEmail("bad_email");
            user.setEmail2("bad_email");

            ValidationAware validationAware = new SimpleAction();
            ValidatorContext context = new DelegatingValidatorContext(validationAware, actionValidatorManager.textProviderFactory);
            actionValidatorManager.validate(user, null, context);
            assertTrue(context.hasFieldErrors());

            // check field errors
            List<String> l = context.getFieldErrors().get("email");
            assertNotNull(l);
            assertEquals(1, l.size());
            assertEquals("Not a valid e-mail.", l.get(0));
            l = context.getFieldErrors().get("email2");
            assertNotNull(l);
            assertEquals(2, l.size());
            assertEquals("Not a valid e-mail2.", l.get(0));
            assertEquals("Email2 not from the right company.", l.get(1));

            // check action errors
            assertTrue(context.hasActionErrors());
            l = new ArrayList<>(context.getActionErrors());
            assertNotNull(l);
            assertEquals(2, l.size()); // both expression test failed see User-validation.xml
            assertEquals("Email does not start with mark", l.get(0));
        } catch (ValidationException ex) {
            fail("Validation error: " + ex.getMessage());
        }
    }

    public void testSkipAllActionLevelShortCircuit2() {
        List<Validator> validatorList = actionValidatorManager.getValidators(User.class, null);
        assertEquals(10, validatorList.size());

        try {
            User user = new User();
            user.setName("Mark");
            // * mark both email to starts with mark to get pass the action-level validator,
            // so we could concentrate on testing the field-level validators (User-validation.xml)
            // * make both email the same to pass the action-level validator at
            // UserMarker-validation.xml
            user.setEmail("mark_bad_email_for_field_val@foo.com");
            user.setEmail2("mark_bad_email_for_field_val@foo.com");

            ValidationAware validationAware = new SimpleAction();
            ValidatorContext context = new DelegatingValidatorContext(validationAware, actionValidatorManager.textProviderFactory);
            actionValidatorManager.validate(user, null, context);
            assertTrue(context.hasFieldErrors());

            // check field errors
            // we have an error in this field level, email does not ends with mycompany.com
            List<String> l = context.getFieldErrors().get("email");
            assertNotNull(l);
            assertEquals(1, l.size()); // because email-field-val is short-circuit
            assertEquals("Email not from the right company.", l.get(0));

            // check action errors
            l = new ArrayList<>(context.getActionErrors());
            assertFalse(context.hasActionErrors());
            assertEquals(0, l.size());
        } catch (ValidationException ex) {
            fail("Validation error: " + ex.getMessage());
        }
    }


    public void testActionLevelShortCircuit() throws Exception {
        List<Validator> validatorList = actionValidatorManager.getValidators(User.class, null);
        assertEquals(10, validatorList.size());

        User user = new User();
        // all fields will trigger error, but sc of action-level, cause it to not appear
        user.setName(null);
        user.setEmail("tmjee(at)yahoo.co.uk");
        user.setEmail("tm_jee(at)yahoo.co.uk");

        ValidationAware validationAware = new SimpleAction();
        ValidatorContext context = new DelegatingValidatorContext(validationAware, actionValidatorManager.textProviderFactory);
        actionValidatorManager.validate(user, null, context);

        // check field level errors
        // shouldn't have any because action error prevents validation of anything else
        List<String> l = context.getFieldErrors().get("email2");
        assertNull(l);

        // check action errors
        assertTrue(context.hasActionErrors());
        l = new ArrayList<>(context.getActionErrors());
        assertNotNull(l);
        // we only get one, because UserMarker-validation.xml action-level validator
        // already sc it   :-)
        assertEquals(1, l.size());
        assertEquals("Email not the same as email2", l.get(0));
    }

    public void testShortCircuitNoErrors() {
        List<Validator> validatorList = actionValidatorManager.getValidators(User.class, null);
        assertEquals(10, validatorList.size());

        try {
            User user = new User();
            user.setName("Mark");
            user.setEmail("mark@mycompany.com");
            user.setEmail2("mark@mycompany.com");

            ValidationAware validationAware = new SimpleAction();
            ValidatorContext context = new DelegatingValidatorContext(validationAware, actionValidatorManager.textProviderFactory);
            actionValidatorManager.validate(user, null, context);
            assertFalse(context.hasErrors());
        } catch (ValidationException ex) {
            fail("Validation error: " + ex.getMessage());
        }
    }

    public void testFieldErrorsOrder() throws Exception {
        ValidationOrderAction action = new ValidationOrderAction();
        actionValidatorManager.validate(action, "actionContext");
        Map<String, List<String>> fieldErrors = action.getFieldErrors();
        Iterator<Map.Entry<String, List<String>>> i = fieldErrors.entrySet().iterator();

        assertNotNull(fieldErrors);
        assertEquals(fieldErrors.size(), 12);


        Map.Entry<String, List<String>> e = i.next();
        assertEquals(e.getKey(), "username");
        assertEquals(e.getValue().get(0), "username required");

        e = i.next();
        assertEquals(e.getKey(), "password");
        assertEquals((e.getValue()).get(0), "password required");

        e = i.next();
        assertEquals(e.getKey(), "confirmPassword");
        assertEquals((e.getValue()).get(0), "confirm password required");

        e = i.next();
        assertEquals(e.getKey(), "firstName");
        assertEquals((e.getValue()).get(0), "first name required");

        e = i.next();
        assertEquals(e.getKey(), "lastName");
        assertEquals((e.getValue()).get(0), "last name required");

        e = i.next();
        assertEquals(e.getKey(), "city");
        assertEquals((e.getValue()).get(0), "city is required");

        e = i.next();
        assertEquals(e.getKey(), "province");
        assertEquals((e.getValue()).get(0), "province is required");

        e = i.next();
        assertEquals(e.getKey(), "country");
        assertEquals((e.getValue()).get(0), "country is required");

        e = i.next();
        assertEquals(e.getKey(), "postalCode");
        assertEquals((e.getValue()).get(0), "postal code is required");

        e = i.next();
        assertEquals(e.getKey(), "email");
        assertEquals((e.getValue()).get(0), "email is required");

        e = i.next();
        assertEquals(e.getKey(), "website");
        assertEquals((e.getValue()).get(0), "website is required");

        e = i.next();
        assertEquals(e.getKey(), "passwordHint");
        assertEquals((e.getValue()).get(0), "password hint is required");
    }

    public void testConversionError_bothErrorsWhenFlagDisabledByDefault() throws Exception {
        ConversionErrorSkipAction action = new ConversionErrorSkipAction();
        ActionContext.getContext().getConversionErrors()
                .put("age", new ConversionData(new String[]{"one"}, Integer.class));

        actionValidatorManager.validate(action, null);

        List<String> ageErrors = action.getFieldErrors().get("age");
        assertNotNull(ageErrors);
        assertEquals(2, ageErrors.size()); // conversion + required, current behavior
        assertTrue(ageErrors.contains("Age must be a valid number"));
        assertTrue(ageErrors.contains("Age is required"));
    }

    public void testConversionError_fieldValidatorsSkippedWhenEnabled() throws Exception {
        ConversionErrorSkipAction action = new ConversionErrorSkipAction();
        ActionContext.getContext().getConversionErrors()
                .put("age", new ConversionData(new String[]{"one"}, Integer.class));
        actionValidatorManager.setSkipValidatorsOnConversionError("true");

        actionValidatorManager.validate(action, null);

        List<String> ageErrors = action.getFieldErrors().get("age");
        assertNotNull(ageErrors);
        // the conversion validator itself still runs and its custom message survives...
        assertEquals(1, ageErrors.size());
        assertEquals("Age must be a valid number", ageErrors.get(0));
        // ...while the required validator on the same field is skipped, not merely absent
        assertFalse(ageErrors.contains("Age is required"));
    }

    public void testConversionError_nestedFieldValidatorsSkippedWhenEnabled() throws Exception {
        // A nested/visitor-validated field is keyed by its full (prefixed) name, e.g. "bean.zip".
        // The skip guard must match on that full field name, exempting only the conversion validator.
        actionValidatorManager.setSkipValidatorsOnConversionError("true");

        // The bean supplies the field values; a separate ValidationAware collects the errors,
        // exactly as the visitor validator wires things up when it recurses into a nested bean.
        ConversionErrorSkipBean bean = new ConversionErrorSkipBean();
        ValidationAware sink = new ValidationAwareSupport();
        ValidatorContext parent = new DelegatingValidatorContext(sink, actionValidatorManager.textProviderFactory);
        VisitorFieldValidator.AppendingValidatorContext nested =
                new VisitorFieldValidator.AppendingValidatorContext(parent, parent, "bean", "");

        ActionContext.getContext().getConversionErrors()
                .put("bean.zip", new ConversionData(new String[]{"one"}, Integer.class));

        actionValidatorManager.validate(bean, null, nested);

        List<String> zipErrors = sink.getFieldErrors().get("bean.zip");
        assertNotNull(zipErrors);
        // required is skipped on the prefixed field; the conversion validator still runs
        assertEquals(1, zipErrors.size());
        assertEquals("Zip must be a valid number", zipErrors.get(0));
        assertFalse(zipErrors.contains("Zip is required"));
    }

    public void testConversionError_unrelatedFieldStillValidatedWhenEnabled() throws Exception {
        ConversionErrorSkipAction action = new ConversionErrorSkipAction();
        ActionContext.getContext().getConversionErrors()
                .put("age", new ConversionData(new String[]{"one"}, Integer.class));
        actionValidatorManager.setSkipValidatorsOnConversionError("true");

        actionValidatorManager.validate(action, null);

        List<String> nameErrors = action.getFieldErrors().get("name");
        assertNotNull(nameErrors); // "name" has no conversion error, still validated
        assertEquals(1, nameErrors.size());
        assertEquals("Name is required", nameErrors.get(0));
    }

    public void testConversionError_actionLevelValidatorUnaffectedWhenEnabled() throws Exception {
        ConversionErrorSkipAction action = new ConversionErrorSkipAction();
        ActionContext.getContext().getConversionErrors()
                .put("age", new ConversionData(new String[]{"one"}, Integer.class));
        actionValidatorManager.setSkipValidatorsOnConversionError("true");

        actionValidatorManager.validate(action, null);

        assertTrue(action.hasActionErrors());
        assertTrue(action.getActionErrors().contains("Action level always fails"));
    }

    public void testConversionError_skipFiresForAnnotationManager() throws Exception {
        // AnnotationActionValidatorManager.buildValidatorKey() needs an ActionInvocation/ActionProxy
        // on the ActionContext to resolve the package name/config for the validator cache key.
        ActionConfig config = new ActionConfig.Builder("packageName", "name", "").build();
        ActionInvocation invocation = EasyMock.createNiceMock(ActionInvocation.class);
        ActionProxy proxy = EasyMock.createNiceMock(ActionProxy.class);
        EasyMock.expect(invocation.getProxy()).andReturn(proxy).anyTimes();
        EasyMock.expect(proxy.getMethod()).andReturn("execute").anyTimes();
        EasyMock.expect(proxy.getConfig()).andReturn(config).anyTimes();
        EasyMock.replay(invocation);
        EasyMock.replay(proxy);
        ActionContext.getContext().withActionInvocation(invocation);

        AnnotationActionValidatorManager annMgr = container.inject(AnnotationActionValidatorManager.class);
        annMgr.setSkipValidatorsOnConversionError("true");

        ConversionErrorSkipAction action = new ConversionErrorSkipAction();
        ActionContext.getContext().getConversionErrors()
                .put("age", new ConversionData(new String[]{"one"}, Integer.class));

        annMgr.validate(action, null);

        List<String> ageErrors = action.getFieldErrors().get("age");
        assertNotNull(ageErrors);
        // required is skipped; the conversion validator itself still runs
        assertEquals(1, ageErrors.size());
        assertEquals("Age must be a valid number", ageErrors.get(0));
    }

    public void testConcurrentGetValidatorsReturnsConsistentResults() throws Exception {
        final int threads = 16;
        ExecutorService pool = Executors.newFixedThreadPool(threads);
        CountDownLatch start = new CountDownLatch(1);
        List<Future<Integer>> futures = new ArrayList<>();

        // actionValidatorManager is freshly injected in setUp(), so validatorCache is cold here -
        // all 16 threads below race the first build for this key, which is the interesting
        // contention this test exists to cover.
        // ActionContext is a plain (non-inheritable) ThreadLocal, so each pool thread needs its
        // own context bound - with its own ValueStack - before it can call getValidators(),
        // mirroring what XWorkTestCaseHelper does for the main test thread.
        ValueStackFactory valueStackFactory = container.getInstance(ValueStackFactory.class);

        for (int t = 0; t < threads; t++) {
            futures.add(pool.submit(() -> {
                ValueStack stack = valueStackFactory.createValueStack();
                stack.getActionContext().withContainer(container).withValueStack(stack).bind();
                start.await();
                return actionValidatorManager.getValidators(SimpleAction.class, alias).size();
            }));
        }

        start.countDown();
        int firstSize = futures.get(0).get(60, TimeUnit.SECONDS);
        assertThat(firstSize).isGreaterThan(0);
        for (Future<Integer> future : futures) {
            assertThat(future.get(60, TimeUnit.SECONDS)).isEqualTo(firstSize);
        }
        pool.shutdown();
        assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue();
    }

    public void testCachedValidatorConfigsAreUnmodifiable() {
        actionValidatorManager.getValidators(SimpleAction.class, alias);

        List<ValidatorConfig> cached = actionValidatorManager.validatorCache.values().iterator().next();

        assertThatThrownBy(() -> cached.add(null))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    /**
     * Covers {@code getValidators}' {@code else if (reloadingConfigs)} branch: reached when the
     * validator-config key is already cached <em>and</em> reload mode is on, in which case the
     * configs are rebuilt and the cache entry is replaced rather than reused. Rebuilding produces a
     * fresh {@link List} instance even though the content is unchanged, so object identity is the
     * real, observable signal that this branch - rather than the {@code computeIfAbsent} branch -
     * ran.
     */
    public void testGetValidatorsRebuildsCacheWhenReloadingConfigsEnabled() {
        actionValidatorManager.getValidators(SimpleAction.class, alias);
        String key = actionValidatorManager.buildValidatorKey(SimpleAction.class, alias);
        List<ValidatorConfig> cachedBeforeReload = actionValidatorManager.validatorCache.get(key);
        assertThat(cachedBeforeReload).isNotNull().isNotEmpty();

        actionValidatorManager.reloadingConfigs = true;
        actionValidatorManager.getValidators(SimpleAction.class, alias);

        List<ValidatorConfig> cachedAfterReload = actionValidatorManager.validatorCache.get(key);
        assertThat(cachedAfterReload)
                .as("the else-if(reloadingConfigs) branch must rebuild and replace the cache entry, "
                        + "not reuse the previously cached list")
                .isNotSameAs(cachedBeforeReload);
        assertThat(cachedAfterReload).hasSameSizeAs(cachedBeforeReload);
    }

    /**
     * Minimal {@link FileManager} wrapping a real one, whose {@code fileNeedsReloading} answer is
     * fixed at construction. {@code loadFile} and everything else delegate to the real instance so
     * that the actual validation XML on the test classpath is genuinely parsed - only the reload
     * check itself is made deterministic.
     */
    private static class ReloadFlagFileManager implements FileManager {
        private final FileManager delegate;
        private final boolean needsReloading;

        ReloadFlagFileManager(FileManager delegate, boolean needsReloading) {
            this.delegate = delegate;
            this.needsReloading = needsReloading;
        }

        @Override
        public void setReloadingConfigs(boolean reloadingConfigs) {
            delegate.setReloadingConfigs(reloadingConfigs);
        }

        @Override
        public boolean fileNeedsReloading(String fileName) {
            return needsReloading;
        }

        @Override
        public boolean fileNeedsReloading(URL fileUrl) {
            return needsReloading;
        }

        @Override
        public InputStream loadFile(URL fileUrl) {
            return delegate.loadFile(fileUrl);
        }

        @Override
        public void monitorFile(URL fileUrl) {
            delegate.monitorFile(fileUrl);
        }

        @Override
        public URL normalizeToFileProtocol(URL url) {
            return delegate.normalizeToFileProtocol(url);
        }

        @Override
        public boolean support() {
            return delegate.support();
        }

        @Override
        public boolean internal() {
            return delegate.internal();
        }

        @Override
        public Collection<? extends URL> getAllPhysicalUrls(URL url) throws IOException {
            return delegate.getAllPhysicalUrls(url);
        }
    }

    /**
     * Covers {@code loadFile}'s {@code checkFile && fileManager.fileNeedsReloading(...)} branch,
     * which re-parses and {@code put}s directly rather than using {@code computeIfAbsent}. Forcing
     * a real re-parse produces a new {@link List} instance even though {@code SimpleAction-
     * validationAlias-validation.xml} hasn't changed, so object identity is the observable proof
     * that this branch - rather than the {@code computeIfAbsent} fallback - ran.
     */
    public void testLoadFileReparsesWhenCheckFileAndFileNeedsReloading() {
        String fileName = SimpleAction.class.getName().replace('.', '/') + "-" + alias + "-validation.xml";
        FileManager realFileManager = actionValidatorManager.fileManager;

        actionValidatorManager.fileManager = new ReloadFlagFileManager(realFileManager, false);
        List<ValidatorConfig> warm = actionValidatorManager.loadFile(fileName, SimpleAction.class, false);
        assertThat(warm).isNotEmpty();
        assertThat(actionValidatorManager.validatorFileCache.get(fileName)).isSameAs(warm);

        actionValidatorManager.fileManager = new ReloadFlagFileManager(realFileManager, true);
        List<ValidatorConfig> reloaded = actionValidatorManager.loadFile(fileName, SimpleAction.class, true);

        assertThat(reloaded)
                .as("checkFile && fileNeedsReloading must re-parse rather than reuse the cached list")
                .isNotSameAs(warm);
        assertThat(reloaded).hasSameSizeAs(warm);
        assertThat(actionValidatorManager.validatorFileCache.get(fileName)).isSameAs(reloaded);
    }

    /**
     * Covers {@code buildValidatorConfigs} returning {@code Collections.emptyList()} when the class
     * was already recorded in the {@code checked} set - the short-circuit that stops the hierarchy
     * walk from revisiting a class (e.g. an interface reachable through more than one path).
     */
    public void testBuildValidatorConfigsShortCircuitsWhenClassAlreadyChecked() {
        Set<String> checked = new TreeSet<>();
        checked.add(SimpleAction.class.getName());

        List<ValidatorConfig> result = actionValidatorManager.buildValidatorConfigs(SimpleAction.class, alias, false, checked);

        assertThat(result).isNotNull().isEmpty();
    }

}
