From a77874124d9fe917c57a6699388e4816543fd149 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Fri, 3 Jun 2022 09:45:17 +0200 Subject: [PATCH 1/3] use channel ID to compute open height --- .../lndmanagej/service/ChannelService.java | 11 ++--------- .../service/NodeFlowWarningsProvider.java | 1 - .../service/ChannelServiceTest.java | 19 ++----------------- .../service/NodeFlowWarningsProviderTest.java | 7 ++++--- 4 files changed, 8 insertions(+), 30 deletions(-) diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/ChannelService.java b/backend/src/main/java/de/cotto/lndmanagej/service/ChannelService.java index f6d733fc..d18020b1 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/ChannelService.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/ChannelService.java @@ -13,10 +13,7 @@ import de.cotto.lndmanagej.model.ForceClosingChannel; import de.cotto.lndmanagej.model.LocalChannel; import de.cotto.lndmanagej.model.LocalOpenChannel; import de.cotto.lndmanagej.model.Pubkey; -import de.cotto.lndmanagej.model.TransactionHash; import de.cotto.lndmanagej.model.WaitingCloseChannel; -import de.cotto.lndmanagej.transactions.model.Transaction; -import de.cotto.lndmanagej.transactions.service.TransactionService; import org.springframework.stereotype.Component; import java.time.Duration; @@ -36,14 +33,12 @@ public class ChannelService { private static final Duration CACHE_REFRESH = Duration.ofSeconds(30); private final GrpcChannels grpcChannels; - private final TransactionService transactionService; private final LoadingCache> localOpenChannelsCache; private final LoadingCache> closedChannelsCache; private final LoadingCache> forceClosingChannelsCache; private final LoadingCache> waitingCloseChannelsCache; public ChannelService( - TransactionService transactionService, GrpcChannels grpcChannels, GrpcClosedChannels grpcClosedChannels ) { @@ -52,7 +47,6 @@ public class ChannelService { .withRefresh(CACHE_REFRESH) .withExpiry(CACHE_EXPIRY) .build(grpcChannels::getChannels); - this.transactionService = transactionService; closedChannelsCache = new CacheBuilder() .withRefresh(CACHE_REFRESH) .withExpiry(CACHE_EXPIRY) @@ -174,8 +168,7 @@ public class ChannelService { ).parallel().map(Supplier::get).flatMap(Collection::stream); } - public Optional getOpenHeight(Channel channel) { - TransactionHash openTransactionHash = channel.getChannelPoint().getTransactionHash(); - return transactionService.getTransaction(openTransactionHash).map(Transaction::blockHeight); + public int getOpenHeight(Channel channel) { + return channel.getId().getBlockHeight(); } } diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/NodeFlowWarningsProvider.java b/backend/src/main/java/de/cotto/lndmanagej/service/NodeFlowWarningsProvider.java index 9053d7bb..db0de2f2 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/NodeFlowWarningsProvider.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/NodeFlowWarningsProvider.java @@ -63,7 +63,6 @@ public class NodeFlowWarningsProvider implements NodeWarningsProvider { private int getDaysToConsider(Pubkey pubkey) { OptionalInt openHeightOldestOpenChannel = channelService.getOpenChannelsWith(pubkey).stream() .map(channelService::getOpenHeight) - .flatMap(Optional::stream) .mapToInt(h -> h) .max(); if (openHeightOldestOpenChannel.isEmpty()) { diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java index 61cb8e3b..b409f325 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/ChannelServiceTest.java @@ -2,7 +2,6 @@ package de.cotto.lndmanagej.service; import de.cotto.lndmanagej.grpc.GrpcChannels; import de.cotto.lndmanagej.grpc.GrpcClosedChannels; -import de.cotto.lndmanagej.transactions.service.TransactionService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -31,8 +30,6 @@ import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; import static de.cotto.lndmanagej.model.WaitingCloseChannelFixtures.WAITING_CLOSE_CHANNEL; import static de.cotto.lndmanagej.model.WaitingCloseChannelFixtures.WAITING_CLOSE_CHANNEL_2; import static de.cotto.lndmanagej.model.WaitingCloseChannelFixtures.WAITING_CLOSE_CHANNEL_TO_NODE_3; -import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.BLOCK_HEIGHT; -import static de.cotto.lndmanagej.transactions.model.TransactionFixtures.TRANSACTION; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -41,9 +38,6 @@ class ChannelServiceTest { @InjectMocks private ChannelService channelService; - @Mock - private TransactionService transactionService; - @Mock private GrpcChannels grpcChannels; @@ -275,17 +269,8 @@ class ChannelServiceTest { ); } - @Test - void getOpenHeight_unknown_transaction() { - when(transactionService.getTransaction(LOCAL_OPEN_CHANNEL.getChannelPoint().getTransactionHash())) - .thenReturn(Optional.empty()); - assertThat(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL)).isEmpty(); - } - @Test void getOpenHeight() { - when(transactionService.getTransaction(LOCAL_OPEN_CHANNEL.getChannelPoint().getTransactionHash())) - .thenReturn(Optional.of(TRANSACTION)); - assertThat(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL)).contains(BLOCK_HEIGHT); + assertThat(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL)).isEqualTo(712_345); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/NodeFlowWarningsProviderTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/NodeFlowWarningsProviderTest.java index 8328ef1d..9c5133d5 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/NodeFlowWarningsProviderTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/NodeFlowWarningsProviderTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class NodeFlowWarningsProviderTest { private static final int EXPECTED_BLOCKS_PER_DAY = 144; + @InjectMocks private NodeFlowWarningsProvider warningsProvider; @@ -55,7 +56,7 @@ class NodeFlowWarningsProviderTest { @Test void getNodeWarnings_no_flow_old_channel() { when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL)); - when(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL)).thenReturn(Optional.of(BLOCK_HEIGHT)); + when(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL)).thenReturn(BLOCK_HEIGHT); assertThat(warningsProvider.getNodeWarnings(PUBKEY)).containsExactly(new NodeNoFlowWarning(90)); } @@ -105,13 +106,13 @@ class NodeFlowWarningsProviderTest { when(configurationService.getIntegerValue(NODE_FLOW_MAXIMUM_DAYS_TO_CONSIDER)) .thenReturn(Optional.of(120)); when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL_2)); - when(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL_2)).thenReturn(Optional.of(BLOCK_HEIGHT)); + when(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL_2)).thenReturn(BLOCK_HEIGHT); assertThat(warningsProvider.getNodeWarnings(PUBKEY)).containsExactly(new NodeNoFlowWarning(120)); } private void mockOpenChannelWithAgeInBlocks(int channelAgeInBlocks) { when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL)); - when(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL)).thenReturn(Optional.of(BLOCK_HEIGHT)); + when(channelService.getOpenHeight(LOCAL_OPEN_CHANNEL)).thenReturn(BLOCK_HEIGHT); when(ownNodeService.getBlockHeight()).thenReturn(BLOCK_HEIGHT + channelAgeInBlocks); } } From 3b4ac5d34070e82b2423c0c4f95918e2a79444cf Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Fri, 3 Jun 2022 10:24:43 +0200 Subject: [PATCH 2/3] fix syntax --- .../src/main/java/de/cotto/lndmanagej/service/FlowService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/FlowService.java b/backend/src/main/java/de/cotto/lndmanagej/service/FlowService.java index 59f960a9..dd2e09d7 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/FlowService.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/FlowService.java @@ -66,8 +66,7 @@ public class FlowService { getSumOfAmounts(forwardingEventsService.getEventsWithOutgoingChannel(channelId, maxAge)); List incomingEvents = forwardingEventsService.getEventsWithIncomingChannel(channelId, maxAge); Coins forwardedReceived = getSumOfAmounts(incomingEvents); - Coins forwardingFeesReceived = getSumOfFees(incomingEvents - ); + Coins forwardingFeesReceived = getSumOfFees(incomingEvents); Coins rebalanceSent = rebalanceService.getAmountFromChannel(channelId, maxAge); Coins rebalanceReceived = rebalanceService.getAmountToChannel(channelId, maxAge); Coins rebalanceSupportSent = rebalanceService.getSupportAsSourceAmountFromChannel(channelId, maxAge); From fd3ec7353f370c127747e21a7b404c21262fec6f Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Fri, 3 Jun 2022 10:10:14 +0200 Subject: [PATCH 3/3] add basic support for ratings --- .../lndmanagej/service/RatingService.java | 78 ++++++++ .../lndmanagej/service/RatingServiceTest.java | 170 ++++++++++++++++++ .../de/cotto/lndmanagej/model/Rating.java | 31 ++++ .../de/cotto/lndmanagej/model/RatingTest.java | 49 +++++ .../controller/RatingControllerIT.java | 69 +++++++ .../controller/RatingController.java | 40 +++++ .../lndmanagej/controller/dto/RatingDto.java | 12 ++ .../controller/RatingControllerTest.java | 54 ++++++ .../controller/dto/RatingDtoTest.java | 18 ++ 9 files changed, 521 insertions(+) create mode 100644 backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java create mode 100644 backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java create mode 100644 model/src/main/java/de/cotto/lndmanagej/model/Rating.java create mode 100644 model/src/test/java/de/cotto/lndmanagej/model/RatingTest.java create mode 100644 web/src/integrationTest/java/de/cotto/lndmanagej/controller/RatingControllerIT.java create mode 100644 web/src/main/java/de/cotto/lndmanagej/controller/RatingController.java create mode 100644 web/src/main/java/de/cotto/lndmanagej/controller/dto/RatingDto.java create mode 100644 web/src/test/java/de/cotto/lndmanagej/controller/RatingControllerTest.java create mode 100644 web/src/test/java/de/cotto/lndmanagej/controller/dto/RatingDtoTest.java diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java b/backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java new file mode 100644 index 00000000..9a22f62b --- /dev/null +++ b/backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java @@ -0,0 +1,78 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.Channel; +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.FeeReport; +import de.cotto.lndmanagej.model.LocalOpenChannel; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.model.Rating; +import de.cotto.lndmanagej.model.RebalanceReport; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +@Component +public class RatingService { + private static final int EXPECTED_MINUTES_PER_BLOCK = 10; + private static final double MINUTES_PER_DAY = 24 * 60; + private static final int MIN_AGE_FOR_ANALYSIS_DAYS = 30; + private static final Duration DURATION_FOR_ANALYSIS = Duration.ofDays(MIN_AGE_FOR_ANALYSIS_DAYS); + + private final ChannelService channelService; + private final OwnNodeService ownNodeService; + private final FeeService feeService; + private final RebalanceService rebalanceService; + private final PolicyService policyService; + + public RatingService( + ChannelService channelService, + OwnNodeService ownNodeService, + FeeService feeService, + RebalanceService rebalanceService, + PolicyService policyService + ) { + this.channelService = channelService; + this.ownNodeService = ownNodeService; + this.feeService = feeService; + this.rebalanceService = rebalanceService; + this.policyService = policyService; + } + + public Rating getRatingForPeer(Pubkey peer) { + return channelService.getOpenChannelsWith(peer).stream() + .map(Channel::getId).map(this::getRatingForChannel) + .flatMap(Optional::stream) + .reduce(Rating.EMPTY, Rating::add); + } + + public Optional getRatingForChannel(ChannelId channelId) { + int ageInDays = getAgeInDays(channelId); + if (ageInDays < MIN_AGE_FOR_ANALYSIS_DAYS) { + return Optional.of(Rating.EMPTY); + } + LocalOpenChannel localOpenChannel = channelService.getOpenChannel(channelId).orElse(null); + if (localOpenChannel == null) { + return Optional.empty(); + } + FeeReport feeReport = + feeService.getFeeReportForChannel(channelId, DURATION_FOR_ANALYSIS); + RebalanceReport rebalanceReport = + rebalanceService.getReportForChannel(channelId, DURATION_FOR_ANALYSIS); + long feeRate = policyService.getMinimumFeeRateTo(localOpenChannel.getRemotePubkey()).orElse(0L); + long localAvailableMilliSat = localOpenChannel.getBalanceInformation().localAvailable().milliSatoshis(); + + long rating = 1; + rating += feeReport.earned().milliSatoshis(); + rating += feeReport.sourced().milliSatoshis(); + rating += rebalanceReport.supportAsSourceAmount().milliSatoshis() / 10; + rating += rebalanceReport.supportAsTargetAmount().milliSatoshis() / 10; + rating += (long) (1.0 * feeRate * localAvailableMilliSat / 1_000 / 1_000_000 / 10); + return Optional.of(new Rating(rating)); + } + + private int getAgeInDays(ChannelId channelId) { + int channelAgeInBlocks = ownNodeService.getBlockHeight() - channelId.getBlockHeight(); + return (int) Math.ceil(channelAgeInBlocks * 1.0 * EXPECTED_MINUTES_PER_BLOCK / MINUTES_PER_DAY); + } +} diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java new file mode 100644 index 00000000..f1a3bdd9 --- /dev/null +++ b/backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java @@ -0,0 +1,170 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.FeeReport; +import de.cotto.lndmanagej.model.Rating; +import de.cotto.lndmanagej.model.RebalanceReport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +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.Duration; +import java.util.Optional; +import java.util.Set; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; +import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL_2; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RatingServiceTest { + private static final Duration DURATION_FOR_ANALYSIS = Duration.ofDays(30); + + @InjectMocks + private RatingService ratingService; + + @Mock + private ChannelService channelService; + + @Mock + private OwnNodeService ownNodeService; + + @Mock + private FeeService feeService; + + @Mock + private RebalanceService rebalanceService; + + @Mock + private PolicyService policyService; + + @BeforeEach + void setUp() { + int daysAhead = LOCAL_OPEN_CHANNEL_2.getId().getBlockHeight() + 100 * 24 * 60 / 10; + lenient().when(ownNodeService.getBlockHeight()).thenReturn(daysAhead); + lenient().when(feeService.getFeeReportForChannel(any(), any())).thenReturn(FeeReport.EMPTY); + lenient().when(rebalanceService.getReportForChannel(any(), any())).thenReturn(RebalanceReport.EMPTY); + } + + @Test + void getRatingForPeer_no_channel() { + assertThat(ratingService.getRatingForPeer(PUBKEY)).isEqualTo(Rating.EMPTY); + } + + @Test + void getRatingForPeer_channel_too_young() { + when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL)); + assertThat(ratingService.getRatingForPeer(PUBKEY)).isEqualTo(Rating.EMPTY); + } + + @Test + void getRatingForPeer_one_channel() { + when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL)); + when(channelService.getOpenChannel(LOCAL_OPEN_CHANNEL.getId())).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); + assertThat(ratingService.getRatingForPeer(PUBKEY)).isEqualTo(new Rating(1)); + } + + @Test + void getRatingForPeer_two_channels() { + when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL, LOCAL_OPEN_CHANNEL_2)); + when(channelService.getOpenChannel(LOCAL_OPEN_CHANNEL.getId())).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); + when(channelService.getOpenChannel(LOCAL_OPEN_CHANNEL_2.getId())).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL_2)); + assertThat(ratingService.getRatingForPeer(PUBKEY)).isEqualTo(new Rating(2)); + } + + @Test + void getRatingForChannel_not_found() { + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).isEmpty(); + } + + @Test + void getRatingForChannel_channel_too_young() { + when(ownNodeService.getBlockHeight()).thenReturn(CHANNEL_ID.getBlockHeight() + 29 * 24 * 60 / 10); + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(Rating.EMPTY); + } + + @Nested + class GetRatingForChannel { + @BeforeEach + void setUp() { + when(channelService.getOpenChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); + mockOutgoingFeeRate(0); + } + + @Test + void idle() { + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(new Rating(1)); + } + + @Test + void earned() { + Coins feesEarned = Coins.ofMilliSatoshis(123); + when(feeService.getFeeReportForChannel(CHANNEL_ID, DURATION_FOR_ANALYSIS)) + .thenReturn(new FeeReport(feesEarned, Coins.NONE)); + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(new Rating(1 + 123)); + } + + @Test + void sourced() { + Coins feesSourced = Coins.ofMilliSatoshis(123); + when(feeService.getFeeReportForChannel(CHANNEL_ID, DURATION_FOR_ANALYSIS)) + .thenReturn(new FeeReport(Coins.NONE, feesSourced)); + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(new Rating(1 + 123)); + } + + @Test + void rebalance_source_support() { + RebalanceReport rebalanceReport = new RebalanceReport( + Coins.NONE, + Coins.NONE, + Coins.NONE, + Coins.NONE, + Coins.ofMilliSatoshis(1_234), + Coins.NONE + ); + lenient().when(rebalanceService.getReportForChannel(CHANNEL_ID, DURATION_FOR_ANALYSIS)) + .thenReturn(rebalanceReport); + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(new Rating(1 + 123)); + } + + @Test + void rebalance_target_support() { + RebalanceReport rebalanceReport = new RebalanceReport( + Coins.NONE, + Coins.NONE, + Coins.NONE, + Coins.NONE, + Coins.NONE, + Coins.ofMilliSatoshis(1_234) + ); + lenient().when(rebalanceService.getReportForChannel(CHANNEL_ID, DURATION_FOR_ANALYSIS)) + .thenReturn(rebalanceReport); + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(new Rating(1 + 123)); + } + + @Test + void potential_forward_fees() { + int feeRate = 123_456; + mockOutgoingFeeRate(feeRate); + long balanceMilliSat = LOCAL_OPEN_CHANNEL.getBalanceInformation().localAvailable().milliSatoshis(); + long maxEarnings = (long) (1.0 * feeRate * balanceMilliSat / 1_000 / 1_000_000.0); + assumeThat(maxEarnings).isGreaterThanOrEqualTo(10); + assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(new Rating(1 + maxEarnings / 10)); + } + } + + private void mockOutgoingFeeRate(long feeRate) { + lenient().when(policyService.getMinimumFeeRateTo(LOCAL_OPEN_CHANNEL.getRemotePubkey())) + .thenReturn(Optional.of(feeRate)); + } +} diff --git a/model/src/main/java/de/cotto/lndmanagej/model/Rating.java b/model/src/main/java/de/cotto/lndmanagej/model/Rating.java new file mode 100644 index 00000000..94d91576 --- /dev/null +++ b/model/src/main/java/de/cotto/lndmanagej/model/Rating.java @@ -0,0 +1,31 @@ +package de.cotto.lndmanagej.model; + +import java.util.Optional; + +public record Rating(Optional rating) { + public static final Rating EMPTY = new Rating(Optional.empty()); + + public Rating(long rating) { + this(Optional.of(rating)); + } + + public Rating add(Rating other) { + Long thisRating = rating.orElse(null); + if (thisRating == null) { + return other; + } + Long otherRating = other.rating.orElse(null); + if (otherRating == null) { + return this; + } + return new Rating(thisRating + otherRating); + } + + public boolean isEmpty() { + return rating.isEmpty(); + } + + public long getRating() { + return rating.orElse(0L); + } +} diff --git a/model/src/test/java/de/cotto/lndmanagej/model/RatingTest.java b/model/src/test/java/de/cotto/lndmanagej/model/RatingTest.java new file mode 100644 index 00000000..ca03ca6d --- /dev/null +++ b/model/src/test/java/de/cotto/lndmanagej/model/RatingTest.java @@ -0,0 +1,49 @@ +package de.cotto.lndmanagej.model; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class RatingTest { + @Test + void empty() { + assertThat(Rating.EMPTY).isEqualTo(new Rating(Optional.empty())); + } + + @Test + void isEmpty() { + assertThat(Rating.EMPTY.isEmpty()).isTrue(); + assertThat(new Rating(1).isEmpty()).isFalse(); + } + + @Test + void getRating() { + assertThat(Rating.EMPTY.getRating()).isEqualTo(0); + assertThat(new Rating(1).getRating()).isEqualTo(1); + } + + @Test + void add_empty_and_empty() { + assertThat(Rating.EMPTY.add(Rating.EMPTY)).isEqualTo(Rating.EMPTY); + } + + @Test + void add_empty_and_something() { + Rating something = new Rating(1); + assertThat(Rating.EMPTY.add(something)).isEqualTo(something); + } + + @Test + void add_something_and_empty() { + Rating something = new Rating(1); + assertThat(something.add(Rating.EMPTY)).isEqualTo(something); + } + + @Test + void add_something_and_something() { + Rating something = new Rating(1); + assertThat(something.add(something)).isEqualTo(new Rating(2)); + } +} diff --git a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/RatingControllerIT.java b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/RatingControllerIT.java new file mode 100644 index 00000000..f872ba54 --- /dev/null +++ b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/RatingControllerIT.java @@ -0,0 +1,69 @@ +package de.cotto.lndmanagej.controller; + +import de.cotto.lndmanagej.model.ChannelIdResolver; +import de.cotto.lndmanagej.model.Rating; +import de.cotto.lndmanagej.service.RatingService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = RatingController.class) +class RatingControllerIT { + private static final String PREFIX = "/api/"; + private static final String RATING = "/rating"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + @SuppressWarnings("unused") + private ChannelIdResolver channelIdResolver; + + @MockBean + private RatingService ratingService; + + @Test + void getRatingForPeer() throws Exception { + when(ratingService.getRatingForPeer(PUBKEY)).thenReturn(new Rating(123)); + mockMvc.perform(get(PREFIX + "/peer/" + PUBKEY + RATING)) + .andExpect(content().json("{\"rating\": 123, \"message\": \"\"}")); + } + + @Test + void getRatingForPeer_no_rating() throws Exception { + when(ratingService.getRatingForPeer(PUBKEY)).thenReturn(Rating.EMPTY); + mockMvc.perform(get(PREFIX + "/peer/" + PUBKEY + RATING)) + .andExpect(content().json("{\"rating\": 0, \"message\": \"Unable to compute rating\"}")); + } + + @Test + void getRatingForChannel() throws Exception { + when(ratingService.getRatingForChannel(CHANNEL_ID)).thenReturn(Optional.of(new Rating(123))); + mockMvc.perform(get(PREFIX + "/channel/" + CHANNEL_ID + RATING)) + .andExpect(content().json("{\"rating\": 123, \"message\": \"\"}")); + } + + @Test + void getRatingForChannel_not_found() throws Exception { + mockMvc.perform(get(PREFIX + "/channel/" + CHANNEL_ID + RATING)) + .andExpect(status().isNotFound()); + } + + @Test + void getRatingForChannel_empty() throws Exception { + when(ratingService.getRatingForChannel(CHANNEL_ID)).thenReturn(Optional.of(Rating.EMPTY)); + mockMvc.perform(get(PREFIX + "/channel/" + CHANNEL_ID + RATING)) + .andExpect(content().json("{\"rating\": 0, \"message\": \"Unable to compute rating\"}")); + } +} diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/RatingController.java b/web/src/main/java/de/cotto/lndmanagej/controller/RatingController.java new file mode 100644 index 00000000..4fbf757b --- /dev/null +++ b/web/src/main/java/de/cotto/lndmanagej/controller/RatingController.java @@ -0,0 +1,40 @@ +package de.cotto.lndmanagej.controller; + +import com.codahale.metrics.annotation.Timed; +import de.cotto.lndmanagej.controller.dto.ObjectMapperConfiguration; +import de.cotto.lndmanagej.controller.dto.RatingDto; +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.model.Rating; +import de.cotto.lndmanagej.service.RatingService; +import org.springframework.context.annotation.Import; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Import(ObjectMapperConfiguration.class) +@RequestMapping("/api/") +public class RatingController { + private final RatingService ratingService; + + public RatingController(RatingService ratingService) { + this.ratingService = ratingService; + } + + @Timed + @GetMapping("/peer/{peer}/rating") + public RatingDto getRatingForPeer(@PathVariable Pubkey peer) { + Rating rating = ratingService.getRatingForPeer(peer); + return RatingDto.fromModel(rating); + } + + @Timed + @GetMapping("/channel/{channelId}/rating") + public RatingDto getRatingForChannel(ChannelId channelId) throws NotFoundException { + return ratingService.getRatingForChannel(channelId) + .map(RatingDto::fromModel) + .orElseThrow(NotFoundException::new); + } +} diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/dto/RatingDto.java b/web/src/main/java/de/cotto/lndmanagej/controller/dto/RatingDto.java new file mode 100644 index 00000000..239ea568 --- /dev/null +++ b/web/src/main/java/de/cotto/lndmanagej/controller/dto/RatingDto.java @@ -0,0 +1,12 @@ +package de.cotto.lndmanagej.controller.dto; + +import de.cotto.lndmanagej.model.Rating; + +public record RatingDto(long rating, String message) { + public static RatingDto fromModel(Rating rating) { + if (rating.isEmpty()) { + return new RatingDto(rating.getRating(), "Unable to compute rating"); + } + return new RatingDto(rating.getRating(), ""); + } +} diff --git a/web/src/test/java/de/cotto/lndmanagej/controller/RatingControllerTest.java b/web/src/test/java/de/cotto/lndmanagej/controller/RatingControllerTest.java new file mode 100644 index 00000000..5b8c34ab --- /dev/null +++ b/web/src/test/java/de/cotto/lndmanagej/controller/RatingControllerTest.java @@ -0,0 +1,54 @@ +package de.cotto.lndmanagej.controller; + +import de.cotto.lndmanagej.controller.dto.RatingDto; +import de.cotto.lndmanagej.model.Rating; +import de.cotto.lndmanagej.service.RatingService; +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.util.Optional; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RatingControllerTest { + @InjectMocks + private RatingController ratingController; + + @Mock + private RatingService ratingService; + + @Test + void getRatingForPeer_empty() { + when(ratingService.getRatingForPeer(PUBKEY)).thenReturn(Rating.EMPTY); + assertThat(ratingController.getRatingForPeer(PUBKEY)).isEqualTo(RatingDto.fromModel(Rating.EMPTY)); + } + + @Test + void getRatingForPeer() { + Rating rating = new Rating(123); + when(ratingService.getRatingForPeer(PUBKEY)).thenReturn(rating); + assertThat(ratingController.getRatingForPeer(PUBKEY)).isEqualTo(RatingDto.fromModel(rating)); + } + + @Test + void getRatingForChannel_channel_not_found() { + assertThatExceptionOfType(NotFoundException.class).isThrownBy( + () -> ratingController.getRatingForChannel(CHANNEL_ID) + ); + } + + @Test + void getRatingForChannel() throws Exception { + Rating rating = new Rating(1); + when(ratingService.getRatingForChannel(CHANNEL_ID)).thenReturn(Optional.of(rating)); + assertThat(ratingController.getRatingForChannel(CHANNEL_ID)).isEqualTo(RatingDto.fromModel(rating)); + } +} diff --git a/web/src/test/java/de/cotto/lndmanagej/controller/dto/RatingDtoTest.java b/web/src/test/java/de/cotto/lndmanagej/controller/dto/RatingDtoTest.java new file mode 100644 index 00000000..3bc7b1e2 --- /dev/null +++ b/web/src/test/java/de/cotto/lndmanagej/controller/dto/RatingDtoTest.java @@ -0,0 +1,18 @@ +package de.cotto.lndmanagej.controller.dto; + +import de.cotto.lndmanagej.model.Rating; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RatingDtoTest { + @Test + void fromModel_empty() { + assertThat(RatingDto.fromModel(Rating.EMPTY)).isEqualTo(new RatingDto(0, "Unable to compute rating")); + } + + @Test + void fromModel() { + assertThat(RatingDto.fromModel(new Rating(1))).isEqualTo(new RatingDto(1, "")); + } +}