Commit 5debc740 authored by calocedre TAC's avatar calocedre TAC
Browse files

Merge remote-tracking branch 'origin/develop' into feature/clea/venue-consumer-config

parents 9ad881ad af451453
......@@ -25,6 +25,8 @@ import lombok.NoArgsConstructor;
public class VenueConsumerConfiguration {
@Min(value = 600)
private long durationUnitInSeconds;
@Min(value = 1800)
private long statSlotDurationInSeconds;
@Positive
private int driftBetweenDeviceAndOfficialTimeInSecs;
@Positive
......
package fr.gouv.clea.consumer.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.EmbeddedId;
import javax.persistence.Entity;
import javax.persistence.Table;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "STAT_LOCATION")
public class StatLocation {
@EmbeddedId
private StatLocationKey statLocationKey;
@Column(name = "backward_visits")
private long backwardVisits;
@Column(name = "forward_visits")
private long forwardVisits;
}
package fr.gouv.clea.consumer.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.io.Serializable;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class StatLocationKey implements Serializable {
private static final long serialVersionUID = 1L;
@Column(name = "period")
private Instant period;
@Column(name = "venue_type")
private int venueType;
@Column(name = "venue_category1")
private int venueCategory1;
@Column(name = "venue_category2")
private int venueCategory2;
}
package fr.gouv.clea.consumer.repository;
import fr.gouv.clea.consumer.model.StatLocation;
import fr.gouv.clea.consumer.model.StatLocationKey;
import org.springframework.data.jpa.repository.JpaRepository;
public interface IStatLocationRepository extends JpaRepository<StatLocation, StatLocationKey> {
}
......@@ -3,5 +3,4 @@ package fr.gouv.clea.consumer.service;
public interface IExposedVisitEntityService {
void deleteOutdatedExposedVisits();
}
package fr.gouv.clea.consumer.service;
import fr.gouv.clea.consumer.model.Visit;
public interface IStatService {
void logStats(Visit visit);
}
package fr.gouv.clea.consumer.service.impl;
import fr.gouv.clea.consumer.configuration.VenueConsumerConfiguration;
import fr.gouv.clea.consumer.model.StatLocation;
import fr.gouv.clea.consumer.model.StatLocationKey;
import fr.gouv.clea.consumer.model.Visit;
import fr.gouv.clea.consumer.repository.IStatLocationRepository;
import fr.gouv.clea.consumer.service.IStatService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
@Component
@Slf4j
public class StatService implements IStatService {
private final IStatLocationRepository repository;
private final VenueConsumerConfiguration config;
@Autowired
public StatService(
IStatLocationRepository repository,
VenueConsumerConfiguration configuration) {
this.repository = repository;
this.config = configuration;
}
@Override
public void logStats(Visit visit) {
StatLocationKey statLocationKey = StatLocationKey.builder()
.period(this.getStatPeriod(visit))
.venueType(visit.getVenueType())
.venueCategory1(visit.getVenueCategory1())
.venueCategory2(visit.getVenueCategory2())
.build();
Optional<StatLocation> optional = repository.findById(statLocationKey);
StatLocation statLocation;
if (optional.isEmpty()) {
statLocation = newStatLocation(statLocationKey, visit);
} else {
statLocation = updateStatLocation(optional.get(), visit);
}
repository.save(statLocation);
log.info("saved stat period: {}, venueType: {} venueCategory1: {}, venueCategory2: {}, backwardVisits: {}, forwardVisits: {}",
statLocation.getStatLocationKey().getPeriod(),
statLocation.getStatLocationKey().getVenueType(),
statLocation.getStatLocationKey().getVenueCategory1(),
statLocation.getStatLocationKey().getVenueCategory2(),
statLocation.getBackwardVisits(),
statLocation.getForwardVisits()
);
}
protected StatLocation newStatLocation(StatLocationKey statLocationKey, Visit visit) {
return StatLocation.builder()
.statLocationKey(statLocationKey)
.backwardVisits(visit.isBackward() ? 1 : 0)
.forwardVisits(visit.isBackward() ? 0 : 1)
.build();
}
protected StatLocation updateStatLocation(StatLocation statLocation, Visit visit) {
if (visit.isBackward()) {
statLocation.setBackwardVisits(statLocation.getBackwardVisits() + 1);
} else {
statLocation.setForwardVisits(statLocation.getForwardVisits() + 1);
}
return statLocation;
}
protected Instant getStatPeriod(Visit visit) {
long secondsToRemove = visit.getQrCodeScanTime().getEpochSecond() % config.getStatSlotDurationInSeconds();
return visit.getQrCodeScanTime().minus(secondsToRemove, ChronoUnit.SECONDS);
}
}
......@@ -16,6 +16,7 @@ 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.IStatService;
import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
import fr.inria.clea.lsp.utils.TimeUtils;
import lombok.extern.slf4j.Slf4j;
......@@ -28,12 +29,16 @@ public class VisitExpositionAggregatorService implements IVisitExpositionAggrega
private final VenueConsumerConfiguration configuration;
private final IStatService statService;
@Autowired
public VisitExpositionAggregatorService(
IExposedVisitRepository repository,
VenueConsumerConfiguration configuration) {
VenueConsumerConfiguration configuration,
IStatService statService) {
this.repository = repository;
this.configuration = configuration;
this.statService = statService;
}
@Override
......@@ -48,7 +53,7 @@ public class VisitExpositionAggregatorService implements IVisitExpositionAggrega
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()));
List<ExposedVisitEntity> exposedVisits = repository.findAllByLocationTemporaryPublicIdAndPeriodStart(visit.getLocationTemporaryPublicId(), periodStartFromCompressedPeriodStart(visit.getCompressedPeriodStartTime()));
List<ExposedVisitEntity> toUpdate = new ArrayList<>();
List<ExposedVisitEntity> toPersist = new ArrayList<>();
......@@ -71,6 +76,8 @@ public class VisitExpositionAggregatorService implements IVisitExpositionAggrega
repository.saveAll(merged);
log.info("Persisting {} new visits!", toPersist.size());
log.info("Updating {} existing visits!", toUpdate.size());
statService.logStats(visit);
} else {
log.info("LTId: {}, qrScanTime: {} - No visit to persist / update", visit.getLocationTemporaryPublicId(), visit.getQrCodeScanTime());
}
......@@ -110,7 +117,7 @@ public class VisitExpositionAggregatorService implements IVisitExpositionAggrega
protected ExposedVisitEntity newExposedVisit(Visit visit, int slotIndex) {
// TODO: visit.getPeriodStart returning an Instant
long periodStart = this.periodStartFromCompressedPeriodStart(visit.getCompressedPeriodStartTime());
long periodStart = periodStartFromCompressedPeriodStart(visit.getCompressedPeriodStartTime());
return ExposedVisitEntity.builder()
.locationTemporaryPublicId(visit.getLocationTemporaryPublicId())
.venueType(visit.getVenueType())
......
......@@ -4,6 +4,7 @@ clea:
driftBetweenDeviceAndOfficialTimeInSecs: 300
retentionDurationInDays: 14
durationUnitInSeconds: 1800
statSlotDurationInSeconds: 3600
scheduling:
purge:
cron: "0 0 1 * * *"
......
......@@ -77,7 +77,7 @@ class ConsumerServiceTest {
producer.send(new ProducerRecord<>(topicName, decodedVisit));
producer.flush();
await().atMost(30, TimeUnit.SECONDS)
await().atMost(60, TimeUnit.SECONDS)
.untilAsserted(
() -> verify(consumerService, times(1))
.consume(any(DecodedVisit.class))
......
......@@ -10,6 +10,7 @@ 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.IStatService;
import fr.inria.clea.lsp.utils.TimeUtils;
import org.apache.commons.lang3.RandomUtils;
import org.junit.jupiter.api.BeforeEach;
......@@ -35,6 +36,9 @@ public class SlotGenerationTest {
@Mock
IExposedVisitRepository repository;
@Mock
IStatService statService;
@Captor
ArgumentCaptor<List<ExposedVisitEntity>> exposedVisitEntitiesCaptor;
......@@ -44,7 +48,7 @@ public class SlotGenerationTest {
@BeforeEach
void init() {
config.setDurationUnitInSeconds(Duration.ofMinutes(30).toSeconds());
service = new VisitExpositionAggregatorService(repository, config);
service = new VisitExpositionAggregatorService(repository, config, statService);
}
@Test
......
package fr.gouv.clea.consumer.service.impl;
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.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import fr.gouv.clea.consumer.model.StatLocation;
import fr.gouv.clea.consumer.model.Visit;
import fr.gouv.clea.consumer.repository.IStatLocationRepository;
import fr.gouv.clea.consumer.service.IStatService;
import fr.inria.clea.lsp.utils.TimeUtils;
@SpringBootTest
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StatServiceTest {
private static final UUID _UUID = UUID.randomUUID();
private static final byte[] LOCATION_TEMPORARY_SECRET_KEY = RandomUtils.nextBytes(20);
private static final byte[] ENCRYPTED_LOCATION_CONTACT_MESSAGE = RandomUtils.nextBytes(20);
private static final Instant TODAY_AT_MIDNIGHT = Instant.now().truncatedTo(ChronoUnit.DAYS);
private static final Instant TODAY_AT_8AM = TODAY_AT_MIDNIGHT.plus(8, ChronoUnit.HOURS);
private static final long TODAY_AT_MIDNIGHT_AS_NTP = TimeUtils.ntpTimestampFromInstant(TODAY_AT_MIDNIGHT);
@Autowired
private IStatLocationRepository repository;
@Autowired
private IStatService service;
@AfterEach
void clean() {
repository.deleteAll();
}
@Test
void should_create_a_new_stat_in_DB_when_visit_has_no_existing_context() {
/*
* if:
* periodStartTime = today at 00:00:00
* qrCodeScanTime = today at 08:15:00
* durationUnit = 1800 seconds
*
* then:
* => scanTimeSlot = 8*2 = 16
* => stat duration = periodStartTime + (slot * durationUnit) = today at 08:00:00
*/
Visit visit = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM.plus(15, ChronoUnit.MINUTES))
.venueType(4)
.venueCategory1(1)
.venueCategory2(2)
.build();
service.logStats(visit);
List<StatLocation> stats = repository.findAll();
assertThat(stats.size()).isEqualTo(1L);
StatLocation statLocation = stats.get(0);
assertThat(statLocation.getStatLocationKey().getPeriod()).isEqualTo(TODAY_AT_8AM);
assertThat(statLocation.getStatLocationKey().getVenueType()).isEqualTo(4);
assertThat(statLocation.getStatLocationKey().getVenueCategory1()).isEqualTo(1);
assertThat(statLocation.getStatLocationKey().getVenueCategory2()).isEqualTo(2);
assertThat(statLocation.getBackwardVisits()).isEqualTo(1L);
assertThat(statLocation.getForwardVisits()).isZero();
}
@Test
void should_update_an_existing_stat_in_DB_when_visit_has_existing_context() {
/*
* if:
* periodStartTime = today at 00:00:00
* qrCodeScanTime = today at 08:15:00
* durationUnit = 1800 seconds
*
* then:
* => scanTimeSlot = 8*2 = 16
* => stat duration = periodStartTime + (slot * durationUnit) = today at 08:00:00
*/
Visit visit1 = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM.plus(15, ChronoUnit.MINUTES))
.build();
service.logStats(visit1);
long before = repository.count();
Visit visit2 = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM.plus(15, ChronoUnit.MINUTES))
.build();
service.logStats(visit2);
long after = repository.count();
assertThat(before).isEqualTo(after);
List<StatLocation> stats = repository.findAll();
assertThat(stats.size()).isEqualTo(1L);
StatLocation statLocation = stats.get(0);
assertThat(statLocation.getStatLocationKey().getPeriod()).isEqualTo(TODAY_AT_8AM);
assertThat(statLocation.getStatLocationKey().getVenueType()).isEqualTo(4);
assertThat(statLocation.getStatLocationKey().getVenueCategory1()).isEqualTo(1);
assertThat(statLocation.getStatLocationKey().getVenueCategory2()).isEqualTo(2);
assertThat(statLocation.getBackwardVisits()).isEqualTo(2L);
assertThat(statLocation.getForwardVisits()).isZero();
}
@Test
void should_get_same_stat_period_when_visits_scantimes_are_in_same_stat_slot() {
Visit visit1 = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM)
.build();
Visit visit2 = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM.plus(15, ChronoUnit.MINUTES)) // same stat slot
.build();
Visit visit3 = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM.plus(28, ChronoUnit.MINUTES)) // same stat slot
.build();
service.logStats(visit1);
service.logStats(visit2);
service.logStats(visit3);
assertThat(repository.count()).isEqualTo(1);
}
@Test
void should_get_new_period_when_scantimes_are_in_different_stat_slot() {
Visit visit1 = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM)
.build();
Visit visit2 = defaultVisit().toBuilder()
.qrCodeScanTime(TODAY_AT_8AM.plus(31, ChronoUnit.MINUTES)) // different stat slot
.build();
service.logStats(visit1);
service.logStats(visit2);
assertThat(repository.count()).isEqualTo(2);
}
@Test
void should_get_new_context_when_different_venue_type() {
Visit visit1 = defaultVisit().toBuilder().venueType(1).build(),
visit2 = defaultVisit().toBuilder().venueType(2).build();
service.logStats(visit1);
service.logStats(visit2);
assertThat(repository.count()).isEqualTo(2);
}
@Test
void should_get_new_context_when_different_venue_category1() {
Visit visit1 = defaultVisit().toBuilder().venueCategory1(1).build(),
visit2 = defaultVisit().toBuilder().venueCategory1(2).build();
service.logStats(visit1);
service.logStats(visit2);
assertThat(repository.count()).isEqualTo(2);
}
@Test
void should_get_new_context_when_different_venue_category2() {
Visit visit1 = defaultVisit().toBuilder().venueCategory2(1).build(),
visit2 = defaultVisit().toBuilder().venueCategory2(2).build();
service.logStats(visit1);
service.logStats(visit2);
assertThat(repository.count()).isEqualTo(2);
}
static Visit defaultVisit() {
return Visit.builder()
.version(0)
.type(0)
.staff(true)
.locationTemporaryPublicId(_UUID)
.qrCodeRenewalIntervalExponentCompact(2)
.venueType(4)
.venueCategory1(1)
.venueCategory2(2)
.periodDuration(24)
.compressedPeriodStartTime((int) (TODAY_AT_MIDNIGHT_AS_NTP / 3600))
.qrCodeValidityStartTime(Instant.now())
.qrCodeScanTime(Instant.now())
.locationTemporarySecretKey(LOCATION_TEMPORARY_SECRET_KEY)
.encryptedLocationContactMessage(ENCRYPTED_LOCATION_CONTACT_MESSAGE)
.isBackward(true)
.build();
}
}
\ No newline at end of file
package fr.gouv.clea.consumer.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
......@@ -14,11 +16,13 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.annotation.DirtiesContext;
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.IStatService;
import fr.gouv.clea.consumer.service.IVisitExpositionAggregatorService;
import fr.inria.clea.lsp.utils.TimeUtils;
......@@ -32,6 +36,9 @@ class VisitExpositionAggregatorServiceTest {
@Autowired
private IVisitExpositionAggregatorService service;
@MockBean
private IStatService statService;
private Instant todayAtMidnight;
private Instant todayAt8am;
private UUID uuid;
......@@ -45,6 +52,8 @@ class VisitExpositionAggregatorServiceTest {
uuid = UUID.randomUUID();
locationTemporarySecretKey = RandomUtils.nextBytes(20);
encryptedLocationContactMessage = RandomUtils.nextBytes(20);
doNothing().when(statService).logStats(any(Visit.class));
}
@AfterEach
......@@ -140,7 +149,7 @@ class VisitExpositionAggregatorServiceTest {
.qrCodeValidityStartTime(todayAt8am)
.qrCodeScanTime(todayAtMidnight)
.build();
service.updateExposureCount(visit);
assertThat(repository.count()).isZero();
......
......@@ -4,6 +4,7 @@ clea:
driftBetweenDeviceAndOfficialTimeInSecs: 300
retentionDurationInDays: 14
durationUnitInSeconds: 1800
statSlotDurationInSeconds: 1800
scheduling:
purge:
cron: "0 0 1 * * *"
......
......@@ -114,7 +114,7 @@ class ReportServiceTest {
UUID uuid2 = UUID.randomUUID();
List<Visit> visits = List.of(
newVisit(uuid1, TimeUtils.ntpTimestampFromInstant(now)), // pass
newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.plus(1, ChronoUnit.SECONDS))) /* don't pass */);
newVisit(uuid2, TimeUtils.ntpTimestampFromInstant(now.plus(2, ChronoUnit.SECONDS))) /* don't pass */);
List<DecodedVisit> processed = reportService.report(new ReportRequest(visits, 0L));
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment