1+ package com .lantanagroup .link .validation .providers ;
2+
3+ import ca .uhn .fhir .context .support .IValidationSupport ;
4+ import com .github .benmanes .caffeine .cache .Caffeine ;
5+ import org .junit .jupiter .api .Test ;
6+ import org .springframework .cache .Cache ;
7+ import org .springframework .cache .CacheManager ;
8+ import org .springframework .cache .annotation .EnableCaching ;
9+ import org .springframework .cache .caffeine .CaffeineCacheManager ;
10+ import org .springframework .context .annotation .AnnotationConfigApplicationContext ;
11+ import org .springframework .context .annotation .Bean ;
12+ import org .springframework .context .annotation .Configuration ;
13+ import org .springframework .data .redis .cache .RedisCacheConfiguration ;
14+ import org .springframework .data .redis .cache .RedisCacheManager ;
15+ import org .springframework .data .redis .cache .RedisCacheWriter ;
16+
17+ import java .time .Duration ;
18+ import java .util .Base64 ;
19+ import java .util .HashMap ;
20+ import java .util .Map ;
21+ import java .util .Objects ;
22+ import java .util .Set ;
23+ import java .util .concurrent .atomic .AtomicReference ;
24+
25+ import static org .junit .jupiter .api .Assertions .assertEquals ;
26+ import static org .junit .jupiter .api .Assertions .assertNotEquals ;
27+ import static org .junit .jupiter .api .Assertions .assertNotNull ;
28+ import static org .junit .jupiter .api .Assertions .assertNull ;
29+ import static org .junit .jupiter .api .Assertions .assertSame ;
30+ import static org .mockito .ArgumentMatchers .any ;
31+ import static org .mockito .ArgumentMatchers .anyString ;
32+ import static org .mockito .ArgumentMatchers .isNull ;
33+ import static org .mockito .Mockito .doAnswer ;
34+ import static org .mockito .Mockito .mock ;
35+ import static org .mockito .Mockito .times ;
36+ import static org .mockito .Mockito .verify ;
37+ import static org .mockito .Mockito .when ;
38+
39+ class ValidationCacheServiceCachingTest {
40+ private static final String CODE_SYSTEM = "http://example.org/system" ;
41+ private static final String DISPLAY = "Example display" ;
42+ private static final String VALUE_SET_URL = "http://example.org/ValueSet/example" ;
43+
44+ @ Test
45+ void validateCodeCacheKey_keepsObjectsHashCollisionSeparateInBothCacheBackends () {
46+ ValidationCacheService cacheService = new ValidationCacheService ();
47+ String aaKey = cacheService .validateCodeCacheKey (CODE_SYSTEM , "Aa" , DISPLAY , VALUE_SET_URL );
48+ String bbKey = cacheService .validateCodeCacheKey (CODE_SYSTEM , "BB" , DISPLAY , VALUE_SET_URL );
49+
50+ assertEquals (
51+ Objects .hash (CODE_SYSTEM , "Aa" , DISPLAY , VALUE_SET_URL ),
52+ Objects .hash (CODE_SYSTEM , "BB" , DISPLAY , VALUE_SET_URL ),
53+ "fixed regression inputs must collide under the former Objects.hash cache key" );
54+ assertNotEquals (aaKey , bbKey );
55+
56+ CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager ("validateCodeCache" );
57+ caffeineCacheManager .setCaffeine (Caffeine .newBuilder ());
58+ assertStoresSeparateEntries (caffeineCacheManager .getCache ("validateCodeCache" ), aaKey , bbKey );
59+
60+ Map <String , byte []> redisEntries = new HashMap <>();
61+ RedisCacheWriter redisCacheWriter = mock (RedisCacheWriter .class );
62+ when (redisCacheWriter .get (anyString (), any (byte [].class ))).thenAnswer (invocation ->
63+ redisEntries .get (serializeKey (invocation .getArgument (1 ))));
64+ doAnswer (invocation -> {
65+ redisEntries .put (serializeKey (invocation .getArgument (1 )), invocation .getArgument (2 ));
66+ return null ;
67+ }).when (redisCacheWriter ).put (anyString (), any (byte [].class ), any (byte [].class ), any (Duration .class ));
68+
69+ CacheManager redisCacheManager = RedisCacheManager .builder (redisCacheWriter )
70+ .cacheDefaults (RedisCacheConfiguration .defaultCacheConfig ())
71+ .build ();
72+ assertStoresSeparateEntries (redisCacheManager .getCache ("validateCodeCache" ), aaKey , bbKey );
73+ assertEquals (2 , redisEntries .size (), "Redis must receive distinct serialized keys" );
74+ }
75+
76+ @ Test
77+ void validateCodeCacheKey_distinguishesNullEmptyAndLiteralNullForNullableInputs () {
78+ ValidationCacheService cacheService = new ValidationCacheService ();
79+ assertDistinct (
80+ cacheService .validateCodeCacheKey (null , "code" , DISPLAY , VALUE_SET_URL ),
81+ cacheService .validateCodeCacheKey ("" , "code" , DISPLAY , VALUE_SET_URL ),
82+ cacheService .validateCodeCacheKey ("null" , "code" , DISPLAY , VALUE_SET_URL ));
83+ assertDistinct (
84+ cacheService .validateCodeCacheKey (CODE_SYSTEM , "code" , null , VALUE_SET_URL ),
85+ cacheService .validateCodeCacheKey (CODE_SYSTEM , "code" , "" , VALUE_SET_URL ),
86+ cacheService .validateCodeCacheKey (CODE_SYSTEM , "code" , "null" , VALUE_SET_URL ));
87+ assertDistinct (
88+ cacheService .validateCodeCacheKey (CODE_SYSTEM , "code" , DISPLAY , null ),
89+ cacheService .validateCodeCacheKey (CODE_SYSTEM , "code" , DISPLAY , "" ),
90+ cacheService .validateCodeCacheKey (CODE_SYSTEM , "code" , DISPLAY , "null" ));
91+ }
92+
93+ @ Test
94+ void cachedValidateCode_cachesNonNullNegativeResultsAndHitsForRepeatedTuples () {
95+ try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext (CaffeineCacheTestConfiguration .class )) {
96+ ValidationCacheService cacheService = context .getBean (ValidationCacheService .class );
97+ RemoteTermServiceValidation delegate = mock (RemoteTermServiceValidation .class );
98+ when (delegate .invokeRemoteValidateCode (anyString (), anyString (), anyString (), anyString (), isNull ()))
99+ .thenAnswer (invocation -> negativeResult (invocation .getArgument (1 )));
100+
101+ IValidationSupport .CodeValidationResult aaResult =
102+ cacheService .cachedValidateCode (delegate , CODE_SYSTEM , "Aa" , DISPLAY , VALUE_SET_URL );
103+ IValidationSupport .CodeValidationResult bbResult =
104+ cacheService .cachedValidateCode (delegate , CODE_SYSTEM , "BB" , DISPLAY , VALUE_SET_URL );
105+ IValidationSupport .CodeValidationResult repeatedAaResult =
106+ cacheService .cachedValidateCode (delegate , CODE_SYSTEM , "Aa" , DISPLAY , VALUE_SET_URL );
107+
108+ assertEquals (IValidationSupport .IssueSeverity .ERROR , aaResult .getSeverity ());
109+ assertEquals (IValidationSupport .IssueSeverity .ERROR , bbResult .getSeverity ());
110+ assertNotEquals (aaResult .getCode (), bbResult .getCode ());
111+ assertSame (aaResult , repeatedAaResult , "the same tuple must be returned from the cache" );
112+ verify (delegate , times (2 )).invokeRemoteValidateCode (
113+ anyString (), anyString (), anyString (), anyString (), isNull ());
114+ }
115+ }
116+
117+ @ Test
118+ void cachedValidateCode_doesNotCacheNullResults () {
119+ try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext (CaffeineCacheTestConfiguration .class )) {
120+ ValidationCacheService cacheService = context .getBean (ValidationCacheService .class );
121+ RemoteTermServiceValidation delegate = mock (RemoteTermServiceValidation .class );
122+ when (delegate .invokeRemoteValidateCode (anyString (), anyString (), anyString (), anyString (), isNull ()))
123+ .thenReturn (null );
124+
125+ assertNull (cacheService .cachedValidateCode (delegate , CODE_SYSTEM , "code" , DISPLAY , VALUE_SET_URL ));
126+ assertNull (cacheService .cachedValidateCode (delegate , CODE_SYSTEM , "code" , DISPLAY , VALUE_SET_URL ));
127+ verify (delegate , times (2 )).invokeRemoteValidateCode (
128+ anyString (), anyString (), anyString (), anyString (), isNull ());
129+ }
130+ }
131+
132+ @ Test
133+ void cachedValidateCode_resolvesItsKeyWithoutApplicationClassInThreadContext () throws InterruptedException {
134+ try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext (CaffeineCacheTestConfiguration .class )) {
135+ ValidationCacheService cacheService = context .getBean (ValidationCacheService .class );
136+ RemoteTermServiceValidation delegate = mock (RemoteTermServiceValidation .class );
137+ when (delegate .invokeRemoteValidateCode (anyString (), anyString (), anyString (), anyString (), isNull ()))
138+ .thenReturn (negativeResult ("code" ));
139+
140+ AtomicReference <Throwable > failure = new AtomicReference <>();
141+ Thread worker = new Thread (() -> {
142+ try {
143+ cacheService .cachedValidateCode (delegate , CODE_SYSTEM , "code" , DISPLAY , VALUE_SET_URL );
144+ } catch (Throwable exception ) {
145+ failure .set (exception );
146+ }
147+ }, "validation-cache-worker" );
148+ worker .setContextClassLoader (ClassLoader .getPlatformClassLoader ());
149+ worker .start ();
150+ worker .join ();
151+
152+ assertNull (failure .get ());
153+ }
154+ }
155+
156+ private static void assertStoresSeparateEntries (Cache cache , String aaKey , String bbKey ) {
157+ assertNotNull (cache );
158+ cache .put (aaKey , "Aa result" );
159+ cache .put (bbKey , "BB result" );
160+ assertEquals ("Aa result" , cache .get (aaKey , String .class ));
161+ assertEquals ("BB result" , cache .get (bbKey , String .class ));
162+ }
163+
164+ private static void assertDistinct (String ... keys ) {
165+ assertEquals (keys .length , Set .of (keys ).size ());
166+ }
167+
168+ private static IValidationSupport .CodeValidationResult negativeResult (String code ) {
169+ IValidationSupport .CodeValidationResult result = new IValidationSupport .CodeValidationResult ();
170+ result .setCode (code );
171+ result .setSeverity (IValidationSupport .IssueSeverity .ERROR );
172+ return result ;
173+ }
174+
175+ private static String serializeKey (byte [] key ) {
176+ return Base64 .getEncoder ().encodeToString (key );
177+ }
178+
179+ @ Configuration (proxyBeanMethods = false )
180+ @ EnableCaching
181+ static class CaffeineCacheTestConfiguration {
182+ @ Bean
183+ CacheManager cacheManager () {
184+ CaffeineCacheManager cacheManager = new CaffeineCacheManager ("validateCodeCache" );
185+ cacheManager .setCaffeine (Caffeine .newBuilder ());
186+ return cacheManager ;
187+ }
188+
189+ @ Bean
190+ ValidationCacheService validationCacheService () {
191+ return new ValidationCacheService ();
192+ }
193+ }
194+ }
0 commit comments