Commit bbfc65b8 authored by calocedre TAC's avatar calocedre TAC
Browse files

Merge branch 'fix/slot-generation-tests' into 'develop'

Fix/slot generation tests

See merge request stemcovid19/tac-server/backend-server!228
parents 0ec0d546 681293fd
package fr.gouv.clea.consumer.configuration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import lombok.Data;
@Data
@Configuration
@ConfigurationProperties(prefix = "clea.conf")
public class VenueConsumerConfiguration {
private long durationUnitInSeconds;
}
......@@ -8,7 +8,7 @@ import lombok.experimental.SuperBuilder;
import java.time.Instant;
@SuperBuilder
@SuperBuilder(toBuilder = true)
@Getter
@Setter
@ToString
......
package fr.gouv.clea.consumer.service.impl;
import fr.gouv.clea.consumer.model.ExposedVisitEntity;
import fr.gouv.clea.consumer.model.Visit;
import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
import fr.inria.clea.lsp.utils.TimeUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
......@@ -19,34 +9,44 @@ import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import fr.gouv.clea.consumer.configuration.VenueConsumerConfiguration;
import fr.gouv.clea.consumer.model.ExposedVisitEntity;
import fr.gouv.clea.consumer.model.Visit;
import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
import fr.inria.clea.lsp.utils.TimeUtils;
import lombok.extern.slf4j.Slf4j;
@Component
@Slf4j
public class VisitExpositionAggregatorService implements IVisitExpositionAggregatorService {
private final IExposedVisitRepository repository;
private final int durationUnitInSeconds;
private final VenueConsumerConfiguration configuration;
@Autowired
public VisitExpositionAggregatorService(
IExposedVisitRepository repository,
@Value("${clea.conf.durationUnitInSeconds}") int durationUnitInSeconds
) {
VenueConsumerConfiguration configuration) {
this.repository = repository;
this.durationUnitInSeconds = durationUnitInSeconds;
this.configuration = configuration;
}
@Override
public void updateExposureCount(Visit visit) {
Instant periodStartAsInstant = this.periodStartFromCompressedPeriodStartAsInstant(visit.getCompressedPeriodStartTime());
long scanTimeSlot = Duration.between(periodStartAsInstant, visit.getQrCodeScanTime()).toSeconds() / durationUnitInSeconds;
long scanTimeSlot = Duration.between(periodStartAsInstant, visit.getQrCodeScanTime()).toSeconds() / configuration.getDurationUnitInSeconds();
if (scanTimeSlot < 0) {
log.warn("LTId: {}, qrScanTime: {} should not before periodStartTime: {}", visit.getLocationTemporaryPublicId(), visit.getQrCodeScanTime(), periodStartAsInstant);
return;
}
int exposureTime = this.getExposureTimeSlots(visit.getVenueType(), visit.getVenueCategory1(), visit.getVenueCategory2(), visit.isStaff());
int firstExposedSlot = Math.max(0, (int) scanTimeSlot - exposureTime);
int lastExposedSlot = Math.min(this.getPeriodMaxSlot(visit.getPeriodDuration()), (int) scanTimeSlot + exposureTime);
int exposureTime = this.getExposureTime(visit.getVenueType(), visit.getVenueCategory1(), visit.getVenueCategory2(), visit.isStaff());
int firstExposedSlot = Math.max(0, (int) scanTimeSlot - exposureTime + 1);
int lastExposedSlot = Math.min(this.getPeriodMaxSlot(visit.getPeriodDuration()), (int) scanTimeSlot + exposureTime - 1);
List<ExposedVisitEntity> exposedVisits = repository.findAllByLocationTemporaryPublicIdAndPeriodStart(visit.getLocationTemporaryPublicId(), this.periodStartFromCompressedPeriodStart(visit.getCompressedPeriodStartTime()));
......@@ -87,19 +87,19 @@ public class VisitExpositionAggregatorService implements IVisitExpositionAggrega
if (periodDuration == 255) {
return Integer.MAX_VALUE;
}
int nbSlotsInPeriod = (int) Duration.of(periodDuration, ChronoUnit.HOURS).dividedBy(Duration.of(durationUnitInSeconds, ChronoUnit.SECONDS));
int nbSlotsInPeriod = (int) Duration.of(periodDuration, ChronoUnit.HOURS).dividedBy(Duration.of(configuration.getDurationUnitInSeconds(), ChronoUnit.SECONDS));
return nbSlotsInPeriod - 1; // 0 based index
}
private long periodStartFromCompressedPeriodStart(long compressedPeriodStartTime) {
protected long periodStartFromCompressedPeriodStart(long compressedPeriodStartTime) {
return compressedPeriodStartTime * TimeUtils.NB_SECONDS_PER_HOUR;
}
private Instant periodStartFromCompressedPeriodStartAsInstant(long compressedPeriodStartTime) {
protected Instant periodStartFromCompressedPeriodStartAsInstant(long compressedPeriodStartTime) {
return TimeUtils.instantFromTimestamp(this.periodStartFromCompressedPeriodStart(compressedPeriodStartTime));
}
private ExposedVisitEntity updateExposedVisit(Visit visit, ExposedVisitEntity exposedVisit) {
protected ExposedVisitEntity updateExposedVisit(Visit visit, ExposedVisitEntity exposedVisit) {
if (visit.isBackward()) {
exposedVisit.setBackwardVisits(exposedVisit.getBackwardVisits() + 1);
} else {
......@@ -108,7 +108,7 @@ public class VisitExpositionAggregatorService implements IVisitExpositionAggrega
return exposedVisit;
}
private ExposedVisitEntity newExposedVisit(Visit visit, int slotIndex) {
protected ExposedVisitEntity newExposedVisit(Visit visit, int slotIndex) {
// TODO: visit.getPeriodStart returning an Instant
long periodStart = this.periodStartFromCompressedPeriodStart(visit.getCompressedPeriodStartTime());
return ExposedVisitEntity.builder()
......@@ -128,7 +128,7 @@ public class VisitExpositionAggregatorService implements IVisitExpositionAggrega
* e.g. if EXPOSURE_TIME_UNIT is 3600 sec (one hour), an exposure time equals to 3 means 3 hours
* if EXPOSURE_TIME_UNIT is 1800 sec (30 minutes), an exposure time equals to 3 means 1,5 hour.
*/
private int getExposureTimeSlots(int venueType, int venueCategory1, int venueCategory2, boolean staff) {
protected int getExposureTime(int venueType, int venueCategory1, int venueCategory2, boolean staff) {
return 3;
}
}
package fr.gouv.clea.consumer.service.impl;
import static java.time.temporal.ChronoUnit.DAYS;
import static java.time.temporal.ChronoUnit.HOURS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import fr.gouv.clea.consumer.configuration.VenueConsumerConfiguration;
import fr.gouv.clea.consumer.model.ExposedVisitEntity;
import fr.gouv.clea.consumer.model.Visit;
import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
import fr.inria.clea.lsp.utils.TimeUtils;
import org.apache.commons.lang3.RandomUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@ExtendWith(MockitoExtension.class)
public class SlotGenerationTest {
static final Instant TODAY_AT_MIDNIGHT = Instant.now().truncatedTo(DAYS);
static final Instant TODAY_AT_8AM = TODAY_AT_MIDNIGHT.plus(8, HOURS);
final VenueConsumerConfiguration config = new VenueConsumerConfiguration();
@Mock
IExposedVisitRepository repository;
@Captor
ArgumentCaptor<List<ExposedVisitEntity>> exposedVisitEntitiesCaptor;
VisitExpositionAggregatorService service;
@BeforeEach
void init() {
config.setDurationUnitInSeconds(Duration.ofMinutes(30).toSeconds());
service = new VisitExpositionAggregatorService(repository, config);
}
@Test
void a_period_duration_of_24_hours_generates_5_slots() {
Visit visit = defaultVisit().toBuilder()
.periodDuration(24)
.compressedPeriodStartTime(getCompressedPeriodStartTime(TODAY_AT_MIDNIGHT))
.qrCodeValidityStartTime(TODAY_AT_MIDNIGHT)
.qrCodeScanTime(TODAY_AT_8AM)
.build();
service.updateExposureCount(visit);
/* => scanTimeSlot = 8*2 = 16
* => slots to generate = 2 before + scanTimeSlot + 2 after = 5
* => firstExposedSlot = 16-2 = 14
* => lastExposedSlot = 16+2 = 18
*/
verify(repository).saveAll(exposedVisitEntitiesCaptor.capture());
assertThat(exposedVisitEntitiesCaptor.getValue())
.extracting(ExposedVisitEntity::getTimeSlot)
.containsExactly(14, 15, 16, 17, 18);
}
@Test
void a_period_duration_of_1_hour_generates_2_slots() {
Visit visit = defaultVisit().toBuilder()
.periodDuration(1)
.compressedPeriodStartTime(getCompressedPeriodStartTime(TODAY_AT_8AM))
.qrCodeValidityStartTime(TODAY_AT_8AM)
.qrCodeScanTime(TODAY_AT_8AM)
.build();
service.updateExposureCount(visit);
/* => scanTimeSlot = 0
* => slots to generate = scanTimeSlot + 1 after = 2
* => firstExposedSlot = 0
* => lastExposedSlot = 0+1 = 1
*/
verify(repository).saveAll(exposedVisitEntitiesCaptor.capture());
assertThat(exposedVisitEntitiesCaptor.getValue())
.extracting(ExposedVisitEntity::getTimeSlot)
.containsExactly(0, 1);
}
@Test
void a_visit_at_first_slot_with_an_unlimited_period_duration_generates_3_slots() {
Visit visit = defaultVisit().toBuilder()
.periodDuration(255)
.compressedPeriodStartTime(getCompressedPeriodStartTime(TODAY_AT_8AM))
.qrCodeValidityStartTime(TODAY_AT_8AM)
.qrCodeScanTime(TODAY_AT_8AM)
.build();
service.updateExposureCount(visit);
/*
* => scanTimeSlot = 0
* => slots to generate = scanTimeSlot + 2 after = 3
* => firstExposedSlot = 0
* => lastExposedSlot = 0+3-1 = 2
*/
verify(repository).saveAll(exposedVisitEntitiesCaptor.capture());
assertThat(exposedVisitEntitiesCaptor.getValue())
.extracting(ExposedVisitEntity::getTimeSlot)
.containsExactly(0, 1, 2);
}
@Test
void a_qrScanTime_after_period_validity_doesnt_generate_slots() {
Visit visit = defaultVisit().toBuilder()
.compressedPeriodStartTime(getCompressedPeriodStartTime(TODAY_AT_MIDNIGHT))
.periodDuration(6)
.qrCodeValidityStartTime(TODAY_AT_MIDNIGHT)
.qrCodeScanTime(TODAY_AT_8AM)
.build();
service.updateExposureCount(visit);
verify(repository, never()).saveAll(exposedVisitEntitiesCaptor.capture());
}
@Test
void a_visit_at_first_slot_when_qrScanTime_is_after_qr_validity_generates_5_slots() {
// This case can happen with authorized drift
config.setDurationUnitInSeconds(Duration.ofHours(1).toSeconds());
Visit visit = defaultVisit().toBuilder()
.compressedPeriodStartTime(getCompressedPeriodStartTime(TODAY_AT_MIDNIGHT))
.periodDuration(24)
.qrCodeValidityStartTime(TODAY_AT_MIDNIGHT)
.qrCodeRenewalIntervalExponentCompact(14) // 2^14 seconds = 4.55 hours
.qrCodeScanTime(TODAY_AT_8AM)
.build();
service.updateExposureCount(visit);
/*
* => scanTimeSlot = 8
* => slots to generate = scanTimeSlot + 2 after + 2 before
* => firstExposedSlot = 10
* => lastExposedSlot = 6
*/
verify(repository).saveAll(exposedVisitEntitiesCaptor.capture());
assertThat(exposedVisitEntitiesCaptor.getValue())
.extracting(ExposedVisitEntity::getTimeSlot)
.containsExactly(6, 7, 8, 9, 10);
}
protected Visit defaultVisit() {
return Visit.builder()
.version(0)
.type(0)
.staff(true)
.locationTemporaryPublicId(UUID.randomUUID())
.qrCodeRenewalIntervalExponentCompact(2)
.venueType(4)
.venueCategory1(1)
.venueCategory2(1)
.periodDuration(24)
.compressedPeriodStartTime(getCompressedPeriodStartTime(TODAY_AT_MIDNIGHT))
.qrCodeValidityStartTime(Instant.now())
.locationTemporarySecretKey(RandomUtils.nextBytes(20))
.encryptedLocationContactMessage(RandomUtils.nextBytes(20))
.qrCodeScanTime(TODAY_AT_8AM)
.isBackward(true)
.build();
}
protected int getCompressedPeriodStartTime(Instant instant) {
return (int) (TimeUtils.ntpTimestampFromInstant(instant) / 3600);
}
}
package fr.gouv.clea.consumer.service.impl;
import fr.gouv.clea.consumer.model.ExposedVisitEntity;
import fr.gouv.clea.consumer.model.Visit;
import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
import fr.inria.clea.lsp.utils.TimeUtils;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import org.apache.commons.lang3.RandomUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
......@@ -14,13 +16,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import fr.gouv.clea.consumer.model.ExposedVisitEntity;
import fr.gouv.clea.consumer.model.Visit;
import fr.gouv.clea.consumer.repository.IExposedVisitRepository;
import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
import fr.inria.clea.lsp.utils.TimeUtils;
@SpringBootTest
@DirtiesContext
......@@ -34,7 +34,6 @@ class VisitExpositionAggregatorServiceTest {
private Instant todayAtMidnight;
private Instant todayAt8am;
private long todayAtMidnightAsNtp;
private UUID uuid;
private byte[] locationTemporarySecretKey;
private byte[] encryptedLocationContactMessage;
......@@ -43,7 +42,6 @@ class VisitExpositionAggregatorServiceTest {
void init() {
todayAtMidnight = Instant.now().truncatedTo(ChronoUnit.DAYS);
todayAt8am = todayAtMidnight.plus(8, ChronoUnit.HOURS);
todayAtMidnightAsNtp = TimeUtils.ntpTimestampFromInstant(todayAtMidnight);
uuid = UUID.randomUUID();
locationTemporarySecretKey = RandomUtils.nextBytes(20);
encryptedLocationContactMessage = RandomUtils.nextBytes(20);
......@@ -57,23 +55,11 @@ class VisitExpositionAggregatorServiceTest {
@Test
@DisplayName("visits with no existing context should be saved in DB")
void saveWithNoContext() {
Visit visit = Visit.builder()
.version(0)
.type(0)
.staff(true)
Visit visit = defaultVisit().toBuilder()
.locationTemporaryPublicId(uuid)
.qrCodeRenewalIntervalExponentCompact(2)
.venueType(4)
.venueCategory1(0)
.venueCategory2(0)
.periodDuration(24)
.compressedPeriodStartTime((int) (todayAtMidnightAsNtp / 3600))
.qrCodeValidityStartTime(Instant.now())
.locationTemporarySecretKey(locationTemporarySecretKey)
.encryptedLocationContactMessage(encryptedLocationContactMessage)
.qrCodeScanTime(todayAt8am)
.isBackward(true)
.build();
service.updateExposureCount(visit);
List<ExposedVisitEntity> entities = repository.findAll();
......@@ -87,31 +73,17 @@ class VisitExpositionAggregatorServiceTest {
@Test
@DisplayName("visits with existing context should be updated in DB")
void updateWithExistingContext() {
Visit visit = Visit.builder()
.version(0)
.type(0)
.staff(true)
Visit visit = defaultVisit().toBuilder()
.locationTemporaryPublicId(uuid)
.qrCodeRenewalIntervalExponentCompact(2)
.venueType(4)
.venueCategory1(0)
.venueCategory2(0)
.periodDuration(24)
.compressedPeriodStartTime((int) (todayAtMidnightAsNtp / 3600))
.qrCodeValidityStartTime(Instant.now())
.locationTemporarySecretKey(locationTemporarySecretKey)
.encryptedLocationContactMessage(encryptedLocationContactMessage)
.qrCodeScanTime(todayAt8am)
.isBackward(true)
.build();
service.updateExposureCount(visit);
long before = repository.count();
service.updateExposureCount(visit);
long after = repository.count();
assertThat(before).isEqualTo(after);
List<ExposedVisitEntity> entities = repository.findAll();
entities.forEach(it -> {
assertThat(it.getLocationTemporaryPublicId()).isEqualTo(uuid);
......@@ -123,32 +95,20 @@ class VisitExpositionAggregatorServiceTest {
@Test
@DisplayName("new visits should be saved while existing be updated in DB")
void mixedContext() {
Visit visit = Visit.builder()
.version(0)
.type(0)
.staff(true)
Visit visit = defaultVisit().toBuilder()
.locationTemporaryPublicId(uuid)
.qrCodeRenewalIntervalExponentCompact(2)
.venueType(4)
.venueCategory1(0)
.venueCategory2(0)
.periodDuration(24)
.compressedPeriodStartTime((int) (todayAtMidnightAsNtp / 3600))
.qrCodeValidityStartTime(Instant.now())
.locationTemporarySecretKey(locationTemporarySecretKey)
.encryptedLocationContactMessage(encryptedLocationContactMessage)
.qrCodeScanTime(todayAt8am)
.isBackward(true)
.build();
service.updateExposureCount(visit);
visit.setBackward(false);
service.updateExposureCount(visit);
UUID newUUID = UUID.randomUUID();
visit.setLocationTemporaryPublicId(newUUID);
visit.setBackward(true);
Visit visit2 = visit.toBuilder()
.locationTemporaryPublicId(newUUID)
.isBackward(true)
.build();
service.updateExposureCount(visit);
service.updateExposureCount(visit2);
List<ExposedVisitEntity> entities = repository.findAll();
entities.stream()
......@@ -159,7 +119,6 @@ class VisitExpositionAggregatorServiceTest {
assertThat(it.getForwardVisits()).isEqualTo(1);
}
);
entities.stream()
.filter(it -> it.getLocationTemporaryPublicId().equals(newUUID))
.forEach(it -> {
......@@ -170,72 +129,16 @@ class VisitExpositionAggregatorServiceTest {
);
}
@Test
@DisplayName("test how many slots are generated for a given visit with a period duration of 24 hour")
void testSlotGeneration() {
Instant todayAtMidnight = Instant.now().truncatedTo(ChronoUnit.DAYS);
Instant todayAt8am = todayAtMidnight.plus(8, ChronoUnit.HOURS);
Visit visit = Visit.builder()
.version(0)
.type(0)
.staff(true)
.locationTemporaryPublicId(uuid)
.qrCodeRenewalIntervalExponentCompact(2)
.venueType(4)
.venueCategory1(0)
.venueCategory2(0)
.periodDuration(24)
.compressedPeriodStartTime(getCompressedPeriodStartTime(todayAtMidnight))
.qrCodeValidityStartTime(todayAtMidnight)
.locationTemporarySecretKey(locationTemporarySecretKey)
.encryptedLocationContactMessage(encryptedLocationContactMessage)
.qrCodeScanTime(todayAt8am)
.isBackward(true)
.build();
service.updateExposureCount(visit);
/*
* if:
* periodDuration = 24 hours
* periodStartTime = today at 00:00:00
* qrCodeScanTime = today at 08:00:00
* durationUnit = 1800 seconds
* exposureTime = 3
*
* then:
* => scanTimeSlot = 8*2 = 16
* => slots to generate = 3 before + scanTimeSlot + 3 after = 7
* => firstExposedSlot = 16-3 = 13
* => lastExposedSlot = 16+3 = 19
*/
assertThat(repository.count()).isEqualTo(7L);
List<ExposedVisitEntity> entities = repository.findAll();
IntStream.rangeClosed(13, 19)
.forEach(slot -> assertThat(entities.stream().filter(it -> it.getTimeSlot() == slot).count()).isEqualTo(1));
}
@Test
@DisplayName("stop processing if qrCodeScanTime is before periodStartTime")
void testWhenQrScanIsBeforePeriodStart() {
Instant todayAtMidnight = Instant.now().truncatedTo(ChronoUnit.DAYS);
Instant todayAt8am = todayAtMidnight.plus(8, ChronoUnit.HOURS);
Visit visit = Visit.builder()
.version(0)
.type(0)
.staff(true)
.locationTemporaryPublicId(uuid)
.qrCodeRenewalIntervalExponentCompact(2)
.venueType(4)
.venueCategory1(0)
.venueCategory2(0)
Visit visit = defaultVisit().toBuilder()
.periodDuration(24)
.compressedPeriodStartTime(getCompressedPeriodStartTime(todayAt8am))
.qrCodeValidityStartTime(todayAt8am)
.locationTemporarySecretKey(locationTemporarySecretKey)
.encryptedLocationContactMessage(encryptedLocationContactMessage)
.qrCodeScanTime(todayAtMidnight)
.isBackward(true)
.build();
service.updateExposureCount(visit);
......@@ -243,136 +146,24 @@ class VisitExpositionAggregatorServiceTest {
assertThat(repository.count()).isZero();
}
@Test
@DisplayName("test how many slots are generated for a given visit with a period duration of 1 hour")
void testSlotGenerationWithPeriodDuration1() {
Instant todayAtMidnight = Instant.now().truncatedTo(ChronoUnit.DAYS);
Instant todayAt8am = todayAtMidnight.plus(8, ChronoUnit.HOURS);
Visit visit = Visit.builder()
.version(0)
.type(0)