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