001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.microservice.test;
018
019import java.lang.reflect.*;
020import java.net.*;
021
022import org.apache.juneau.commons.inject.*;
023import org.apache.juneau.microservice.*;
024import org.apache.juneau.microservice.jetty.*;
025import org.apache.juneau.rest.client.*;
026import org.apache.juneau.bct.junit.*;
027import org.eclipse.jetty.server.*;
028import org.junit.jupiter.api.extension.*;
029
030/**
031 * JUnit 5 extension behind {@link MicroserviceTest @MicroserviceTest}: boots a whole
032 * {@link Microservice} (config + lifecycle + embedded Jetty on an ephemeral port) for the test class, with
033 * {@link TestBean @TestBean} mock-bean injection and convenience parameter resolution.
034 *
035 * <h5 class='topic'>Lifecycle (per class)</h5>
036 *
037 * <p>
038 * The microservice is built + {@link Microservice#start() started} once in {@code beforeAll} and
039 * {@link Microservice#stop() stopped} in {@code afterAll}. A fresh instance is booted per class and stopped
040 * cleanly &mdash; never reused, since {@code Microservice} restart is unsupported (the {@code stopped} flag is
041 * one-way, the bean store is closed, and each {@code start()} adds a JVM shutdown hook). The bound port is read
042 * from the live {@link ServerConnector} (never hard-coded), mirroring the established fixture pattern.
043 *
044 * <h5 class='topic'>Mock-bean injection</h5>
045 *
046 * <p>
047 * {@link TestBean @TestBean} declarations are discovered via
048 * {@link JuneauBeanStoreExtension#discoverOverrides(Object)}. <b>Mode INJECT</b> (the default) installs the
049 * overrides via {@code Microservice.Builder.overridingBeanStore(...)} <i>before</i> boot, so the service reads
050 * them from startup. <b>Mode OVERLAY</b> pushes the overrides onto the booted instance's bean store for the
051 * class duration and pops them in teardown.
052 *
053 * <h5 class='topic'>Parameter resolution</h5>
054 *
055 * <p>
056 * Resolves {@link RestClient} (bound to the booted root URL), {@link Microservice}, {@link WritableBeanStore},
057 * and the bound port ({@code int} / {@code Integer}) on test + lifecycle method parameters.
058 *
059 * @since 10.0.0
060 */
061@SuppressWarnings({
062   "java:S3011", // setAccessible(true) is needed to call the test class's (possibly private) builder method and no-arg constructor.
063   "resource" // The RestClient and Microservice are created in beforeAll(), kept in the per-class State, and closed/stopped in afterAll().
064})
065public class MicroserviceExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver {
066
067   /** Default name of the optional {@code static Microservice.Builder} supplier method on the test class. */
068   public static final String BUILDER_SUPPLIER_METHOD = "microserviceBuilder";
069
070   private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(MicroserviceExtension.class);
071   private static final String KEY_STATE = "state";
072
073   /** Per-class booted state. */
074   private static final class State {
075      Microservice microservice;
076      RestClient client;
077      URI rootUrl;
078      WritableBeanStore beanStore;
079      Snapshot overlaySnapshot;   // non-null when Mode OVERLAY was pushed
080   }
081
082   @Override /* BeforeAllCallback */
083   public void beforeAll(ExtensionContext context) throws Exception {
084      var testClass = context.getRequiredTestClass();
085      var ann = findAnnotation(testClass);
086
087      // Discover @TestBean overrides from the test class (static members participate at class-boot time).
088      var instance = instantiateForDiscovery(testClass);
089      var overrides = JuneauBeanStoreExtension.discoverOverrides(instance);
090
091      var builder = resolveBuilder(testClass, ann);
092
093      // The user's configurations come first (so @Bean Servlet methods are visible), then the ephemeral Jetty
094      // server (port 0), then JettyConfiguration wires the lifecycle. BeanStore returns the first registered
095      // match, so a user-supplied @Bean Server still wins.
096      builder.configurations(ann.configurations());
097      builder.configurations(EphemeralJettyServerConfig.class, JettyConfiguration.class);
098
099      var injecting = ! overrides.isEmpty() && overrides.mode() == Mode.INJECT;
100      if (injecting)
101         builder.overridingBeanStore(overrides.store());
102
103      var state = new State();
104      state.microservice = builder.build();
105      state.microservice.start();
106      state.beanStore = state.microservice.getBeanStore();
107      state.rootUrl = resolveRootUrl(state.microservice);
108      state.client = RestClient.builder().rootUrl(state.rootUrl.toString()).build();
109
110      // Mode OVERLAY: push the overrides onto the booted instance's bean store for the class duration.
111      if (! overrides.isEmpty() && overrides.mode() == Mode.OVERLAY)
112         state.overlaySnapshot = state.beanStore.pushOverlay(overrides.store());
113
114      context.getStore(NAMESPACE).put(KEY_STATE, state);
115   }
116
117   @Override /* AfterAllCallback */
118   public void afterAll(ExtensionContext context) {
119      var state = (State) context.getStore(NAMESPACE).remove(KEY_STATE);
120      if (state == null)
121         return;
122      try {
123         if (state.overlaySnapshot != null)
124            state.beanStore.popOverlay(state.overlaySnapshot);
125      } finally {
126         try {
127            if (state.client != null)
128               state.client.close();
129         } catch (Exception e) {  // HTT: RestClient.close() failure is not reproducible against the in-process JDK transport.
130            // Best-effort close; never mask teardown.
131         } finally {
132            if (state.microservice != null)
133               safeStop(state.microservice);
134         }
135      }
136   }
137
138   //-----------------------------------------------------------------------------------------------------------------
139   // ParameterResolver
140   //-----------------------------------------------------------------------------------------------------------------
141
142   @Override /* ParameterResolver */
143   public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
144      var t = pc.getParameter().getType();
145      return t == RestClient.class || t == Microservice.class || t == WritableBeanStore.class
146         || t == int.class || t == Integer.class;
147   }
148
149   @Override /* ParameterResolver */
150   public Object resolveParameter(ParameterContext pc, ExtensionContext ec) {
151      var state = readState(ec);
152      if (state == null)
153         throw new ParameterResolutionException("@MicroserviceTest has no booted microservice to resolve from.");
154      var t = pc.getParameter().getType();
155      if (t == RestClient.class)
156         return state.client;
157      if (t == Microservice.class)
158         return state.microservice;
159      if (t == WritableBeanStore.class)
160         return state.beanStore;
161      if (t == int.class || t == Integer.class)
162         return state.rootUrl.getPort();
163      throw new ParameterResolutionException("Unsupported @MicroserviceTest parameter type: " + t.getName());
164   }
165
166   //-----------------------------------------------------------------------------------------------------------------
167   // Helpers
168   //-----------------------------------------------------------------------------------------------------------------
169
170   private static MicroserviceTest findAnnotation(Class<?> testClass) {
171      for (var c = testClass; c != null; c = c.getSuperclass()) {
172         var a = c.getAnnotation(MicroserviceTest.class);
173         if (a != null)
174            return a;
175      }
176      throw new ExtensionContextException("@MicroserviceTest annotation not found on " + testClass.getName());
177   }
178
179   private static Microservice.Builder resolveBuilder(Class<?> testClass, MicroserviceTest ann) {
180      var m = findBuilderSupplier(testClass, ann.builderMethod());
181      if (m == null)
182         return Microservice.create();
183      try {
184         m.setAccessible(true);
185         var result = m.invoke(null);
186         if (! (result instanceof Microservice.Builder b))
187            throw new ExtensionContextException(
188               "@MicroserviceTest builder method '" + ann.builderMethod() + "' on " + testClass.getName()
189               + " must return a Microservice.Builder.");
190         return b;
191      } catch (ReflectiveOperationException e) {
192         throw new ExtensionContextException(
193            "Failed to invoke @MicroserviceTest builder method '" + ann.builderMethod() + "' on " + testClass.getName(), e);
194      }
195   }
196
197   private static Method findBuilderSupplier(Class<?> testClass, String name) {
198      for (var c = testClass; c != null; c = c.getSuperclass()) {
199         try {
200            var m = c.getDeclaredMethod(name);
201            if (Modifier.isStatic(m.getModifiers()))
202               return m;
203            throw new ExtensionContextException(
204               "@MicroserviceTest builder method '" + name + "' on " + c.getName() + " must be static.");
205         } catch (NoSuchMethodException e) {
206            // Try the superclass.
207         }
208      }
209      return null;
210   }
211
212   /**
213    * Instantiates the test class via its no-arg constructor purely to drive {@code @TestBean} discovery (which
214    * reads instance + static members). Falls back to a hierarchy-static-only scan if no usable no-arg ctor exists.
215    */
216   private static Object instantiateForDiscovery(Class<?> testClass) {
217      try {
218         var ctor = testClass.getDeclaredConstructor();
219         ctor.setAccessible(true);
220         return ctor.newInstance();
221      } catch (ReflectiveOperationException e) {
222         // No accessible no-arg ctor (e.g. @TestInstance(PER_CLASS) with constructor injection). Static-only
223         // @TestBean discovery still works via a throwaway minimal instance of Object's identity is impossible,
224         // so re-raise with guidance.
225         throw new ExtensionContextException(
226            "@MicroserviceTest requires a no-arg constructor on " + testClass.getName()
227            + " to discover @TestBean overrides before boot.", e);
228      }
229   }
230
231   private static URI resolveRootUrl(Microservice microservice) {
232      var component = microservice.getBeanStore().getBean(JettyServerComponent.class).orElseThrow(
233         () -> new ExtensionContextException("@MicroserviceTest could not find a JettyServerComponent after start()."));
234      var server = component.getServer();
235      var localPort = -1;
236      for (var c : server.getConnectors()) {
237         if (c instanceof ServerConnector sc) {
238            localPort = sc.getLocalPort();
239            break;
240         }
241      }
242      if (localPort <= 0)
243         throw new ExtensionContextException("@MicroserviceTest could not determine the bound ServerConnector port after start().");
244      return URI.create("http://localhost:" + localPort);
245   }
246
247   private static void safeStop(Microservice microservice) {
248      try {
249         microservice.stop();
250      } catch (Exception e) {  // HTT: Microservice.stop() failure mid-teardown is not reproducible in the happy-path test boot.
251         throw new ExtensionContextException("@MicroserviceTest failed to stop the microservice.", e);
252      }
253   }
254
255   private static State readState(ExtensionContext context) {
256      for (var c = context; c != null; c = c.getParent().orElse(null)) {
257         var s = (State) c.getStore(NAMESPACE).get(KEY_STATE);
258         if (s != null)
259            return s;
260      }
261      return null;
262   }
263}