diff --git a/src/main/java/com/danielvm/destiny2bot/controller/InsertUserController.java b/src/main/java/com/danielvm/destiny2bot/controller/InsertUserController.java new file mode 100644 index 0000000..66a0c47 --- /dev/null +++ b/src/main/java/com/danielvm/destiny2bot/controller/InsertUserController.java @@ -0,0 +1,38 @@ +package com.danielvm.destiny2bot.controller; + +import com.danielvm.destiny2bot.dto.UserChoiceValue; +import com.danielvm.destiny2bot.dto.destiny.RaidStatistics; +import com.danielvm.destiny2bot.service.RaidStatsService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +@RestController +@Slf4j +public class InsertUserController { + + private final RaidStatsService raidStatsService; + + public InsertUserController( + RaidStatsService raidStatsService) { + this.raidStatsService = raidStatsService; + } + + /** + * Inserts a user manually into the Mongo Database + * + * @param userId The userId + * @return the saved entity + */ + @PostMapping("/user") + public Flux insertNewUser(@RequestParam String membershipId, + @RequestParam Integer membershipType, @RequestParam String userId, + @RequestParam Integer userTag) { + UserChoiceValue parsedValues = new UserChoiceValue(membershipId, membershipType, userId, + userTag, null); + return raidStatsService.calculateRaidStats(parsedValues); + } + +} diff --git a/src/main/java/com/danielvm/destiny2bot/dto/destiny/RaidStatistics.java b/src/main/java/com/danielvm/destiny2bot/dto/destiny/RaidStatistics.java index b4f942a..84bb908 100644 --- a/src/main/java/com/danielvm/destiny2bot/dto/destiny/RaidStatistics.java +++ b/src/main/java/com/danielvm/destiny2bot/dto/destiny/RaidStatistics.java @@ -10,9 +10,9 @@ public class RaidStatistics { /** - * The name of the raid + * The _Id of the MongoDB aggregation should be the name of the raid */ - private String raidName; + private String _id; /** * Total amount of kills done for a raid @@ -29,11 +29,6 @@ public class RaidStatistics { */ private Integer fastestTime; - /** - * The average time a player finishes the raid - */ - private Integer averageTime; - /** * The number of completed raids that user has for a specific raid */ @@ -49,16 +44,15 @@ public class RaidStatistics { */ private Integer fullClears; - public RaidStatistics(String raidName) { - this.raidName = raidName; - this.totalKills = 0; - this.totalDeaths = 0; - this.fastestTime = Integer.MAX_VALUE; - this.averageTime = 0; - this.partialClears = 0; - this.totalClears = 0; - this.fullClears = 0; - } + /** + * The total amount of normal-mode raid clears for this raid + */ + private Integer masterClears; + + /** + * The total amount of master-mode raid clears for this raid + */ + private Integer normalClears; /** * This toString uses a StringBuilder to manipulate the actual output to send through Discord diff --git a/src/main/java/com/danielvm/destiny2bot/handler/RaidStatsHandler.java b/src/main/java/com/danielvm/destiny2bot/handler/RaidStatsHandler.java index 558868d..5f651c3 100644 --- a/src/main/java/com/danielvm/destiny2bot/handler/RaidStatsHandler.java +++ b/src/main/java/com/danielvm/destiny2bot/handler/RaidStatsHandler.java @@ -5,6 +5,7 @@ import com.danielvm.destiny2bot.config.DiscordConfiguration; import com.danielvm.destiny2bot.dto.UserChoiceValue; import com.danielvm.destiny2bot.dto.destiny.MemberGroupResponse; +import com.danielvm.destiny2bot.dto.destiny.RaidStatistics; import com.danielvm.destiny2bot.dto.destiny.UserGlobalSearchBody; import com.danielvm.destiny2bot.dto.destiny.UserSearchResult; import com.danielvm.destiny2bot.dto.discord.Choice; @@ -112,6 +113,7 @@ private Mono processRaidsResponseUser(Interaction inter UserChoiceValue parsedData = ((UserChoiceValue) interaction.getData().getOptions().get(0) .getValue()); return raidStatsService.calculateRaidStats(parsedData) + .collectMap(RaidStatistics::get_id) .map(response -> response.entrySet().stream() .map(entry -> EmbeddedField.builder() .name(entry.getKey()) diff --git a/src/main/java/com/danielvm/destiny2bot/service/RaidStatsService.java b/src/main/java/com/danielvm/destiny2bot/service/RaidStatsService.java index 18263ef..77c483e 100644 --- a/src/main/java/com/danielvm/destiny2bot/service/RaidStatsService.java +++ b/src/main/java/com/danielvm/destiny2bot/service/RaidStatsService.java @@ -3,36 +3,113 @@ import com.danielvm.destiny2bot.dto.UserChoiceValue; import com.danielvm.destiny2bot.dto.destiny.RaidStatistics; import com.danielvm.destiny2bot.entity.UserDetails; -import com.danielvm.destiny2bot.entity.UserRaidDetails; +import com.danielvm.destiny2bot.enums.RaidDifficulty; import java.time.Instant; -import java.util.Map; +import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; +import org.springframework.data.mongodb.core.aggregation.Fields; +import org.springframework.data.mongodb.core.aggregation.GroupOperation; +import org.springframework.data.mongodb.core.aggregation.MatchOperation; +import org.springframework.data.mongodb.core.aggregation.UnwindOperation; +import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Service @Slf4j public class RaidStatsService { + private static final List RAIDS_WITH_MASTER_MODE = List.of( + "Vault of Glass", "Vow of the Disciple", "King's Fall", "Root of Nightmares", "Crota's End" + ); + private static final String RAID_NAME = "userRaidDetails.raidName"; + private static final String IS_COMPLETED = "userRaidDetails.isCompleted"; + private static final String RAID_DIFFICULTY = "userRaidDetails.raidDifficulty"; + private static final String TOTAL_KILLS = "userRaidDetails.totalKills"; + private static final String TOTAL_DEATHS = "userRaidDetails.totalDeaths"; + private static final String FROM_BEGINNING = "userRaidDetails.fromBeginning"; + private final UserRaidDetailsService userRaidDetailsService; + private final ReactiveMongoTemplate reactiveMongoTemplate; public RaidStatsService( - UserRaidDetailsService userRaidDetailsService) { + UserRaidDetailsService userRaidDetailsService, + ReactiveMongoTemplate reactiveMongoTemplate) { this.userRaidDetailsService = userRaidDetailsService; + this.reactiveMongoTemplate = reactiveMongoTemplate; } - private static RaidStatistics reduceStatistics(RaidStatistics stats, - UserRaidDetails details) { - stats.setTotalKills(stats.getTotalKills() + details.getTotalKills()); - stats.setTotalDeaths(stats.getTotalDeaths() + details.getTotalDeaths()); - if (details.getIsCompleted()) { - stats.setTotalClears(stats.getTotalClears() + 1); - if (details.getFromBeginning()) { - stats.setFastestTime(Math.min(stats.getFastestTime(), details.getDurationSeconds())); - stats.setFullClears(stats.getFullClears() + 1); - } - } - return stats; + private static Aggregation raidStatisticsAggregationPipeline(String userId) { + MatchOperation userIdMatch = Aggregation.match(Criteria.where("_id").is(userId)); + + UnwindOperation unwindRaids = new UnwindOperation(Fields.field("userRaidDetails")); + + Criteria fastestTimeCriteria = new Criteria(); + fastestTimeCriteria.andOperator( + Criteria.where(FROM_BEGINNING).is(true), + Criteria.where(IS_COMPLETED).is(true) + ); + + Criteria normalModeClearsCriteria = new Criteria(); + normalModeClearsCriteria.andOperator( + Criteria.where(RAID_NAME).in(RAIDS_WITH_MASTER_MODE), + Criteria.where(IS_COMPLETED).is(true), + Criteria.where(RAID_DIFFICULTY).is(RaidDifficulty.NORMAL.name()) + ); + + Criteria masterModeClearsCriteria = new Criteria(); + masterModeClearsCriteria.andOperator( + Criteria.where(RAID_NAME).in(RAIDS_WITH_MASTER_MODE), + Criteria.where(IS_COMPLETED).is(true), + Criteria.where(RAID_DIFFICULTY).is(RaidDifficulty.MASTER.name()) + ); + + Criteria raidClearCriteria = Criteria.where(IS_COMPLETED).is(true); + + Criteria fullRaidClearCriteria = new Criteria(); + fullRaidClearCriteria.andOperator( + Criteria.where(IS_COMPLETED).is(true), + Criteria.where(FROM_BEGINNING).is(true) + ); + + Criteria partialRaidClearCriteria = new Criteria(); + partialRaidClearCriteria.andOperator( + Criteria.where(IS_COMPLETED).is(true), + Criteria.where(FROM_BEGINNING).is(false) + ); + GroupOperation groupByRaidName = Aggregation.group(RAID_NAME) + .sum(TOTAL_KILLS).as("totalKills") + .sum(TOTAL_DEATHS).as("totalDeaths") + .min(ConditionalOperators + .when(fastestTimeCriteria) + .then("$userRaidDetails.durationSeconds") + .otherwise("0")).as("fastestTime") + .sum(ConditionalOperators + .when(raidClearCriteria) + .then(1) + .otherwise(0)).as("totalClears") + .sum(ConditionalOperators + .when(partialRaidClearCriteria) + .then(1) + .otherwise(0)).as("partialClears") + .sum(ConditionalOperators + .when(fullRaidClearCriteria) + .then(1) + .otherwise(0)).as("fullClears") + .sum(ConditionalOperators + .when(normalModeClearsCriteria) + .then(1) + .otherwise(0)).as("normalClears") + .sum(ConditionalOperators + .when(masterModeClearsCriteria) + .then(1) + .otherwise(0)).as("masterClears"); + + return Aggregation.newAggregation(userIdMatch, unwindRaids, groupByRaidName); } /** @@ -42,7 +119,7 @@ private static RaidStatistics reduceStatistics(RaidStatistics stats, * @param parsedData The parsed data needed to retrieve Raid Statistics for a player * @return Map of Raid Statistics grouped by raid name */ - public Mono> calculateRaidStats(UserChoiceValue parsedData) { + public Flux calculateRaidStats(UserChoiceValue parsedData) { Instant now = Instant.now(); // Timestamp for this action String userId = parsedData.getBungieDisplayName() + "#" + parsedData.getBungieDisplayCode(); @@ -54,17 +131,11 @@ public Mono> calculateRaidStats(UserChoiceValue pars .doOnSubscribe(subscription -> log.info("Update action initiated for user [{}]", userId)) .doOnSuccess(userDetails -> log.info("Update action finished for user [{}]", userId)); + Aggregation aggregation = raidStatisticsAggregationPipeline(userId); + return userRaidDetailsService.existsById(userId) .flatMap(exists -> exists ? updateAction : createAction) - .flatMapIterable(UserDetails::getUserRaidDetails) - .groupBy(UserRaidDetails::getRaidName) - .flatMap(group -> group.reduce(new RaidStatistics(group.key()), - RaidStatsService::reduceStatistics)) - .collectMap(RaidStatistics::getRaidName, raidStatistics -> { - if (raidStatistics.getFastestTime() == Integer.MAX_VALUE) { - raidStatistics.setFastestTime(0); - } - return raidStatistics; - }); + .flatMapMany(userDetails -> reactiveMongoTemplate.aggregate(aggregation, + UserDetails.class, RaidStatistics.class)); } } diff --git a/src/main/java/com/danielvm/destiny2bot/service/UserRaidDetailsService.java b/src/main/java/com/danielvm/destiny2bot/service/UserRaidDetailsService.java index 3028c8b..7bb80fa 100644 --- a/src/main/java/com/danielvm/destiny2bot/service/UserRaidDetailsService.java +++ b/src/main/java/com/danielvm/destiny2bot/service/UserRaidDetailsService.java @@ -3,8 +3,10 @@ import com.danielvm.destiny2bot.client.BungieClient; import com.danielvm.destiny2bot.client.BungieClientWrapper; import com.danielvm.destiny2bot.dto.UserChoiceValue; +import com.danielvm.destiny2bot.dto.destiny.ActivitiesResponse; import com.danielvm.destiny2bot.dto.destiny.Activity; import com.danielvm.destiny2bot.dto.destiny.Basic; +import com.danielvm.destiny2bot.dto.destiny.BungieResponse; import com.danielvm.destiny2bot.dto.destiny.ValueEntry; import com.danielvm.destiny2bot.entity.UserDetails; import com.danielvm.destiny2bot.entity.UserRaidDetails; @@ -13,6 +15,7 @@ import com.danielvm.destiny2bot.repository.UserDetailsRepository; import java.time.Instant; import java.util.List; +import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; import lombok.extern.slf4j.Slf4j; @@ -70,8 +73,7 @@ public Mono createUserDetails(Instant creationInstant, UserChoiceVa Integer membershipType = parsedData.getMembershipType(); String userId = USER_ID_FORMAT.formatted(parsedData.getBungieDisplayName(), parsedData.getBungieDisplayCode()); - return defaultBungieClient.getUserCharacters(membershipType, - membershipId) + return defaultBungieClient.getUserCharacters(membershipType, membershipId) .flatMapMany(userCharacter -> Flux.fromIterable( userCharacter.getResponse().getCharacters().getData().keySet())) .flatMap( @@ -103,16 +105,22 @@ public Mono updateUserDetails(Instant updateTimestamp, UserChoiceVa .flatMap(userDetails -> defaultBungieClient.getUserCharacters(membershipType, membershipId) .flatMapIterable(response -> response.getResponse().getCharacters().getData().keySet()) .flatMap(characterId -> getActivitiesUntil(membershipType, membershipId, - characterId, updateTimestamp)) - .flatMap(this::buildRaidDetails) - .flatMap(this::addPGCRDetails) + characterId, userDetails.getLastRequestDateTime()) + .flatMap(this::buildRaidDetails) + .flatMap(this::addPGCRDetails)) .collectList() .flatMap(raidDetails -> { - log.info( - "Adding [{}] new raid encounters for user [{}]. Last time requested set to: [{}]", - raidDetails.size(), userDetails, updateTimestamp); - userDetails.getUserRaidDetails().addAll(raidDetails); userDetails.setLastRequestDateTime(updateTimestamp); + if (CollectionUtils.isEmpty(raidDetails)) { + log.warn( + "No new raid encounters were found for user [{}]. Last time requested set to: [{}]", + userId, updateTimestamp); + } else { + log.info( + "Adding [{}] new raid encounters for user [{}]. Last time requested set to: [{}]", + raidDetails.size(), userDetails, updateTimestamp); + userDetails.getUserRaidDetails().addAll(raidDetails); + } return userDetailsRepository.save(userDetails); })); } @@ -128,13 +136,25 @@ public Mono updateUserDetails(Instant updateTimestamp, UserChoiceVa */ public Flux getActivitiesAll(Integer membershipType, String membershipId, String characterId) { + Predicate> takeUntilCondition = response -> + Objects.isNull(response.getResponse()) || + CollectionUtils.isEmpty(response.getResponse().getActivities()) || + response.getResponse().getActivities().size() < MAX_PAGE_COUNT; + + // Filters if response is null and activities is null + Predicate> filterCondition = response -> + Objects.nonNull(response.getResponse()) && + Objects.nonNull(response.getResponse().getActivities()); + return Flux.range(0, MAX_SANE_AMOUNT_OF_RAID_PAGES) - .flatMapSequential( - page -> defaultBungieClient.getActivityHistory(membershipType, membershipId, - characterId, MAX_PAGE_COUNT, RAID_MODE, page)) - .filter(activities -> CollectionUtils.isNotEmpty(activities.getResponse().getActivities())) - .takeUntil(activities -> activities.getResponse().getActivities().size() < MAX_PAGE_COUNT) - .flatMapIterable(response -> response.getResponse().getActivities()); + .flatMapSequential(pageNumber -> + defaultBungieClient.getActivityHistory(membershipType, membershipId, + characterId, MAX_PAGE_COUNT, RAID_MODE, pageNumber)) + .takeUntil(takeUntilCondition) + .filter(filterCondition) + .flatMapIterable(response -> response.getResponse().getActivities()) + .switchIfEmpty(Flux.empty()); + } /** @@ -145,27 +165,32 @@ public Flux getActivitiesAll(Integer membershipType, String membership * @param membershipType The membershipType of the user * @param membershipId The membershipId of the user * @param characterId The characterId of the user - * @param until The instant that serves as the stopping point of the search + * @param until The last timestamp when this user was searched for * @return Flux of all existing activities */ public Flux getActivitiesUntil(Integer membershipType, String membershipId, String characterId, Instant until) { - var retrieveActivities = Flux.range(0, MAX_SANE_AMOUNT_OF_RAID_PAGES) - .flatMapSequential( - page -> defaultBungieClient.getActivityHistory(membershipType, membershipId, - characterId, MAX_PAGE_COUNT, RAID_MODE, page)); - - Predicate isNewActivity = activity -> activity.getPeriod().isAfter(until); - Predicate> isLastPage = activities -> - activities.stream().filter(isNewActivity).count() < MAX_PAGE_COUNT; - Predicate> existNewActivities = activities -> activities.stream() - .anyMatch(activity -> activity.getPeriod().isAfter(until)); - return retrieveActivities - .filter(response -> CollectionUtils.isNotEmpty(response.getResponse().getActivities())) - .takeUntil(response -> existNewActivities.test(response.getResponse().getActivities()) && - isLastPage.test(response.getResponse().getActivities())) + return Flux.range(0, MAX_SANE_AMOUNT_OF_RAID_PAGES) + .flatMapSequential(pageNumber -> + defaultBungieClient.getActivityHistory(membershipType, membershipId, characterId, + MAX_PAGE_COUNT, RAID_MODE, pageNumber)) + .filter(response -> + Objects.nonNull(response.getResponse()) && + Objects.nonNull(response.getResponse().getActivities())) + .takeUntil(response -> { + boolean nullResponse = Objects.isNull(response.getResponse()); + boolean emptyActivities = CollectionUtils.isEmpty(response.getResponse().getActivities()); + + List activities = response.getResponse().getActivities(); + long newRaidCount = activities.stream() + .filter(activity -> activity.getPeriod().isAfter(until)) + .count(); + boolean noMoreNewDataAvailable = newRaidCount < MAX_PAGE_COUNT; + return nullResponse || emptyActivities || noMoreNewDataAvailable; + }) .flatMapIterable(response -> response.getResponse().getActivities()) - .filter(isNewActivity); + .filter(activity -> activity.getPeriod().isAfter(until)) + .switchIfEmpty(Flux.empty()); } private Mono addPGCRDetails(UserRaidDetails userRaidDetails) { diff --git a/src/test/java/com/danielvm/destiny2bot/service/UserRaidDetailsServiceTest.java b/src/test/java/com/danielvm/destiny2bot/service/UserRaidDetailsServiceTest.java index a6f0493..1e66f04 100644 --- a/src/test/java/com/danielvm/destiny2bot/service/UserRaidDetailsServiceTest.java +++ b/src/test/java/com/danielvm/destiny2bot/service/UserRaidDetailsServiceTest.java @@ -24,6 +24,7 @@ import com.danielvm.destiny2bot.dto.destiny.manifest.DisplayProperties; import com.danielvm.destiny2bot.dto.destiny.manifest.ResponseFields; import com.danielvm.destiny2bot.entity.PGCRDetails; +import com.danielvm.destiny2bot.entity.UserDetails; import com.danielvm.destiny2bot.entity.UserRaidDetails; import com.danielvm.destiny2bot.enums.ManifestEntity; import com.danielvm.destiny2bot.enums.RaidDifficulty; @@ -60,7 +61,146 @@ public class UserRaidDetailsServiceTest { UserRaidDetailsService sut; @Test - @DisplayName("Retrieve Raid Stats makes a create action for a new user") + @DisplayName("Get all activities works successfully for characters with only one page") + public void getUserActivitiesSuccess() { + // given: membershipType, membershipId, and characterId + Integer membershipType = 3; + String membershipId = "1389012"; + String characterId = "1"; + + Map entryMap = Map.of( + "deaths", new ValueEntry("deaths", new Basic(0.0, "0")), + "killsDeathsAssists", new ValueEntry("killsDeathsAssists", new Basic(134.0, "134")), + "activityDurationSeconds", + new ValueEntry("activityDurationSeconds", new Basic(3600.0, "3600")), + "completed", new ValueEntry("completed", new Basic(1.0, "1.0")), + "kills", new ValueEntry("kills", new Basic(134.0, "134.0")) + ); + ArrayList activities = new ArrayList<>(); + for (int i = 0; i < 249; i++) { + ActivityDetails details = new ActivityDetails(1L, (long) i, 4); + Instant completionDate = LocalDate.now().minusDays(i).atStartOfDay() + .toInstant(ZoneOffset.UTC); + activities.add(new Activity(completionDate, details, entryMap)); + } + var activitiesResponse = new ActivitiesResponse(activities); + var bungieResponse = new BungieResponse<>(activitiesResponse); + when(bungieClient.getActivityHistory(membershipType, membershipId, characterId, 250, 4, 0)) + .thenReturn(Mono.just(bungieResponse)); + + // when: getActivitiesAll is called + var response = sut.getActivitiesAll(membershipType, membershipId, characterId); + + // then: the response that is returned has the correct size + StepVerifier.create(response.collectList()) + .assertNext(list -> { + assertThat(list).isNotNull(); + assertThat(list.size()).isEqualTo(249); + }).verifyComplete(); + + // and: the bungie API calls is only one + verify(bungieClient, times(1)).getActivityHistory(membershipType, membershipId, characterId, + 250, 4, 0); + verify(bungieClient, times(0)).getActivityHistory(membershipType, membershipId, characterId, + 250, 4, 1); + } + + @Test + @DisplayName("Get all activities works successfully for characters with more than one page") + public void getUserActivitiesMoreThanOnePageSuccess() { + // given: membershipType, membershipId, and characterId + Integer membershipType = 3; + String membershipId = "1389012"; + String characterId = "1"; + + Map entryMap = Map.of( + "deaths", new ValueEntry("deaths", new Basic(0.0, "0")), + "killsDeathsAssists", new ValueEntry("killsDeathsAssists", new Basic(134.0, "134")), + "activityDurationSeconds", + new ValueEntry("activityDurationSeconds", new Basic(3600.0, "3600")), + "completed", new ValueEntry("completed", new Basic(1.0, "1.0")), + "kills", new ValueEntry("kills", new Basic(134.0, "134.0")) + ); + ArrayList firstActivities = new ArrayList<>(); + for (int i = 0; i < 250; i++) { + ActivityDetails details = new ActivityDetails(1L, (long) i, 4); + Instant completionDate = LocalDate.now().minusDays(i).atStartOfDay() + .toInstant(ZoneOffset.UTC); + firstActivities.add(new Activity(completionDate, details, entryMap)); + } + var firstPageActivities = new ActivitiesResponse(firstActivities); + var firstPage = new BungieResponse<>(firstPageActivities); + when(bungieClient.getActivityHistory(membershipType, membershipId, characterId, 250, 4, 0)) + .thenReturn(Mono.just(firstPage)); + + ArrayList secondActivities = new ArrayList<>(); + for (int i = 0; i < 249; i++) { + ActivityDetails details = new ActivityDetails(1L, (long) i, 4); + Instant completionDate = LocalDate.now().minusDays(i).atStartOfDay() + .toInstant(ZoneOffset.UTC); + secondActivities.add(new Activity(completionDate, details, entryMap)); + } + var secondPageActivities = new ActivitiesResponse(secondActivities); + var secondPage = new BungieResponse<>(secondPageActivities); + when(bungieClient.getActivityHistory(membershipType, membershipId, characterId, 250, 4, 1)) + .thenReturn(Mono.just(secondPage)); + + // when: getActivitiesAll is called + var response = sut.getActivitiesAll(membershipType, membershipId, characterId); + + // then: the response that is returned has the correct size + StepVerifier.create(response.collectList()) + .assertNext(list -> { + assertThat(list).isNotNull(); + assertThat(list.size()).isEqualTo(499); + }).verifyComplete(); + + // and: there's two Bungie API calls + verify(bungieClient, times(1)).getActivityHistory(membershipType, membershipId, characterId, + 250, 4, 0); + verify(bungieClient, times(1)).getActivityHistory(membershipType, membershipId, characterId, + 250, 4, 1); + verify(bungieClient, times(0)).getActivityHistory(membershipType, membershipId, characterId, + 250, 4, 2); + } + + @Test + @DisplayName("Get all activities returns an empty list for characters with no raids") + public void getUserActivitiesEmptyActivityHistorySuccess() { + // given: membershipType, membershipId, and characterId + Integer membershipType = 3; + String membershipId = "1389012"; + String characterId = "1"; + + Map entryMap = Map.of( + "deaths", new ValueEntry("deaths", new Basic(0.0, "0")), + "killsDeathsAssists", new ValueEntry("killsDeathsAssists", new Basic(134.0, "134")), + "activityDurationSeconds", + new ValueEntry("activityDurationSeconds", new Basic(3600.0, "3600")), + "completed", new ValueEntry("completed", new Basic(1.0, "1.0")), + "kills", new ValueEntry("kills", new Basic(134.0, "134.0")) + ); + + var bungieResponse = new BungieResponse(null); + when(bungieClient.getActivityHistory(membershipType, membershipId, characterId, 250, 4, 0)) + .thenReturn(Mono.just(bungieResponse)); + + // when: getActivitiesAll is called + var response = StepVerifier.create( + sut.getActivitiesAll(membershipType, membershipId, characterId)); + + // then: the response emits no values + response.expectNextCount(0).verifyComplete(); + + // and: the bungie API calls is only one + verify(bungieClient, times(1)).getActivityHistory(membershipType, membershipId, characterId, + 250, 4, 0); + verify(bungieClient, times(0)).getActivityHistory(membershipType, membershipId, characterId, + 250, 4, 1); + } + + @Test + @DisplayName("create action for a new user is successful") public void createUserRaidDetailsIsSuccessful() { // given: parsed data for user details String username = "Deaht"; @@ -279,6 +419,7 @@ public void getCharacterActivitiesUntilVariousPages() { Integer membershipType = 3; String membershipId = "SomeId"; String characterId = "1893"; + // last request for this user was made 300 days ago Instant timestamp = LocalDate.now().minusDays(300) .atStartOfDay().toInstant(ZoneOffset.UTC); @@ -314,20 +455,167 @@ public void getCharacterActivitiesUntilVariousPages() { when(bungieClient.getActivityHistory(membershipType, membershipId, characterId, 250, 4, 1)) .thenReturn(Mono.just(secondPage)); - // when: getting activities and the most recent call was 25 days ago + // when: getting activities and the most recent call was 300 days ago var response = sut.getActivitiesUntil(membershipType, membershipId, characterId, timestamp); - // then: we only get the 25 most-recent activities + // then: we only get the 300 most-recent activities from two pages StepVerifier.create(response.collectList()) .assertNext(list -> { assertThat(list).isNotNull(); assertThat(list.size()).isEqualTo(300); + assertThat( + list.stream().allMatch(activity -> activity.getPeriod().isAfter(timestamp))).isTrue(); }).verifyComplete(); - // and: verify that the activities endpoint was called for both pages + // and: verify that the activities endpoint was called for both pages and a third page was not called verify(bungieClient, times(1)) .getActivityHistory(membershipType, membershipId, characterId, 250, 4, 0); verify(bungieClient, times(1)) .getActivityHistory(membershipType, membershipId, characterId, 250, 4, 1); + verify(bungieClient, times(0)) + .getActivityHistory(membershipType, membershipId, characterId, 250, 4, 2); + } + + @Test + @DisplayName("Get activities until works when there's no new activities for a user") + public void getCharacter() { + // given: membershipType, membershipId, characterId, and a timestamp + Integer membershipType = 3; + String membershipId = "SomeId"; + String characterId = "1893"; + Instant timestamp = LocalDate.now().atStartOfDay().toInstant(ZoneOffset.UTC); + + Map entryMap = Map.of( + "deaths", new ValueEntry("deaths", new Basic(0.0, "0")), + "killsDeathsAssists", new ValueEntry("killsDeathsAssists", new Basic(134.0, "134")), + "activityDurationSeconds", + new ValueEntry("activityDurationSeconds", new Basic(3600.0, "3600")), + "completed", new ValueEntry("completed", new Basic(1.0, "1.0")), + "kills", new ValueEntry("kills", new Basic(134.0, "134.0")) + ); + ArrayList firstPageActivities = new ArrayList<>(); + for (int i = 0; i < 250; i++) { + ActivityDetails details = new ActivityDetails(1L, (long) i, 4); + Instant completionDate = LocalDate.now().minusDays(i).atStartOfDay() + .toInstant(ZoneOffset.UTC); + firstPageActivities.add(new Activity(completionDate, details, entryMap)); + } + var activitiesResponse = new ActivitiesResponse(firstPageActivities); + var firstPage = new BungieResponse<>(activitiesResponse); + when(bungieClient.getActivityHistory(membershipType, membershipId, characterId, 250, 4, 0)) + .thenReturn(Mono.just(firstPage)); + + // when: getting activities and the most recent call was 300 days ago + var response = sut.getActivitiesUntil(membershipType, membershipId, characterId, timestamp); + + // then: we get zero new raid entries + StepVerifier.create(response.collectList()) + .assertNext(list -> { + assertThat(list).isNotNull(); + assertThat(list.size()).isEqualTo(0); + }).verifyComplete(); + + // and: verify that the activities endpoint was called for only the first page + verify(bungieClient, times(1)) + .getActivityHistory(membershipType, membershipId, characterId, 250, 4, 0); + verify(bungieClient, times(0)) + .getActivityHistory(membershipType, membershipId, characterId, 250, 4, 1); + } + + @Test + @DisplayName("Update action is successful for updating user raids with latest information") + public void updateActionSuccessful() { + // given: the timestamp this action was triggered and parsed user data + String username = "Deaht"; + Integer userTag = 8080; + String membershipId = "12345"; + Integer membershipType = 3; + String clanName = "Legends of Honor"; + String userId = username + "#" + userTag; + var updatedInstant = Instant.now(); + var parsedData = new UserChoiceValue(membershipId, membershipType, username, userTag, clanName); + + var threeDaysAgo = LocalDate.now().minusDays(3).atStartOfDay().toInstant(ZoneOffset.UTC); + List existingData = new ArrayList<>(); + existingData.add(new UserRaidDetails("Last Wish", null, null, true, 1000, 3, 34.0, + 3600, true, 1L)); + existingData.add( + new UserRaidDetails("King's Fall", RaidDifficulty.NORMAL, null, true, 1000, 3, 34.0, + 3600, true, 2L)); + existingData.add( + new UserRaidDetails("King's Fall", RaidDifficulty.MASTER, null, true, 1000, 3, 34.0, + 3600, true, 3L)); + existingData.add( + new UserRaidDetails("Vow of the Disciple", RaidDifficulty.NORMAL, null, false, 0, 0, 31.1, + 333, true, 4L)); + + // Last time this user was searched for was three days ago + UserDetails existingUser = new UserDetails(userId, clanName, threeDaysAgo, existingData); + when(userDetailsRepository.findById(userId)).thenReturn(Mono.just(existingUser)); + + Map data = Map.of("1", new UserCharacter()); + Characters characters = new Characters(data); + CharactersResponse charactersResponse = new CharactersResponse(characters); + when(bungieClient.getUserCharacters(membershipType, membershipId)) + .thenReturn(Mono.just(new BungieResponse<>(charactersResponse))); + + Map entryMap = Map.of( + "deaths", new ValueEntry("deaths", new Basic(0.0, "0")), + "killsDeathsAssists", new ValueEntry("killsDeathsAssists", new Basic(134.0, "134")), + "activityDurationSeconds", + new ValueEntry("activityDurationSeconds", new Basic(3600.0, "3600")), + "completed", new ValueEntry("completed", new Basic(1.0, "1.0")), + "kills", new ValueEntry("kills", new Basic(134.0, "134.0")) + ); + Instant today = LocalDate.now().atStartOfDay().toInstant(ZoneOffset.UTC); + List activities = List.of( + new Activity(today, new ActivityDetails(1L, 5L, 4), entryMap), + new Activity(threeDaysAgo, new ActivityDetails(1L, 1L, 4), Collections.emptyMap()), + new Activity(threeDaysAgo, new ActivityDetails(1L, 2L, 4), Collections.emptyMap()), + new Activity(threeDaysAgo, new ActivityDetails(2L, 3L, 4), Collections.emptyMap()), + new Activity(threeDaysAgo, new ActivityDetails(2L, 4L, 4), Collections.emptyMap()) + ); + ActivitiesResponse activitiesResponse = new ActivitiesResponse(activities); + when(bungieClient.getActivityHistory(membershipType, membershipId, "1", 250, 4, 0)) + .thenReturn(Mono.just(new BungieResponse<>(activitiesResponse))); + + ResponseFields firstActivity = ResponseFields.builder() + .displayProperties(new DisplayProperties("", "Last Wish: 50", "", "", false)) + .build(); + when(bungieClientWrapper.getManifestEntity(ManifestEntity.ACTIVITY_DEFINITION, 1L)) + .thenReturn(Mono.just(new BungieResponse<>(firstActivity))); + + PGCRDetails pgcr = new PGCRDetails(null, true, null); + when(postGameCarnageService.retrievePGCR(any(Long.class))) + .thenReturn(Mono.just((pgcr))); + + when(userDetailsRepository.save(assertArg(ud -> { + UserRaidDetails lastWish = ud.getUserRaidDetails().stream() + .filter(raid -> raid.getInstanceId() == 5L) + .findFirst().orElse(null); + + assertThat(ud.getUserIdentifier()).isEqualTo(userId); + assertThat(ud.getLastRequestDateTime()).isEqualTo(updatedInstant); + assertThat(ud.getDestinyClanName()).isEqualTo(clanName); + assertThat(ud.getUserRaidDetails().size()).isEqualTo(5); + assertThat(lastWish.getRaidName()).isEqualTo("Last Wish"); + assertThat(lastWish.getRaidDifficulty()).isNull(); + assertThat(lastWish.getIsCompleted()).isTrue(); + assertThat(lastWish.getTotalKills()).isEqualTo(134); + assertThat(lastWish.getTotalDeaths()).isEqualTo(0); + assertThat(lastWish.getDurationSeconds()).isEqualTo(3600); + }))).thenReturn(Mono.empty()); + + // when: create user details is called + var response = StepVerifier.create(sut.updateUserDetails(updatedInstant, parsedData)); + + // then: the saved entity is saved correctly + response.verifyComplete(); + + // and: the correct interactions occur + verify(bungieClient, times(1)).getUserCharacters(membershipType, membershipId); + verify(bungieClientWrapper, times(1)).getManifestEntity(any(), anyLong()); + verify(postGameCarnageService, atMost(1)).retrievePGCR(anyLong()); + verify(userDetailsRepository, times(1)).save(any()); } }