cache = new ConcurrentHashMap<>(1);
+
+ public double getSellRate(String currency) {
+ return getCurrency(currency).sell();
+ }
+
+ public double getBuyRate(String currency) {
+ return getCurrency(currency).buy();
+ }
+
+ private synchronized RatesCache getCurrency(String currency) {
+ var today = getFetchDate();
+ var cacheObject = cache.get(currency);
+ if (cacheObject == null || cacheObject.date().isBefore(today)) {
+ var fresh = fetchData(currency, dtf.format(today));
+ var rate = fresh.getRates().get(0);
+ cacheObject = new RatesCache(
+ LocalDate.parse(rate.getEffectiveDate(), dtf),
+ rate.getBid(),
+ rate.getAsk()
+ );
+ cache.put(fresh.getCode(), cacheObject);
+ }
+ return cacheObject;
+ }
+
+ /**
+ * Calculates date for data fetch.
+ *
+ * Usually this would be today, but as rates are set only Mon-Fri, during weekends it is needed to fetch last friday rates.
+ *
+ * @return date for data fetch
+ */
+ @VisibleForTesting
+ LocalDate getFetchDate() {
+ var today = LocalDate.now(clock);
+ return isWeekend(today) ? today.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY)) : today;
+ }
+
+ @VisibleForTesting
+ Rates fetchData(String code, String date) {
+ return restClient.get().uri(URI_PATTERN, code.toLowerCase(), date)
+ .retrieve()
+ .onStatus(HttpStatusCode::is4xxClientError, ((request, response) -> {
+ throw new NoDataException(code, date);
+ }))
+ .body(Rates.class);
+ }
+
+ private static boolean isWeekend(LocalDate today) {
+ return today.getDayOfWeek() == DayOfWeek.SATURDAY
+ || today.getDayOfWeek() == DayOfWeek.SUNDAY;
+ }
+
+ private record RatesCache(LocalDate date, double buy, double sell) {
+
+ }
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequest.java b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequest.java
new file mode 100644
index 0000000..608bc3c
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequest.java
@@ -0,0 +1,24 @@
+package eu.ztsh.wymiana.validation;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+
+@Retention(RUNTIME)
+@Target({ TYPE_USE })
+@Documented
+@Constraint(validatedBy = {ValidExchangeRequestValidator.class })
+public @interface ValidExchangeRequest {
+
+ String message() default "Exchange request is not valid";
+ Class>[] groups() default { };
+ Class extends Payload>[] payload() default { };
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidator.java b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidator.java
new file mode 100644
index 0000000..0895d92
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidator.java
@@ -0,0 +1,24 @@
+package eu.ztsh.wymiana.validation;
+
+import eu.ztsh.wymiana.web.model.CurrencyExchangeRequest;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class ValidExchangeRequestValidator implements
+ ConstraintValidator {
+
+ @Override
+ public boolean isValid(CurrencyExchangeRequest request,
+ ConstraintValidatorContext constraintValidatorContext) {
+ if (request == null) {
+ return false;
+ }
+
+ return !request.from().equals(request.to())
+ && !((request.toBuy() == null && request.toSell() == null)
+ || (request.toBuy() != null && request.toSell() != null))
+ && ((request.toBuy() != null && request.toBuy() >= 0)
+ || (request.toSell() != null && request.toSell() >= 0));
+ }
+
+}
diff --git a/src/main/java/eu/ztsh/wymiana/web/model/CurrencyExchangeRequest.java b/src/main/java/eu/ztsh/wymiana/web/model/CurrencyExchangeRequest.java
new file mode 100644
index 0000000..54f192e
--- /dev/null
+++ b/src/main/java/eu/ztsh/wymiana/web/model/CurrencyExchangeRequest.java
@@ -0,0 +1,18 @@
+package eu.ztsh.wymiana.web.model;
+
+import eu.ztsh.wymiana.validation.ValidExchangeRequest;
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+import org.hibernate.validator.constraints.pl.PESEL;
+
+@Builder
+@ValidExchangeRequest
+public record CurrencyExchangeRequest(
+ @PESEL String pesel,
+ @NotNull String from,
+ @NotNull String to,
+ Double toBuy,
+ Double toSell
+) {
+
+}
diff --git a/src/main/resources/schema/rates.json b/src/main/resources/schema/rates.json
new file mode 100644
index 0000000..2ed42d0
--- /dev/null
+++ b/src/main/resources/schema/rates.json
@@ -0,0 +1,53 @@
+{
+ "$id": "https://api.nbp.pl/c/rates.json",
+ "$schema": "http://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "def": {
+ "rate": {
+ "type": "object",
+ "properties": {
+ "no": {
+ "type": "string"
+ },
+ "effectiveDate": {
+ "type": "string"
+ },
+ "bid": {
+ "type": "number"
+ },
+ "ask": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "no",
+ "effectiveDate",
+ "bid",
+ "ask"
+ ]
+ }
+ },
+ "properties": {
+ "table": {
+ "type": "string"
+ },
+ "currency": {
+ "type": "string"
+ },
+ "code": {
+ "type": "string"
+ },
+ "rates": {
+ "type": "array",
+ "items": {
+ "$ref": "#/def/rate"
+ }
+ }
+ },
+ "required": [
+ "table",
+ "currency",
+ "code",
+ "rates"
+ ]
+}
diff --git a/src/test/java/eu/ztsh/wymiana/EntityCreator.java b/src/test/java/eu/ztsh/wymiana/EntityCreator.java
index 4f7fcd7..df78b20 100644
--- a/src/test/java/eu/ztsh/wymiana/EntityCreator.java
+++ b/src/test/java/eu/ztsh/wymiana/EntityCreator.java
@@ -2,6 +2,9 @@ package eu.ztsh.wymiana;
import eu.ztsh.wymiana.data.entity.CurrencyEntity;
import eu.ztsh.wymiana.data.entity.UserEntity;
+import eu.ztsh.wymiana.model.Rate;
+import eu.ztsh.wymiana.model.Rates;
+import eu.ztsh.wymiana.web.model.CurrencyExchangeRequest;
import eu.ztsh.wymiana.web.model.UserCreateRequest;
import java.util.ArrayList;
@@ -16,6 +19,12 @@ public class EntityCreator {
public static String NAME = "Janina";
public static String SURNAME = "Kowalska";
public static double PLN = 20.10;
+ public static double USD_SELL = 5.18;
+ public static double USD_BUY = 5.08;
+ public static String PLN_SYMBOL = "PLN";
+ public static String USD_SYMBOL = "USD";
+ public static double BUY_RATE = 3.8804;
+ public static double SELL_RATE = 3.9572;
}
@@ -30,6 +39,24 @@ public class EntityCreator {
.pln(Constants.PLN);
}
+ public static CurrencyExchangeRequest.CurrencyExchangeRequestBuilder exchangeRequest() {
+ return CurrencyExchangeRequest.builder().pesel(Constants.PESEL);
+ }
+
+ public static Rates rates(String date) {
+ var rates = new Rates();
+ rates.setTable("C");
+ rates.setCurrency("dolar amerykaĆski");
+ rates.setCode("USD");
+ var rate = new Rate();
+ rate.setNo("096/C/NBP/2024");
+ rate.setEffectiveDate(date);
+ rate.setBid(Constants.BUY_RATE);
+ rate.setAsk(Constants.SELL_RATE);
+ rates.setRates(List.of(rate));
+ return rates;
+ }
+
public static class UserEntityBuilder {
String name;
diff --git a/src/test/java/eu/ztsh/wymiana/WireMockExtension.java b/src/test/java/eu/ztsh/wymiana/WireMockExtension.java
new file mode 100644
index 0000000..99ebc73
--- /dev/null
+++ b/src/test/java/eu/ztsh/wymiana/WireMockExtension.java
@@ -0,0 +1,50 @@
+package eu.ztsh.wymiana;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+
+public class WireMockExtension implements BeforeAllCallback, AfterEachCallback, ExtensionContext.Store.CloseableResource {
+
+ public static final String baseUrl = "http://localhost:38080";
+
+ public static void response(String endpoint, int status, String body) {
+ configureFor(38080);
+ stubFor(get(urlEqualTo(endpoint))
+ .willReturn(WireMock.status(status)
+ .withHeader("Content-Type", "application/json")
+ .withBody(body)));
+ }
+
+ public static void verifyGet(int count, String url) {
+ verify(exactly(count), getRequestedFor(urlEqualTo(url)));
+ }
+
+ private static final WireMockServer wireMockServer = new WireMockServer(38080);
+ private boolean started;
+
+ @Override
+ public void beforeAll(ExtensionContext extensionContext) throws Exception {
+ if (!started) {
+ wireMockServer.start();
+ started = true;
+ }
+ }
+ @Override
+ public void afterEach(ExtensionContext extensionContext) throws Exception {
+ wireMockServer.listAllStubMappings().getMappings().forEach(wireMockServer::removeStub);
+ wireMockServer.findAllUnmatchedRequests().forEach(System.out::println);
+ wireMockServer.resetRequests();
+ }
+
+ @Override
+ public void close() throws Throwable {
+ wireMockServer.stop();
+ }
+
+
+}
diff --git a/src/test/java/eu/ztsh/wymiana/service/NbpServiceTest.java b/src/test/java/eu/ztsh/wymiana/service/NbpServiceTest.java
new file mode 100644
index 0000000..7424fbe
--- /dev/null
+++ b/src/test/java/eu/ztsh/wymiana/service/NbpServiceTest.java
@@ -0,0 +1,117 @@
+package eu.ztsh.wymiana.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import eu.ztsh.wymiana.EntityCreator;
+import eu.ztsh.wymiana.WireMockExtension;
+import eu.ztsh.wymiana.exception.NoDataException;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.mockito.Mockito;
+import org.springframework.web.client.RestClient;
+
+import java.time.Clock;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.Month;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAdjusters;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@ExtendWith(WireMockExtension.class)
+class NbpServiceTest {
+
+ private static final ZoneId zone = ZoneId.of("Europe/Warsaw");
+ private static final LocalDate today = LocalDate.of(2024, Month.MAY, 12); // Sunday
+ private static Clock clock;
+ private final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ private final RestClient restClient = RestClient.builder().baseUrl(WireMockExtension.baseUrl).build();
+ private NbpService nbpService;
+
+ @BeforeAll
+ static void prepare() {
+ clock = Mockito.mock(Clock.class);
+ Mockito.when(clock.getZone()).thenReturn(zone);
+ }
+
+ @BeforeEach
+ void prepareTest() {
+ nbpService = new NbpService(clock, restClient);
+ }
+
+ @DisplayName("Check if fetch date is calculated properly: weekdays")
+ @ParameterizedTest
+ @EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"}, mode = EnumSource.Mode.EXCLUDE)
+ void getFetchDateOnWorkingDayTest(DayOfWeek dayOfWeek) {
+ updateClock(dayOfWeek);
+ assertThat(nbpService.getFetchDate()).isEqualTo(
+ switch (dayOfWeek) {
+ case MONDAY -> LocalDate.of(2024, Month.MAY, 6);
+ case TUESDAY -> LocalDate.of(2024, Month.MAY, 7);
+ case WEDNESDAY -> LocalDate.of(2024, Month.MAY, 8);
+ case THURSDAY -> LocalDate.of(2024, Month.MAY, 9);
+ case FRIDAY -> LocalDate.of(2024, Month.MAY, 10);
+ default -> null;
+ }
+ );
+ }
+
+ @DisplayName("Check if fetch date is calculated properly: weekends")
+ @ParameterizedTest
+ @EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"})
+ void getFetchDateOnWeekendTest(DayOfWeek dayOfWeek) {
+ updateClock(dayOfWeek);
+ assertThat(nbpService.getFetchDate()).isEqualTo(LocalDate.of(2024, Month.MAY, 10));
+ }
+
+ @DisplayName("Fetch rates straight from server")
+ @Test
+ void getWithoutCacheTest() throws JsonProcessingException {
+ var date = dtf.format(updateClock(DayOfWeek.FRIDAY));
+ var url = "/api/exchangerates/rates/c/usd/%s/".formatted(date);
+ WireMockExtension.response(url, 200, new ObjectMapper().writeValueAsString(EntityCreator.rates(date)));
+ try {
+ assertThat(nbpService.getSellRate(EntityCreator.Constants.USD_SYMBOL)).isEqualTo(EntityCreator.Constants.SELL_RATE);
+ } finally {
+ WireMockExtension.verifyGet(1, url);
+ }
+ }
+
+ @DisplayName("Fetch rates from cache")
+ @Test
+ void getWithCacheTest() throws JsonProcessingException {
+ var date = dtf.format(updateClock(DayOfWeek.FRIDAY));
+ var url = "/api/exchangerates/rates/c/usd/%s/".formatted(date);
+ WireMockExtension.response(url, 200, new ObjectMapper().writeValueAsString(EntityCreator.rates(date)));
+ // save to cache
+ assertThat(nbpService.getSellRate(EntityCreator.Constants.USD_SYMBOL)).isEqualTo(EntityCreator.Constants.SELL_RATE);
+ // get from cache
+ assertThat(nbpService.getBuyRate(EntityCreator.Constants.USD_SYMBOL)).isEqualTo(EntityCreator.Constants.BUY_RATE);
+ WireMockExtension.verifyGet(1, url);
+ }
+
+ @DisplayName("Support 404: invalid currency or no data")
+ @Test
+ void getInvalidCurrencyTest() {
+ var date = dtf.format(updateClock(DayOfWeek.FRIDAY));
+ var url = "/api/exchangerates/rates/c/usb/%s/".formatted(date);
+ WireMockExtension.response(url, 404, "404 NotFound - Not Found - Brak danych");
+ assertThatThrownBy(() -> nbpService.getSellRate("usb")).isInstanceOf(NoDataException.class);
+ WireMockExtension.verifyGet(1, url);
+ }
+
+ private LocalDate updateClock(DayOfWeek dayOfWeek) {
+ var date = today.with(TemporalAdjusters.previousOrSame(dayOfWeek));
+ Mockito.when(clock.instant()).thenReturn(date.atStartOfDay(zone).toInstant());
+ return LocalDate.from(date);
+ }
+
+}
diff --git a/src/test/java/eu/ztsh/wymiana/validation/AdultValidatorTest.java b/src/test/java/eu/ztsh/wymiana/validation/AdultValidatorTest.java
index ca24718..d4a2ed1 100644
--- a/src/test/java/eu/ztsh/wymiana/validation/AdultValidatorTest.java
+++ b/src/test/java/eu/ztsh/wymiana/validation/AdultValidatorTest.java
@@ -1,58 +1,42 @@
package eu.ztsh.wymiana.validation;
-import jakarta.validation.ConstraintValidatorContext;
-import org.hibernate.validator.internal.engine.DefaultClockProvider;
-import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
-import static org.assertj.core.api.Assertions.assertThat;
+class AdultValidatorTest extends ValidatorTest {
-class AdultValidatorTest {
-
- private static AdultValidator validator;
- private static ConstraintValidatorContext validatorContext;
-
- @BeforeAll
- static void prepare() {
- validator = new AdultValidator();
- validatorContext = Mockito.mock(ConstraintValidatorContext.class);
- Mockito.when(validatorContext.getClockProvider()).thenReturn(DefaultClockProvider.INSTANCE);
+ protected AdultValidatorTest() {
+ super(new AdultValidator());
}
@Test
@DisplayName("No digits in PESEL")
void invalidPatternTest() {
- assertThat(call("notAPesel")).isFalse();
+ assertThatValidation("notAPesel").isFalse();
}
@Test
@DisplayName("Not an adult")
void notAnAdultTest() {
- assertThat(call("24242400000")).isFalse();
+ assertThatValidation("24242400000").isFalse();
}
@Test
@DisplayName("Adult")
void adultTest() {
- assertThat(call("88010100000")).isTrue();
+ assertThatValidation("88010100000").isTrue();
}
@Test
@DisplayName("Elderly person")
void seniorTest() {
- assertThat(call("00010100000")).isTrue();
+ assertThatValidation("00010100000").isTrue();
}
@Test
@DisplayName("Invalid date")
void notAValidDateTest() {
- assertThat(call("00919100000")).isFalse();
- }
-
- private boolean call(String value) {
- return validator.isValid(value, validatorContext);
+ assertThatValidation("00919100000").isFalse();
}
}
diff --git a/src/test/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidatorTest.java b/src/test/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidatorTest.java
new file mode 100644
index 0000000..85161e6
--- /dev/null
+++ b/src/test/java/eu/ztsh/wymiana/validation/ValidExchangeRequestValidatorTest.java
@@ -0,0 +1,128 @@
+package eu.ztsh.wymiana.validation;
+
+import eu.ztsh.wymiana.EntityCreator;
+import eu.ztsh.wymiana.web.model.CurrencyExchangeRequest;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+import static eu.ztsh.wymiana.EntityCreator.Constants.*;
+
+class ValidExchangeRequestValidatorTest extends ValidatorTest {
+
+ protected ValidExchangeRequestValidatorTest() {
+ super(new ValidExchangeRequestValidator());
+ }
+
+ @Test
+ @DisplayName("Valid request with buy value specified")
+ void validRequestWithBuyTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(PLN_SYMBOL)
+ .to(USD_SYMBOL)
+ .toBuy(USD_BUY)
+ .build()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Valid request with sell value specified")
+ void validRequestWithSellTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(PLN_SYMBOL)
+ .to(USD_SYMBOL)
+ .toSell(USD_SELL)
+ .build()).isTrue();
+ }
+
+ @Disabled("Already validated (has field annotation)")
+ @DisplayName("Invalid PESEL value")
+ @ParameterizedTest
+ @MethodSource
+ void invalidPeselTest(String pesel) {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .pesel(pesel)
+ .from(PLN_SYMBOL)
+ .to(USD_SYMBOL)
+ .toSell(USD_SELL)
+ .build()).isFalse();
+ }
+
+ @Test
+ @DisplayName("From and To have same value")
+ void sameFromToTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(USD_SYMBOL)
+ .to(USD_SYMBOL)
+ .toSell(USD_SELL)
+ .build()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Empty amounts")
+ void emptyBuySellTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(PLN_SYMBOL)
+ .to(USD_SYMBOL)
+ .build()).isFalse();
+ }
+
+ @Disabled("Already validated (has field annotation)")
+ @Test
+ @DisplayName("Empty 'from' value")
+ void emptyFromTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .to(USD_SYMBOL)
+ .toSell(USD_SELL)
+ .build()).isFalse();
+ }
+
+ @Disabled("Already validated (has field annotation)")
+ @Test
+ @DisplayName("Empty 'to' value")
+ void emptyToTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(PLN_SYMBOL)
+ .toSell(USD_SELL)
+ .build()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Both Buy and Sell params filled in")
+ void bothFilledBuySellTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(PLN_SYMBOL)
+ .to(USD_SYMBOL)
+ .toBuy(USD_BUY)
+ .toSell(USD_SELL)
+ .build()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Negative buy amount value")
+ void negativeBuyAmountTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(PLN_SYMBOL)
+ .to(USD_SYMBOL)
+ .toBuy(-1.0)
+ .build()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Negative sell amount value")
+ void negativeSellAmountTest() {
+ assertThatValidation(EntityCreator.exchangeRequest()
+ .from(PLN_SYMBOL)
+ .to(USD_SYMBOL)
+ .toSell(-1.0)
+ .build()).isFalse();
+ }
+
+ private static Stream invalidPeselTest() {
+ return Stream.of("INVALID", PESEL.replace('6', '7'));
+ }
+
+}
diff --git a/src/test/java/eu/ztsh/wymiana/validation/ValidatorTest.java b/src/test/java/eu/ztsh/wymiana/validation/ValidatorTest.java
new file mode 100644
index 0000000..7dc4bd8
--- /dev/null
+++ b/src/test/java/eu/ztsh/wymiana/validation/ValidatorTest.java
@@ -0,0 +1,31 @@
+package eu.ztsh.wymiana.validation;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import org.assertj.core.api.AbstractBooleanAssert;
+import org.hibernate.validator.internal.engine.DefaultClockProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.mockito.Mockito;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public abstract class ValidatorTest, C> {
+
+ private final V validator;
+ private static ConstraintValidatorContext validatorContext;
+
+ protected ValidatorTest(V validator) {
+ this.validator = validator;
+ }
+
+ @BeforeAll
+ static void prepare() {
+ validatorContext = Mockito.mock(ConstraintValidatorContext.class);
+ Mockito.when(validatorContext.getClockProvider()).thenReturn(DefaultClockProvider.INSTANCE);
+ }
+
+ protected AbstractBooleanAssert> assertThatValidation(C value) {
+ return assertThat(validator.isValid(value, validatorContext));
+ }
+
+}