diff --git a/application/build.gradle b/application/build.gradle index 22fc4983..478e35ce 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -3,9 +3,11 @@ plugins { } dependencies { - implementation project(':web') + runtimeOnly project(':web') + runtimeOnly project(':statistics') runtimeOnly 'org.postgresql:postgresql' testRuntimeOnly 'com.h2database:h2' + testImplementation project(':backend') testImplementation project(':grpc-adapter') } diff --git a/application/src/integrationTest/java/de/cotto/lndmanagej/ApplicationContextIT.java b/application/src/integrationTest/java/de/cotto/lndmanagej/ApplicationContextIT.java index b55b9d86..a887d4c3 100644 --- a/application/src/integrationTest/java/de/cotto/lndmanagej/ApplicationContextIT.java +++ b/application/src/integrationTest/java/de/cotto/lndmanagej/ApplicationContextIT.java @@ -1,8 +1,8 @@ package de.cotto.lndmanagej; -import de.cotto.lndmanagej.controller.LegacyController; import de.cotto.lndmanagej.grpc.GrpcRouterService; import de.cotto.lndmanagej.grpc.GrpcService; +import de.cotto.lndmanagej.service.ChannelService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -13,7 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest class ApplicationContextIT { @Autowired - private LegacyController legacyController; + private ChannelService channelService; @MockBean @SuppressWarnings("unused") @@ -25,6 +25,6 @@ class ApplicationContextIT { @Test void contextStarts() { - assertThat(legacyController).isNotNull(); + assertThat(channelService).isNotNull(); } } \ No newline at end of file diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java index 108a7866..97c35c73 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java @@ -36,10 +36,10 @@ class BalanceServiceTest { @Test void getBalanceInformation_for_pubkey() { BalanceInformation expected = new BalanceInformation( - Coins.ofSatoshis(3_000), - Coins.ofSatoshis(300), - Coins.ofSatoshis(346), - Coins.ofSatoshis(30) + Coins.ofSatoshis(4_000), + Coins.ofSatoshis(400), + Coins.ofSatoshis(446), + Coins.ofSatoshis(40) ); when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL, LOCAL_OPEN_CHANNEL_2)); when(grpcChannels.getChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL_MORE_BALANCE)); diff --git a/model/src/testFixtures/java/de/cotto/lndmanagej/model/LocalOpenChannelFixtures.java b/model/src/testFixtures/java/de/cotto/lndmanagej/model/LocalOpenChannelFixtures.java index 3f0b129d..8a7b2f98 100644 --- a/model/src/testFixtures/java/de/cotto/lndmanagej/model/LocalOpenChannelFixtures.java +++ b/model/src/testFixtures/java/de/cotto/lndmanagej/model/LocalOpenChannelFixtures.java @@ -67,7 +67,7 @@ public class LocalOpenChannelFixtures { CAPACITY, PUBKEY, PUBKEY_2, - BALANCE_INFORMATION, + BALANCE_INFORMATION_2, REMOTE, false ); diff --git a/settings.gradle b/settings.gradle index 15c4c228..3eac1e72 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,5 +6,6 @@ include 'grpc-adapter' include 'grpc-client' include 'metrics' include 'model' +include 'statistics' include 'transactions' include 'web' diff --git a/statistics/build.gradle b/statistics/build.gradle new file mode 100644 index 00000000..c46b0d8e --- /dev/null +++ b/statistics/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'lnd-manageJ.java-library-conventions' +} + +dependencies { + implementation project(':backend') + implementation project(':model') + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + testFixturesApi testFixtures(project(':model')) +} \ No newline at end of file diff --git a/statistics/src/main/java/de/cotto/lndmanagej/statistics/Statistics.java b/statistics/src/main/java/de/cotto/lndmanagej/statistics/Statistics.java new file mode 100644 index 00000000..d6778d0b --- /dev/null +++ b/statistics/src/main/java/de/cotto/lndmanagej/statistics/Statistics.java @@ -0,0 +1,13 @@ +package de.cotto.lndmanagej.statistics; + +import de.cotto.lndmanagej.model.BalanceInformation; +import de.cotto.lndmanagej.model.ChannelId; + +import java.time.LocalDateTime; + +public record Statistics( + LocalDateTime timestamp, + ChannelId channelId, + BalanceInformation balanceInformation +) { +} diff --git a/statistics/src/main/java/de/cotto/lndmanagej/statistics/StatisticsDao.java b/statistics/src/main/java/de/cotto/lndmanagej/statistics/StatisticsDao.java new file mode 100644 index 00000000..b9d202d2 --- /dev/null +++ b/statistics/src/main/java/de/cotto/lndmanagej/statistics/StatisticsDao.java @@ -0,0 +1,5 @@ +package de.cotto.lndmanagej.statistics; + +public interface StatisticsDao { + void saveStatistics(Statistics statistics); +} diff --git a/statistics/src/main/java/de/cotto/lndmanagej/statistics/StatisticsService.java b/statistics/src/main/java/de/cotto/lndmanagej/statistics/StatisticsService.java new file mode 100644 index 00000000..af61b7fc --- /dev/null +++ b/statistics/src/main/java/de/cotto/lndmanagej/statistics/StatisticsService.java @@ -0,0 +1,34 @@ +package de.cotto.lndmanagej.statistics; + +import de.cotto.lndmanagej.model.BalanceInformation; +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.LocalOpenChannel; +import de.cotto.lndmanagej.service.ChannelService; +import org.springframework.scheduling.annotation.Scheduled; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.concurrent.TimeUnit; + +public class StatisticsService { + private final ChannelService channelService; + private final StatisticsDao statisticsDao; + + public StatisticsService(ChannelService channelService, StatisticsDao statisticsDao) { + this.channelService = channelService; + this.statisticsDao = statisticsDao; + } + + @Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES) + public void storeBalances() { + LocalDateTime timestamp = LocalDateTime.now(ZoneOffset.UTC); + channelService.getOpenChannels().forEach(channel -> storeBalance(channel, timestamp)); + } + + private void storeBalance(LocalOpenChannel channel, LocalDateTime timestamp) { + ChannelId channelId = channel.getId(); + BalanceInformation balanceInformation = channel.getBalanceInformation(); + Statistics statistics = new Statistics(timestamp, channelId, balanceInformation); + statisticsDao.saveStatistics(statistics); + } +} diff --git a/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsDaoImpl.java b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsDaoImpl.java new file mode 100644 index 00000000..510700f8 --- /dev/null +++ b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsDaoImpl.java @@ -0,0 +1,22 @@ +package de.cotto.lndmanagej.statistics.persistence; + +import de.cotto.lndmanagej.statistics.Statistics; +import de.cotto.lndmanagej.statistics.StatisticsDao; +import org.springframework.stereotype.Component; + +import javax.transaction.Transactional; + +@Component +@Transactional +public class StatisticsDaoImpl implements StatisticsDao { + private final StatisticsRepository statisticsRepository; + + public StatisticsDaoImpl(StatisticsRepository statisticsRepository) { + this.statisticsRepository = statisticsRepository; + } + + @Override + public void saveStatistics(Statistics statistics) { + statisticsRepository.save(StatisticsJpaDto.fromModel(statistics)); + } +} diff --git a/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsId.java b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsId.java new file mode 100644 index 00000000..1fa8c207 --- /dev/null +++ b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsId.java @@ -0,0 +1,6 @@ +package de.cotto.lndmanagej.statistics.persistence; + +import java.io.Serializable; + +public record StatisticsId(Long channelId, long timestamp) implements Serializable { +} \ No newline at end of file diff --git a/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsJpaDto.java b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsJpaDto.java new file mode 100644 index 00000000..66a6ac7c --- /dev/null +++ b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsJpaDto.java @@ -0,0 +1,74 @@ +package de.cotto.lndmanagej.statistics.persistence; + +import com.google.common.annotations.VisibleForTesting; +import de.cotto.lndmanagej.statistics.Statistics; + +import javax.annotation.Nullable; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.Table; +import java.time.ZoneOffset; +import java.util.Objects; + +@Entity +@IdClass(StatisticsId.class) +@Table(name = "statistics") +class StatisticsJpaDto { + @Id + private long timestamp; + + @Id + @Nullable + private Long channelId; + + private long localBalance; + private long localReserved; + private long remoteBalance; + private long remoteReserved; + + StatisticsJpaDto() { + // for JPA + } + + protected static StatisticsJpaDto fromModel(Statistics statistics) { + StatisticsJpaDto dto = new StatisticsJpaDto(); + dto.timestamp = statistics.timestamp().toEpochSecond(ZoneOffset.UTC); + dto.channelId = statistics.channelId().getShortChannelId(); + dto.localBalance = statistics.balanceInformation().localBalance().satoshis(); + dto.localReserved = statistics.balanceInformation().localReserve().satoshis(); + dto.remoteBalance = statistics.balanceInformation().remoteBalance().satoshis(); + dto.remoteReserved = statistics.balanceInformation().remoteReserve().satoshis(); + return dto; + } + + @VisibleForTesting + protected long getTimestamp() { + return timestamp; + } + + @VisibleForTesting + protected long getChannelId() { + return Objects.requireNonNull(channelId); + } + + @VisibleForTesting + protected long getLocalBalance() { + return localBalance; + } + + @VisibleForTesting + protected long getLocalReserved() { + return localReserved; + } + + @VisibleForTesting + protected long getRemoteBalance() { + return remoteBalance; + } + + @VisibleForTesting + protected long getRemoteReserved() { + return remoteReserved; + } +} diff --git a/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsRepository.java b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsRepository.java new file mode 100644 index 00000000..4b305e73 --- /dev/null +++ b/statistics/src/main/java/de/cotto/lndmanagej/statistics/persistence/StatisticsRepository.java @@ -0,0 +1,6 @@ +package de.cotto.lndmanagej.statistics.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StatisticsRepository extends JpaRepository { +} diff --git a/statistics/src/test/java/de/cotto/lndmanagej/statistics/StatisticsServiceTest.java b/statistics/src/test/java/de/cotto/lndmanagej/statistics/StatisticsServiceTest.java new file mode 100644 index 00000000..855b71be --- /dev/null +++ b/statistics/src/test/java/de/cotto/lndmanagej/statistics/StatisticsServiceTest.java @@ -0,0 +1,56 @@ +package de.cotto.lndmanagej.statistics; + +import de.cotto.lndmanagej.model.LocalOpenChannel; +import de.cotto.lndmanagej.service.ChannelService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; + +import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; +import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_MORE_BALANCE_2; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StatisticsServiceTest { + @InjectMocks + private StatisticsService statisticsService; + + @Mock + private ChannelService channelService; + + @Mock + private StatisticsDao statisticsDao; + + @Test + void storeBalances() { + when(channelService.getOpenChannels()).thenReturn(Set.of( + LOCAL_OPEN_CHANNEL, + LOCAL_OPEN_CHANNEL_MORE_BALANCE_2 + )); + statisticsService.storeBalances(); + verify(statisticsDao).saveStatistics(argThat(withBalanceInformation(LOCAL_OPEN_CHANNEL_MORE_BALANCE_2))); + verify(statisticsDao).saveStatistics(argThat(withChannelId(LOCAL_OPEN_CHANNEL_MORE_BALANCE_2))); + verify(statisticsDao).saveStatistics(argThat(withBalanceInformation(LOCAL_OPEN_CHANNEL))); + verify(statisticsDao).saveStatistics(argThat(withChannelId(LOCAL_OPEN_CHANNEL))); + verify(statisticsDao, times(2)).saveStatistics(any()); + verifyNoMoreInteractions(statisticsDao); + } + + private ArgumentMatcher withChannelId(LocalOpenChannel channel) { + return statistics -> statistics.channelId().equals(channel.getId()); + } + + private ArgumentMatcher withBalanceInformation(LocalOpenChannel channel) { + return statistics -> statistics.balanceInformation().equals(channel.getBalanceInformation()); + } +} \ No newline at end of file diff --git a/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsDaoImplTest.java b/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsDaoImplTest.java new file mode 100644 index 00000000..90b70646 --- /dev/null +++ b/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsDaoImplTest.java @@ -0,0 +1,47 @@ +package de.cotto.lndmanagej.statistics.persistence; + +import de.cotto.lndmanagej.statistics.StatisticsFixtures; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.ZoneOffset; + +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class StatisticsDaoImplTest { + + @InjectMocks + private StatisticsDaoImpl statisticsDaoImpl; + + @Mock + private StatisticsRepository statisticsRepository; + + @Test + void saveStatistics() { + statisticsDaoImpl.saveStatistics(StatisticsFixtures.STATISTICS); + verify(statisticsRepository).save(argThat(jpaDto -> + jpaDto.getTimestamp() == StatisticsFixtures.TIMESTAMP.toEpochSecond(ZoneOffset.UTC) + )); + verify(statisticsRepository).save(argThat(jpaDto -> + jpaDto.getChannelId() == CHANNEL_ID.getShortChannelId())); + verify(statisticsRepository).save(argThat(jpaDto -> + jpaDto.getLocalBalance() == BALANCE_INFORMATION.localBalance().satoshis() + )); + verify(statisticsRepository).save(argThat(jpaDto -> + jpaDto.getLocalReserved() == BALANCE_INFORMATION.localReserve().satoshis() + )); + verify(statisticsRepository).save(argThat(jpaDto -> + jpaDto.getRemoteBalance() == BALANCE_INFORMATION.remoteBalance().satoshis() + )); + verify(statisticsRepository).save(argThat(jpaDto -> + jpaDto.getRemoteReserved() == BALANCE_INFORMATION.remoteReserve().satoshis() + )); + } +} \ No newline at end of file diff --git a/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsIdTest.java b/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsIdTest.java new file mode 100644 index 00000000..57afc5ee --- /dev/null +++ b/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsIdTest.java @@ -0,0 +1,23 @@ +package de.cotto.lndmanagej.statistics.persistence; + +import org.junit.jupiter.api.Test; + +import java.time.ZoneOffset; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.statistics.StatisticsFixtures.TIMESTAMP; +import static org.assertj.core.api.Assertions.assertThat; + +class StatisticsIdTest { + + private static final long CHANNEL_ID_LONG = CHANNEL_ID.getShortChannelId(); + private static final long TIMESTAMP_LONG = TIMESTAMP.toEpochSecond(ZoneOffset.UTC); + + private final StatisticsId statisticsId = new StatisticsId(CHANNEL_ID_LONG, TIMESTAMP_LONG); + + @Test + void test_types() { + assertThat(statisticsId.channelId()).isEqualTo(CHANNEL_ID_LONG); + assertThat(statisticsId.timestamp()).isEqualTo(TIMESTAMP_LONG); + } +} \ No newline at end of file diff --git a/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsJpaDtoTest.java b/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsJpaDtoTest.java new file mode 100644 index 00000000..35d498e0 --- /dev/null +++ b/statistics/src/test/java/de/cotto/lndmanagej/statistics/persistence/StatisticsJpaDtoTest.java @@ -0,0 +1,25 @@ +package de.cotto.lndmanagej.statistics.persistence; + +import org.junit.jupiter.api.Test; + +import java.time.ZoneOffset; + +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.statistics.StatisticsFixtures.STATISTICS; +import static de.cotto.lndmanagej.statistics.StatisticsFixtures.TIMESTAMP; +import static org.assertj.core.api.Assertions.assertThat; + +class StatisticsJpaDtoTest { + @Test + @SuppressWarnings("PMD.JUnitTestContainsTooManyAsserts") + void fromModel() { + StatisticsJpaDto jpaDto = StatisticsJpaDto.fromModel(STATISTICS); + assertThat(jpaDto.getTimestamp()).isEqualTo(TIMESTAMP.toEpochSecond(ZoneOffset.UTC)); + assertThat(jpaDto.getChannelId()).isEqualTo(CHANNEL_ID.getShortChannelId()); + assertThat(jpaDto.getLocalBalance()).isEqualTo(BALANCE_INFORMATION.localBalance().satoshis()); + assertThat(jpaDto.getLocalReserved()).isEqualTo(BALANCE_INFORMATION.localReserve().satoshis()); + assertThat(jpaDto.getRemoteBalance()).isEqualTo(BALANCE_INFORMATION.remoteBalance().satoshis()); + assertThat(jpaDto.getRemoteReserved()).isEqualTo(BALANCE_INFORMATION.remoteReserve().satoshis()); + } +} \ No newline at end of file diff --git a/statistics/src/testFixtures/java/de/cotto/lndmanagej/statistics/StatisticsFixtures.java b/statistics/src/testFixtures/java/de/cotto/lndmanagej/statistics/StatisticsFixtures.java new file mode 100644 index 00000000..eaeb1328 --- /dev/null +++ b/statistics/src/testFixtures/java/de/cotto/lndmanagej/statistics/StatisticsFixtures.java @@ -0,0 +1,12 @@ +package de.cotto.lndmanagej.statistics; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; + +public class StatisticsFixtures { + public static final LocalDateTime TIMESTAMP = LocalDateTime.now(ZoneOffset.UTC); + public static final Statistics STATISTICS = new Statistics(TIMESTAMP, CHANNEL_ID, BALANCE_INFORMATION); +}