View Javadoc
1   package de.dlr.shepard.data.timeseries.services;
2   
3   import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
4   import static org.junit.jupiter.api.Assertions.assertEquals;
5   import static org.junit.jupiter.api.Assertions.assertInstanceOf;
6   import static org.junit.jupiter.api.Assertions.assertNotNull;
7   import static org.junit.jupiter.api.Assertions.assertThrows;
8   import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
9   import static org.junit.jupiter.api.Assertions.assertTrue;
10  import static org.junit.jupiter.api.Assertions.fail;
11  import static org.mockito.Mockito.mock;
12  import static org.mockito.Mockito.when;
13  
14  import de.dlr.shepard.auth.security.AuthenticationContext;
15  import de.dlr.shepard.auth.users.entities.User;
16  import de.dlr.shepard.auth.users.services.UserService;
17  import de.dlr.shepard.common.exceptions.InvalidAuthException;
18  import de.dlr.shepard.common.exceptions.InvalidBodyException;
19  import de.dlr.shepard.common.exceptions.InvalidPathException;
20  import de.dlr.shepard.data.timeseries.TimeseriesTestDataGenerator;
21  import de.dlr.shepard.data.timeseries.io.TimeseriesContainerIO;
22  import de.dlr.shepard.data.timeseries.model.TimeseriesContainer;
23  import de.dlr.shepard.data.timeseries.model.TimeseriesDataPoint;
24  import de.dlr.shepard.data.timeseries.model.TimeseriesDataPointsQueryParams;
25  import de.dlr.shepard.data.timeseries.model.TimeseriesTuple;
26  import io.quarkus.test.InjectMock;
27  import io.quarkus.test.junit.QuarkusTest;
28  import jakarta.inject.Inject;
29  import jakarta.transaction.Transactional;
30  import jakarta.ws.rs.core.Response.Status;
31  import java.time.Instant;
32  import java.util.ArrayList;
33  import java.util.Arrays;
34  import java.util.List;
35  import java.util.NoSuchElementException;
36  import java.util.Optional;
37  import org.eclipse.microprofile.config.Config;
38  import org.eclipse.microprofile.config.ConfigProvider;
39  import org.junit.jupiter.api.BeforeAll;
40  import org.junit.jupiter.api.BeforeEach;
41  import org.junit.jupiter.api.Test;
42  import org.mockito.Mockito;
43  
44  @QuarkusTest
45  public class TimeseriesServiceTest {
46  
47    @Inject
48    TimeseriesService timeseriesService;
49  
50    @Inject
51    TimeseriesContainerService timeseriesContainerService;
52  
53    @InjectMock
54    UserService userService;
55  
56    @InjectMock
57    AuthenticationContext authenticationContext;
58  
59    private final String containerName = "TimeseriesServiceTestContainer";
60    private final long startDate = InstantHelper.fromGermanDate("01.01.2024").toNano();
61    private final long endDate = InstantHelper.now().addHours(1).toNano();
62  
63    private TimeseriesContainer container;
64  
65    @BeforeEach
66    public void setUpEach() {
67      User user = new User("Testuser");
68      when(userService.getCurrentUser()).thenReturn(user);
69      when(authenticationContext.getCurrentUserName()).thenReturn(user.getUsername());
70  
71      TimeseriesContainerIO containerIO = new TimeseriesContainerIO();
72      containerIO.setName(containerName);
73  
74      container = timeseriesContainerService.createContainer(containerIO);
75    }
76  
77    @Test
78    @Transactional
79    public void saveDataPoints_addDoubleValue_success() {
80      var timeseries = TimeseriesTestDataGenerator.generateTimeseries("measurement");
81      List<TimeseriesDataPoint> dataPoints = new ArrayList<>();
82      var point = TimeseriesTestDataGenerator.generateDataPointDouble(123.456);
83      dataPoints.add(point);
84  
85      var created = this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
86      assertNotNull(created);
87      TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(
88        startDate,
89        endDate,
90        null,
91        null,
92        null
93      );
94      var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
95      assertNotNull(actual);
96      assertEquals(1, actual.size());
97      TimeseriesDataPoint actualPoint = actual.getFirst();
98      assertInstanceOf(Double.class, actualPoint.getValue(), "DataPoint value must be a double");
99      assertEquals(point.getTimestamp(), actualPoint.getTimestamp(), "DataPoint timestamp must be taken over");
100   }
101 
102   @Test
103   @Transactional
104   public void saveDataPoints_addBooleanValue_success() {
105     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("measurement");
106     List<TimeseriesDataPoint> dataPoints = new ArrayList<>();
107     var point = TimeseriesTestDataGenerator.generateDataPointBoolean(true);
108     dataPoints.add(point);
109 
110     var created = this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
111     assertNotNull(created);
112     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(
113       startDate,
114       endDate,
115       null,
116       null,
117       null
118     );
119     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
120     assertNotNull(actual);
121     assertEquals(1, actual.size());
122     TimeseriesDataPoint actualPoint = actual.getFirst();
123     assertInstanceOf(Boolean.class, actualPoint.getValue(), "DataPoint value must be a boolean");
124     assertEquals(point.getTimestamp(), actualPoint.getTimestamp(), "DataPoint timestamp must be taken over");
125   }
126 
127   @Test
128   @Transactional
129   public void saveDataPoints_addStringValue_success() {
130     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("measurement");
131     List<TimeseriesDataPoint> dataPoints = new ArrayList<>();
132     var point = TimeseriesTestDataGenerator.generateDataPointString("Hello World");
133     dataPoints.add(point);
134 
135     var created = this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
136     assertNotNull(created);
137     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(
138       startDate,
139       endDate,
140       null,
141       null,
142       null
143     );
144     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
145     assertNotNull(actual);
146     assertEquals(1, actual.size());
147     TimeseriesDataPoint actualPoint = actual.getFirst();
148     assertInstanceOf(String.class, actualPoint.getValue(), "DataPoint value must be a string");
149     assertEquals(point.getTimestamp(), actualPoint.getTimestamp(), "DataPoint timestamp must be taken over");
150   }
151 
152   @Test
153   @Transactional
154   public void saveDataPoints_addIntegerValue_success() {
155     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("measurement");
156     List<TimeseriesDataPoint> dataPoints = new ArrayList<>();
157     var point = TimeseriesTestDataGenerator.generateDataPointInteger(42);
158     dataPoints.add(point);
159 
160     var created = this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
161     assertNotNull(created);
162     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(
163       startDate,
164       endDate,
165       null,
166       null,
167       null
168     );
169     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
170     assertNotNull(actual);
171     assertEquals(1, actual.size());
172     TimeseriesDataPoint actualPoint = actual.getFirst();
173     assertInstanceOf(Long.class, actualPoint.getValue(), "DataPoint value must be a long");
174     assertEquals(point.getTimestamp(), actualPoint.getTimestamp(), "DataPoint timestamp must be taken over");
175   }
176 
177   @Test
178   @Transactional
179   public void saveDataPoints_toExistingTimeseries_success() {
180     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("temperature");
181     List<TimeseriesDataPoint> dataPoints = new ArrayList<>(
182       List.of(TimeseriesTestDataGenerator.generateDataPointDouble(22.1))
183     );
184 
185     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
186 
187     List<TimeseriesDataPoint> morePoints = new ArrayList<>(
188       List.of(TimeseriesTestDataGenerator.generateDataPointDouble(22.2))
189     );
190 
191     this.timeseriesService.saveDataPoints(container.getId(), timeseries, morePoints);
192     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(
193       startDate,
194       endDate,
195       null,
196       null,
197       null
198     );
199     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
200     assertEquals(2, actual.size());
201   }
202 
203   @Test
204   @Transactional
205   public void saveDataPoints_requiredFieldsMissing_throwsException() {
206     var timeseries = new TimeseriesTuple("", "", "", "", "");
207     List<TimeseriesDataPoint> dataPoints = new ArrayList<>();
208     var point = TimeseriesTestDataGenerator.generateDataPointInteger(5);
209     dataPoints.add(point);
210 
211     InvalidBodyException thrown = assertThrowsExactly(InvalidBodyException.class, () ->
212       this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints)
213     );
214 
215     assertEquals(Status.BAD_REQUEST.getStatusCode(), thrown.getResponse().getStatus());
216   }
217 
218   @Test
219   @Transactional
220   public void saveDataPoints_addDataPointToExistingTimeseriesWithDifferentType_throwsException() {
221     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("temperature");
222 
223     List<TimeseriesDataPoint> dataPoints = new ArrayList<>();
224     var point = TimeseriesTestDataGenerator.generateDataPointDouble(22.3);
225     dataPoints.add(point);
226     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
227 
228     List<TimeseriesDataPoint> otherDataPoints = new ArrayList<>();
229     var pointWithDifferentType = TimeseriesTestDataGenerator.generateDataPointInteger(20);
230     otherDataPoints.add(pointWithDifferentType);
231 
232     InvalidBodyException thrown = assertThrowsExactly(InvalidBodyException.class, () ->
233       this.timeseriesService.saveDataPoints(container.getId(), timeseries, otherDataPoints)
234     );
235 
236     assertEquals(Status.BAD_REQUEST.getStatusCode(), thrown.getResponse().getStatus());
237   }
238 
239   @Test
240   @Transactional
241   public void saveDataPoints_addDataPointToExistingTimeseriesWithDifferentType_autoConversion() {
242     try (var configProviderMock = Mockito.mockStatic(ConfigProvider.class)) {
243       var config = mock(Config.class);
244       configProviderMock.when(ConfigProvider::getConfig).thenReturn(config);
245       when(config.getOptionalValue("shepard.autoconvert-int", Boolean.class)).thenReturn(Optional.of(true));
246 
247       var timeseries = TimeseriesTestDataGenerator.generateTimeseries("temperature");
248 
249       List<TimeseriesDataPoint> dataPoints = new ArrayList<>();
250       var point = TimeseriesTestDataGenerator.generateDataPointDouble(22.3);
251       dataPoints.add(point);
252       this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
253 
254       List<TimeseriesDataPoint> otherDataPoints = new ArrayList<>();
255       var pointWithDifferentType = TimeseriesTestDataGenerator.generateDataPointInteger(20);
256       otherDataPoints.add(pointWithDifferentType);
257 
258       assertDoesNotThrow(() -> {
259         this.timeseriesService.saveDataPoints(container.getId(), timeseries, otherDataPoints);
260       });
261 
262       var queryParams = new TimeseriesDataPointsQueryParams(
263         0L,
264         Instant.now().toEpochMilli() * 1_000_000,
265         null,
266         null,
267         null
268       );
269       var storedPoints = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
270 
271       assertEquals(2, storedPoints.size());
272 
273       var storedValues = storedPoints.stream().map(TimeseriesDataPoint::getValue).toList();
274 
275       storedValues.forEach(item -> assertInstanceOf(Double.class, item));
276 
277       List<Double> expectedValues = Arrays.asList(22.3, 20.0);
278 
279       assertTrue(storedValues.containsAll(expectedValues));
280     }
281   }
282 
283   @Test
284   @Transactional
285   public void getTimeseriesAvailable_timeseriesExists_returnsTimeseries() {
286     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("temperature");
287     List<TimeseriesDataPoint> dataPoints = new ArrayList<>(
288       List.of(TimeseriesTestDataGenerator.generateDataPointDouble(22.1))
289     );
290 
291     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
292 
293     var actual = this.timeseriesService.getTimeseriesAvailable(container.getId()).toList();
294     assertEquals(1, actual.size());
295     assertEquals("temperature", actual.getFirst().getTimeseriesTuple().getMeasurement());
296   }
297 
298   @Test
299   public void getTimeseriesById_timeseriesDoesNotExist_throwsNoSuchElementException() {
300     var nonExistingTimeseriesId = -1L;
301 
302     assertThrowsExactly(NoSuchElementException.class, () ->
303       this.timeseriesService.getTimeseriesById(nonExistingTimeseriesId)
304     );
305   }
306 
307   @Test
308   public void getTimeseriesById_timeseriesExists_containerNotReadable_throwsInvalidAuthException() {
309     // Arrange
310     User user1 = new User("u1");
311     when(userService.getCurrentUser()).thenReturn(user1);
312     when(authenticationContext.getCurrentUserName()).thenReturn(user1.getUsername());
313     TimeseriesContainerIO containerIO = new TimeseriesContainerIO();
314     containerIO.setName(containerName);
315     var privateContainerId = timeseriesContainerService.createContainer(containerIO).getId();
316     var nonAccessibleTimeseriesId = timeseriesService
317       .saveDataPoints(
318         privateContainerId,
319         new TimeseriesTuple("m", "d", "l", "s", "f"),
320         List.of(new TimeseriesDataPoint(1, 1))
321       )
322       .getTimeseriesId();
323 
324     User user2 = new User("u2");
325     when(userService.getCurrentUser()).thenReturn(user2);
326     when(authenticationContext.getCurrentUserName()).thenReturn(user2.getUsername());
327 
328     // Act & Assert
329     assertThrows(InvalidAuthException.class, () -> this.timeseriesService.getTimeseriesById(nonAccessibleTimeseriesId));
330   }
331 
332   @Test
333   public void getTimeseries_timeseriesDoesNotExist_returnsEmpty() {
334     TimeseriesTuple nonExistingTimeseries = new TimeseriesTuple(
335       "nonExisting",
336       "nonExisting",
337       "nonExisting",
338       "nonExisting",
339       "nonExisting"
340     );
341 
342     assertEquals(Optional.empty(), this.timeseriesService.getTimeseries(container.getId(), nonExistingTimeseries));
343   }
344 
345   @Test
346   @Transactional
347   public void getTimeseriesAvailable_containerDoesNotExist_throwsNotFoundException() {
348     int nonExistingContainerId = -1;
349 
350     assertThrowsExactly(InvalidPathException.class, () ->
351       this.timeseriesService.getTimeseriesAvailable(nonExistingContainerId)
352     );
353   }
354 
355   @Test
356   @Transactional
357   public void getDataPointsByTimeseries_forGivenDuration_returnsAll() {
358     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("humidity");
359     var start = InstantHelper.now().addDays(-4).toNano();
360     var end = InstantHelper.now().addDays(-2).toNano();
361 
362     List<TimeseriesDataPoint> dataPoints = new ArrayList<>(
363       List.of(
364         TimeseriesTestDataGenerator.generateDataPointInteger(start, 70),
365         TimeseriesTestDataGenerator.generateDataPointInteger(start + 1000, 80),
366         TimeseriesTestDataGenerator.generateDataPointInteger(start + 100000, 65),
367         TimeseriesTestDataGenerator.generateDataPointInteger(end - 10000, 72),
368         TimeseriesTestDataGenerator.generateDataPointInteger(end, 88)
369       )
370     );
371 
372     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
373 
374     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(start, end, null, null, null);
375 
376     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
377 
378     assertEquals(dataPoints.size(), actual.size());
379     assertTrue(actual.containsAll(dataPoints));
380     assertTrue(dataPoints.containsAll(actual));
381   }
382 
383   @Test
384   @Transactional
385   public void getDataPointsByTimeseries_forGivenDuration_returnsThreeOutOfFive() {
386     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("humidity");
387     var start = InstantHelper.now().addDays(-4).toNano();
388     var end = InstantHelper.now().addDays(-2).toNano();
389     List<TimeseriesDataPoint> dataPoints = new ArrayList<>(
390       List.of(
391         TimeseriesTestDataGenerator.generateDataPointInteger(InstantHelper.now().addDays(-5).toNano(), 70),
392         TimeseriesTestDataGenerator.generateDataPointInteger(start, 80),
393         TimeseriesTestDataGenerator.generateDataPointInteger(InstantHelper.now().addDays(-3).toNano(), 65),
394         TimeseriesTestDataGenerator.generateDataPointInteger(end, 72),
395         TimeseriesTestDataGenerator.generateDataPointInteger(InstantHelper.now().addDays(-1).toNano(), 88)
396       )
397     );
398 
399     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
400     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(start, end, null, null, null);
401     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
402 
403     assertEquals(3, actual.size());
404   }
405 
406   @Test
407   @Transactional
408   public void getDataPointsByTimeseries_forGivenDuration_returnNone() {
409     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("humidity");
410     var start = InstantHelper.now().addDays(-4).toNano();
411     var end = InstantHelper.now().addDays(-2).toNano();
412     List<TimeseriesDataPoint> dataPoints = new ArrayList<>(
413       List.of(
414         TimeseriesTestDataGenerator.generateDataPointInteger(start - 1000, 70),
415         TimeseriesTestDataGenerator.generateDataPointInteger(start - 1100, 80),
416         TimeseriesTestDataGenerator.generateDataPointInteger(start - 1200, 65),
417         TimeseriesTestDataGenerator.generateDataPointInteger(end + 10000, 72),
418         TimeseriesTestDataGenerator.generateDataPointInteger(end + 1000, 88)
419       )
420     );
421 
422     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
423     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(start, end, null, null, null);
424     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
425 
426     assertEquals(0, actual.size());
427   }
428 
429   /**
430    * The intended behavior of the timescaleDb is to silently overwrite non-unique timestamp values with the most recent record.
431    * Meaning that when a record has a timestamp that is already present in the DB, the new record should overwrite the old one.
432    */
433   @Test
434   @Transactional
435   public void saveDataPoint_non_unique_returnOverwritten() {
436     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("uniqueness-test-1");
437 
438     // setup batch of distinct timeseries data points
439     var timeseriesDataPoint1 = new TimeseriesDataPoint(1708067683056880001L, "value 1");
440     var timeseriesDataPoint2 = new TimeseriesDataPoint(1708067683056880002L, "value 2");
441     var timeseriesDataPoint3 = new TimeseriesDataPoint(1708067683056880003L, "value 3");
442 
443     List<TimeseriesDataPoint> dataPoints = new ArrayList<>(
444       List.of(timeseriesDataPoint1, timeseriesDataPoint2, timeseriesDataPoint3)
445     );
446 
447     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
448 
449     // create new batch of data points, this checks that overwriting existing timeseries datapoints work
450     var timeseriesDataPoint3New = new TimeseriesDataPoint(1708067683056880003L, "value 3 UPDATED");
451     var timeseriesDataPoint4 = new TimeseriesDataPoint(1708067683056880004L, "value 4");
452     var timeseriesDataPoint5 = new TimeseriesDataPoint(1708067683056880005L, "value 5");
453 
454     List<TimeseriesDataPoint> dataPointsContainingNonUnique = new ArrayList<>(
455       List.of(timeseriesDataPoint3New, timeseriesDataPoint4, timeseriesDataPoint5)
456     );
457 
458     this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPointsContainingNonUnique);
459     TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(
460       1L,
461       1708067683056880099L,
462       null,
463       null,
464       null
465     );
466     var actual = this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
467 
468     assertEquals(5, actual.size());
469     assertEquals("value 1", actual.getFirst().getValue());
470     assertEquals("value 2", actual.get(1).getValue());
471     assertEquals("value 3 UPDATED", actual.get(2).getValue());
472     assertEquals("value 4", actual.get(3).getValue());
473     assertEquals("value 5", actual.get(4).getValue());
474   }
475 
476   @Test
477   @Transactional
478   public void saveDataPoint_non_unique_batch_returnExceptionOrSilentlyOverwrite() {
479     var timeseries = TimeseriesTestDataGenerator.generateTimeseries("uniqueness-test-2");
480 
481     // setup batch of non-unique timestamp values - we expect an exception to be thrown
482     var timeseriesDataPoint1 = new TimeseriesDataPoint(1708067683056880001L, "value 1");
483     var timeseriesDataPoint2 = new TimeseriesDataPoint(1708067683056880001L, "value 2");
484     List<TimeseriesDataPoint> dataPoints = new ArrayList<>(List.of(timeseriesDataPoint1, timeseriesDataPoint2));
485 
486     // These test cases and their behavior here is due to a problem with the UPSERT command in postgres
487     // The issue is further documented in the architectural documentation under 'Building Block View' -> 'Timeseries: Multiple Values for One Timestamp'
488     try {
489       this.timeseriesService.saveDataPoints(container.getId(), timeseries, dataPoints);
490       TimeseriesDataPointsQueryParams queryParams = new TimeseriesDataPointsQueryParams(
491         1L,
492         1908067683056880001L,
493         null,
494         null,
495         null
496       );
497       var retrievedTimeseries =
498         this.timeseriesService.getDataPointsByTimeseries(container.getId(), timeseries, queryParams);
499       assertEquals(1, retrievedTimeseries.size());
500       assertEquals("value 2", retrievedTimeseries.getFirst().getValue());
501     } catch (InvalidBodyException ex) {
502       assertTrue(true);
503     } catch (Exception ex) {
504       fail("An unexpected exception was thrown.");
505     }
506   }
507 }