diff --git a/CHANGELOG.md b/CHANGELOG.md index 638d9964..6542cb3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## ⚙️ All important changes to this project are tracked here: -## [Unreleased](https://github.com/jaivalis/release-raccoon/compare/0.3.12...jdevelop) +## [0.4.0](https://github.com/jaivalis/release-raccoon/compare/0.3.14...0.4.0) - 15/10/2024 + +- Feat: Introduce `UserSettings` ## [0.3.14](https://github.com/jaivalis/release-raccoon/compare/0.3.13...0.3.14) - 05/10/2024 diff --git a/docker/db_init/postgres/postgres.sql b/docker/db_init/postgres/postgres.sql index dad0e3d5..959417ae 100644 --- a/docker/db_init/postgres/postgres.sql +++ b/docker/db_init/postgres/postgres.sql @@ -30,8 +30,8 @@ create table Artist ( create_date timestamp(6), name varchar(300) unique, lastfmUri varchar(255), - musicbrainzId varchar(255) unique, - spotifyUri varchar(255) unique, + musicbrainzId varchar(255), + spotifyUri varchar(255), primary key (artistId) ); @@ -87,6 +87,13 @@ create table UserArtist ( primary key (artist_id, user_id) ); +create table UserSettings ( + user_id bigint not null, + emailDisabled boolean, + id bigint generated by default as identity, + primary key (id) +); + create index ArtistSpotifyUri_idx on Artist (spotifyUri); diff --git a/parent/pom.xml b/parent/pom.xml index f7fe70d6..536046f3 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -59,8 +59,8 @@ 3.13.0 - 3.5.0 - 3.5.0 + 3.5.1 + 3.5.1 3.4.2 0.8.12 diff --git a/raccoon-entities/src/main/java/com/raccoon/entity/UserSettings.java b/raccoon-entities/src/main/java/com/raccoon/entity/UserSettings.java new file mode 100644 index 00000000..87e50b35 --- /dev/null +++ b/raccoon-entities/src/main/java/com/raccoon/entity/UserSettings.java @@ -0,0 +1,51 @@ +package com.raccoon.entity; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.NoArgsConstructor; + +import static java.util.Objects.isNull; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "UserSettings") +public class UserSettings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userSettingId; + + @OneToOne + @JoinColumn(name = "user_id", nullable = false) + private RaccoonUser user; + + @Column(nullable = false) + private Integer notifyIntervalDays = 1; + + private Boolean unsubscribed = Boolean.FALSE; + + public void updateFrom(UserSettings other) { + this.notifyIntervalDays = other.getNotifyIntervalDays(); + this.unsubscribed = other.getUnsubscribed(); + } + + public boolean shouldNotify(LocalDate lastNotified) { + if (unsubscribed) { + return false; + } + if (isNull(lastNotified)) { + return true; + } + return LocalDate.now().isAfter(lastNotified.plusDays(notifyIntervalDays)); + } +} \ No newline at end of file diff --git a/raccoon-entities/src/main/java/com/raccoon/entity/repository/UserSettingsRepository.java b/raccoon-entities/src/main/java/com/raccoon/entity/repository/UserSettingsRepository.java new file mode 100644 index 00000000..df151a67 --- /dev/null +++ b/raccoon-entities/src/main/java/com/raccoon/entity/repository/UserSettingsRepository.java @@ -0,0 +1,17 @@ +package com.raccoon.entity.repository; + +import com.raccoon.entity.UserSettings; + +import java.util.Optional; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class UserSettingsRepository implements PanacheRepository { + + public Optional findByUserId(Long userId) { + return find("user.id", userId).stream().findFirst(); + } + +} diff --git a/raccoon-entities/src/test/java/com/raccoon/entity/UserSettingsTest.java b/raccoon-entities/src/test/java/com/raccoon/entity/UserSettingsTest.java new file mode 100644 index 00000000..0e727555 --- /dev/null +++ b/raccoon-entities/src/test/java/com/raccoon/entity/UserSettingsTest.java @@ -0,0 +1,81 @@ +package com.raccoon.entity; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserSettingsTest { + + @Test + void updateFrom_should_updateNotifyIntervalDays() { + UserSettings userSettings = new UserSettings(); + userSettings.setNotifyIntervalDays(5); + UserSettings other = new UserSettings(); + other.setNotifyIntervalDays(10); + other.setUnsubscribed(true); + + userSettings.updateFrom(other); + + assertThat(userSettings.getNotifyIntervalDays()).isEqualTo(10); + assertThat(userSettings.getUnsubscribed()).isTrue(); + } + + @Test + void shouldNotify_should_returnTrue_when_lastNotifiedNull() { + UserSettings userSettings = new UserSettings(); + userSettings.setNotifyIntervalDays(1); + userSettings.setUnsubscribed(false); + + assertThat(userSettings.shouldNotify(null)).isTrue(); + } + + @Test + void shouldNotify_should_returnTrue_when_withinInterval() { + UserSettings userSettings = new UserSettings(); + userSettings.setNotifyIntervalDays(1); + userSettings.setUnsubscribed(false); + LocalDate lastNotified = LocalDate.now().minusDays(2); + + assertThat(userSettings.shouldNotify(lastNotified)).isTrue(); + } + + @Test + void shouldNotify_should_returnTrue_when_outsideInterval() { + UserSettings userSettings = new UserSettings(); + userSettings.setNotifyIntervalDays(1); + userSettings.setUnsubscribed(false); + LocalDate lastNotified = LocalDate.now(); + + assertThat(userSettings.shouldNotify(lastNotified)) + .isFalse(); + } + + @Test + void shouldNotify_should_returnTrue_when_outsideInterval2() { + UserSettings userSettings = new UserSettings(); + userSettings.setNotifyIntervalDays(3); + userSettings.setUnsubscribed(false); + LocalDate lastNotified = LocalDate.now().minusDays(1); + + boolean result = userSettings.shouldNotify(lastNotified); + + assertThat(result).isFalse(); + } + + @Test + void shouldNotify_should_returnFalse_when_unsubscribed() { + UserSettings userSettings = new UserSettings(); + userSettings.setNotifyIntervalDays(1); + userSettings.setUnsubscribed(true); + LocalDate lastNotified = LocalDate.now().minusDays(1); + + boolean result = userSettings.shouldNotify(lastNotified); + + assertThat(result).isFalse(); + } +} \ No newline at end of file diff --git a/raccoon-entities/src/test/java/com/raccoon/entity/repository/UserSettingsRepositoryTest.java b/raccoon-entities/src/test/java/com/raccoon/entity/repository/UserSettingsRepositoryTest.java new file mode 100644 index 00000000..260619f3 --- /dev/null +++ b/raccoon-entities/src/test/java/com/raccoon/entity/repository/UserSettingsRepositoryTest.java @@ -0,0 +1,58 @@ +package com.raccoon.entity.repository; + +import com.raccoon.entity.RaccoonUser; +import com.raccoon.entity.UserSettings; +import com.raccoon.entity.factory.UserFactory; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import io.quarkus.test.TestTransaction; +import io.quarkus.test.common.WithTestResource; +import io.quarkus.test.h2.H2DatabaseTestResource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; + +import static org.assertj.core.api.Assertions.assertThat; + +@QuarkusTest +@WithTestResource(H2DatabaseTestResource.class) +@TestTransaction +class UserSettingsRepositoryTest { + + @Inject + UserRepository userRepository; + @Inject + UserSettingsRepository userSettingsRepository; + @Inject + UserFactory factory; + + @Test + void findByUserId_should_returnUserSettings_when_userExists() { + RaccoonUser user = factory.createUser("email@mail.com"); + UserSettings settings = new UserSettings(); + settings.setUser(user); + userSettingsRepository.persist(settings); + + Optional result = userSettingsRepository.findByUserId(user.id); + + assertThat(result).isPresent(); + assertThat(result.get().getUser()) + .isEqualTo(user); + } + + @Test + void findByUserId_should_returnEmpty_when_userDoesNotExist() { + RaccoonUser user = factory.createUser("email@mail.com"); + userRepository.persist(List.of(user)); + UserSettings settings = new UserSettings(); + settings.setUser(user); + userSettingsRepository.persist(settings); + + Optional result = userSettingsRepository.findByUserId(user.id + 1); + + assertThat(result).isNotPresent(); + } +} \ No newline at end of file diff --git a/release-raccoon-app/src/main/java/com/raccoon/notify/NotifyService.java b/release-raccoon-app/src/main/java/com/raccoon/notify/NotifyService.java index 3ea54900..a0c6c927 100644 --- a/release-raccoon-app/src/main/java/com/raccoon/notify/NotifyService.java +++ b/release-raccoon-app/src/main/java/com/raccoon/notify/NotifyService.java @@ -4,12 +4,16 @@ import com.raccoon.entity.RaccoonUser; import com.raccoon.entity.Release; import com.raccoon.entity.UserArtist; +import com.raccoon.entity.UserSettings; import com.raccoon.entity.repository.ReleaseRepository; import com.raccoon.entity.repository.UserArtistRepository; +import com.raccoon.entity.repository.UserSettingsRepository; import com.raccoon.mail.RaccoonMailer; +import java.time.LocalDate; import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.Set; import io.quarkus.qute.TemplateException; @@ -26,16 +30,19 @@ @ApplicationScoped public class NotifyService { - RaccoonMailer raccoonMailer; - ReleaseRepository releaseRepository; - UserArtistRepository userArtistRepository; + private final RaccoonMailer raccoonMailer; + private final ReleaseRepository releaseRepository; + private final UserArtistRepository userArtistRepository; + private final UserSettingsRepository userSettingsRepository; @Inject public NotifyService(final ReleaseRepository releaseRepository, final UserArtistRepository userArtistRepository, + final UserSettingsRepository userSettingsRepository, final RaccoonMailer raccoonMailer) { this.userArtistRepository = userArtistRepository; this.releaseRepository = releaseRepository; + this.userSettingsRepository = userSettingsRepository; this.raccoonMailer = raccoonMailer; } @@ -55,9 +62,14 @@ public Uni notifyUsers() { .map(entry -> { var user = entry.getKey(); var userArtistList = entry.getValue(); - return notifyUser(user, getLatestReleases(userArtistList), userArtistList); - }) - .toList(); + + if (shouldNotify(user)) { + return notifyUser(user, getLatestReleases(userArtistList), userArtistList); + } else { + log.info("Skipping over user {} because of user settings", user.id); + return Uni.createFrom().voidItem(); + } + }).toList(); if (unis.isEmpty()) { log.info("Nobody to notify"); @@ -71,6 +83,16 @@ public Uni notifyUsers() { .recoverWithUni(failure -> Uni.createFrom().item(false)); } + private boolean shouldNotify(RaccoonUser user) { + Optional settings = userSettingsRepository.findByUserId(user.id); + if (settings.isPresent()) { + LocalDate lastNotified = user.getLastNotified(); + return settings.get().shouldNotify(lastNotified); + } else { + return true; + } + } + /** * Can be broken into `boolean canNotifyUser` & `Uni notifyUser` * @param raccoonUser the raccoonUser to notify @@ -137,13 +159,14 @@ private Uni notifyUser(final RaccoonUser raccoonUser, */ void mailSuccessCallback(RaccoonUser raccoonUser, Collection userArtistList) { log.info("Notified raccoonUser {}", raccoonUser.getId()); + raccoonUser.setLastNotified(LocalDate.now()); userArtistList.forEach(userArtist -> userArtist.setHasNewRelease(false)); userArtistRepository.persist(userArtistList); } void mailFailureCallback(RaccoonUser raccoonUser) { - log.warn("Failed to notify raccoonUser {}", raccoonUser.id); + log.warn("Failed to deliver mail to raccoonUser {}", raccoonUser.id); } } diff --git a/release-raccoon-app/src/main/java/com/raccoon/templatedata/QuteTemplateLoader.java b/release-raccoon-app/src/main/java/com/raccoon/templatedata/QuteTemplateLoader.java index 8bbd1482..1f53defc 100644 --- a/release-raccoon-app/src/main/java/com/raccoon/templatedata/QuteTemplateLoader.java +++ b/release-raccoon-app/src/main/java/com/raccoon/templatedata/QuteTemplateLoader.java @@ -36,6 +36,9 @@ public QuteTemplateLoader(Engine engine) { final String profileContents = new Scanner(requireNonNull(this.getClass().getResourceAsStream("/templates/profile.html")), UTF_8) .useDelimiter(EOF_DELIMITER).next(); + final String userSettingsContents = + new Scanner(requireNonNull(this.getClass().getResourceAsStream("/templates/profile-settings.html")), UTF_8) + .useDelimiter(EOF_DELIMITER).next(); final String digestEmailContents = new Scanner(requireNonNull(this.getClass().getResourceAsStream("/templates/mail-digest.html")), UTF_8) .useDelimiter(EOF_DELIMITER).next(); @@ -46,6 +49,7 @@ public QuteTemplateLoader(Engine engine) { public static final String DIGEST_EMAIL_TEMPLATE_ID = "digest"; public static final String INDEX_TEMPLATE_ID = "index"; public static final String PROFILE_TEMPLATE_ID = "profile"; + public static final String USER_SETTINGS_TEMPLATE_ID = "profile-settings"; public static final String WELCOME_EMAIL_TEMPLATE_ID = "welcome"; @Startup @@ -53,6 +57,7 @@ void onStart() { engine.putTemplate(DIGEST_EMAIL_TEMPLATE_ID, engine.parse(digestEmailContents)); engine.putTemplate(INDEX_TEMPLATE_ID, engine.parse(indexContents)); engine.putTemplate(PROFILE_TEMPLATE_ID, engine.parse(profileContents)); + engine.putTemplate(USER_SETTINGS_TEMPLATE_ID, engine.parse(userSettingsContents)); engine.putTemplate(WELCOME_EMAIL_TEMPLATE_ID, engine.parse(welcomeEmailContents)); } diff --git a/release-raccoon-app/src/main/java/com/raccoon/user/settings/UserSettingsResource.java b/release-raccoon-app/src/main/java/com/raccoon/user/settings/UserSettingsResource.java new file mode 100644 index 00000000..57889c39 --- /dev/null +++ b/release-raccoon-app/src/main/java/com/raccoon/user/settings/UserSettingsResource.java @@ -0,0 +1,61 @@ +package com.raccoon.user.settings; + +import com.raccoon.entity.UserSettings; +import com.raccoon.user.settings.dto.UserSettingsDto; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jboss.resteasy.annotations.cache.NoCache; + +import io.quarkus.oidc.IdToken; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import static com.raccoon.Constants.EMAIL_CLAIM; + +@Path("/me/settings") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class UserSettingsResource { + + private final UserSettingsService userSettingsService; + + @Inject + public UserSettingsResource(final UserSettingsService userSettingsService) { + this.userSettingsService = userSettingsService; + } + + @IdToken + JsonWebToken idToken; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public UserSettingsDto getUserSettings() { + final String email = idToken.getClaim(EMAIL_CLAIM); + return userSettingsService.getUserSettings(email); + } + + @GET + @NoCache + @Produces(MediaType.TEXT_HTML) + @Transactional + public Response registrationCallback() { + final String email = idToken.getClaim(EMAIL_CLAIM); + + return Response.ok(userSettingsService.renderSettingsPage(email)) + .build(); + } + + @POST + public void setUserSettings(UserSettings userSettings) { + final String email = idToken.getClaim(EMAIL_CLAIM); + userSettingsService.addOrUpdateUserSetting(email, userSettings); + } + +} \ No newline at end of file diff --git a/release-raccoon-app/src/main/java/com/raccoon/user/settings/UserSettingsService.java b/release-raccoon-app/src/main/java/com/raccoon/user/settings/UserSettingsService.java new file mode 100644 index 00000000..47a146bb --- /dev/null +++ b/release-raccoon-app/src/main/java/com/raccoon/user/settings/UserSettingsService.java @@ -0,0 +1,70 @@ +package com.raccoon.user.settings; + +import com.raccoon.entity.UserSettings; +import com.raccoon.entity.repository.UserRepository; +import com.raccoon.entity.repository.UserSettingsRepository; +import com.raccoon.user.settings.dto.UserSettingsDto; +import com.raccoon.user.settings.dto.UserSettingsMapper; + +import java.util.Optional; + +import io.quarkus.qute.Engine; +import io.quarkus.qute.Template; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; + +import static com.raccoon.templatedata.QuteTemplateLoader.USER_SETTINGS_TEMPLATE_ID; + +@ApplicationScoped +@Slf4j +public class UserSettingsService { + + UserRepository userRepository; + UserSettingsRepository userSettingsRepository; + UserSettingsMapper mapper; + Template settingsTemplate; + + @Inject + public UserSettingsService(UserRepository userRepository, + UserSettingsRepository userSettingsRepository, + UserSettingsMapper mapper, + Engine engine) { + this.userRepository = userRepository; + this.userSettingsRepository = userSettingsRepository; + this.mapper = mapper; + settingsTemplate = engine.getTemplate(USER_SETTINGS_TEMPLATE_ID); + } + + @Transactional + public UserSettingsDto getUserSettings(String email) { + var user = userRepository.findByEmail(email); + log.info("Retrieving user settings for user {}", user.getId()); + UserSettings settings = userSettingsRepository.findByUserId(user.id) + .orElse(new UserSettings()); + return mapper.toUserSettingsDto(settings); + } + + @Transactional + public void addOrUpdateUserSetting(String email, UserSettings userSettings) { + var user = userRepository.findByEmail(email); + log.info("Updating user settings for user {}", user.id); + Optional existingOpt = + userSettingsRepository.findByUserId(user.id); + if (existingOpt.isPresent()) { + UserSettings existing = existingOpt.get(); + existing.updateFrom(userSettings); + userSettingsRepository.persist(existing); + } else { + userSettings.setUser(user); + userSettingsRepository.persist(userSettings); + } + } + + public String renderSettingsPage(String email) { + UserSettingsDto settingsDto = getUserSettings(email); + return settingsTemplate.data("contents", settingsDto) + .render(); + } +} \ No newline at end of file diff --git a/release-raccoon-app/src/main/java/com/raccoon/user/settings/dto/UserSettingsDto.java b/release-raccoon-app/src/main/java/com/raccoon/user/settings/dto/UserSettingsDto.java new file mode 100644 index 00000000..abb494d1 --- /dev/null +++ b/release-raccoon-app/src/main/java/com/raccoon/user/settings/dto/UserSettingsDto.java @@ -0,0 +1,22 @@ +package com.raccoon.user.settings.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(NON_NULL) +@Data +public class UserSettingsDto { + + private Integer notifyIntervalDays; + private Boolean unsubscribed; + +} diff --git a/release-raccoon-app/src/main/java/com/raccoon/user/settings/dto/UserSettingsMapper.java b/release-raccoon-app/src/main/java/com/raccoon/user/settings/dto/UserSettingsMapper.java new file mode 100644 index 00000000..62a5ae92 --- /dev/null +++ b/release-raccoon-app/src/main/java/com/raccoon/user/settings/dto/UserSettingsMapper.java @@ -0,0 +1,13 @@ +package com.raccoon.user.settings.dto; + +import com.raccoon.entity.UserSettings; + +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; + +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA) +public interface UserSettingsMapper { + + UserSettingsDto toUserSettingsDto(UserSettings release); + +} diff --git a/release-raccoon-app/src/main/resources/META-INF/resources/css/profile.css b/release-raccoon-app/src/main/resources/META-INF/resources/css/profile.css index 8530860c..017e452a 100644 --- a/release-raccoon-app/src/main/resources/META-INF/resources/css/profile.css +++ b/release-raccoon-app/src/main/resources/META-INF/resources/css/profile.css @@ -117,6 +117,11 @@ button { transition: background-color 0.3s ease; } +button:disabled { + background-color: #aaa; + cursor: not-allowed; +} + /* Bootstrap */ /* * Top navigation diff --git a/release-raccoon-app/src/main/resources/application.properties b/release-raccoon-app/src/main/resources/application.properties index 222666c7..9e243635 100644 --- a/release-raccoon-app/src/main/resources/application.properties +++ b/release-raccoon-app/src/main/resources/application.properties @@ -40,7 +40,7 @@ quarkus.datasource.password=${DB_PASSWORD} #quarkus.hibernate-orm.database.generation=drop-and-create # DB Migration quarkus.liquibase.migrate-at-start=true -quarkus.liquibase.change-log=db/changelog/db.changelog-0.0.1.sql +quarkus.liquibase.change-log=db/changelog/db.changelog-0.4.0.sql quarkus.otel.exporter.otlp.enabled=false # OIDC Configuration quarkus.oidc.auth-server-url= diff --git a/release-raccoon-app/src/main/resources/db/changelog/db.changelog-0.4.0.sql b/release-raccoon-app/src/main/resources/db/changelog/db.changelog-0.4.0.sql new file mode 100644 index 00000000..d22a21d7 --- /dev/null +++ b/release-raccoon-app/src/main/resources/db/changelog/db.changelog-0.4.0.sql @@ -0,0 +1,13 @@ +-- Migration script to create the UserSettings table + +CREATE TABLE if not exists UserSettings ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + emailDisabled BOOLEAN, + notifyIntervalDays INT NOT NULL DEFAULT 1, + unsubscribed BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES RaccoonUser(id) + ); + +-- Add an index on user_id for better performance on joins +CREATE INDEX if not exists idx_user_id ON UserSettings(user_id); \ No newline at end of file diff --git a/release-raccoon-app/src/main/resources/templates/profile-settings.html b/release-raccoon-app/src/main/resources/templates/profile-settings.html new file mode 100644 index 00000000..7d1e40a4 --- /dev/null +++ b/release-raccoon-app/src/main/resources/templates/profile-settings.html @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ReleaseRaccoon | Profile + + + + + + + + + +
+
+ Profile Settings + +
+
+

+ Limit notifications to maximum once every + + day(s) +

+ {#if contents.getUnsubscribed()} +

Unsubscribed from all notifications 🥺

+ {/if} + + + +
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/release-raccoon-app/src/main/resources/templates/profile.html b/release-raccoon-app/src/main/resources/templates/profile.html index 811930d1..ff647af4 100644 --- a/release-raccoon-app/src/main/resources/templates/profile.html +++ b/release-raccoon-app/src/main/resources/templates/profile.html @@ -10,14 +10,14 @@ - - - + + + + - + - @@ -442,20 +442,34 @@ {!!} Release Raccoon - diff --git a/release-raccoon-app/src/test/java/com/raccoon/integration/resource/NotifyingResourceIT.java b/release-raccoon-app/src/test/java/com/raccoon/integration/resource/NotifyingResourceIT.java index 47535133..82ea723a 100644 --- a/release-raccoon-app/src/test/java/com/raccoon/integration/resource/NotifyingResourceIT.java +++ b/release-raccoon-app/src/test/java/com/raccoon/integration/resource/NotifyingResourceIT.java @@ -1,6 +1,7 @@ package com.raccoon.integration.resource; import com.raccoon.entity.repository.UserArtistRepository; +import com.raccoon.entity.repository.UserRepository; import com.raccoon.integration.profile.NotifyingResourceDatabaseProfile; import org.junit.jupiter.api.BeforeEach; @@ -22,12 +23,13 @@ import static org.assertj.core.api.Assertions.assertThat; @QuarkusTest -@TestTransaction @TestProfile(value = NotifyingResourceDatabaseProfile.class) class NotifyingResourceIT { @Inject UserArtistRepository userArtistRepository; + @Inject + UserRepository userRepository; @Inject MockMailbox mockMailbox; @@ -60,6 +62,9 @@ void should_notifyUser_and_updateArtistHasNewRelease() { assertThat(uaOptional.get().hasNewRelease) .as("Release should be marked processed") .isFalse(); + assertThat(userRepository.findByIdOptional(300L).get().getLastNotified()) + .isNotNull() + .isToday(); } } diff --git a/release-raccoon-app/src/test/java/com/raccoon/notify/NotifyServiceTest.java b/release-raccoon-app/src/test/java/com/raccoon/notify/NotifyServiceTest.java index 5dcf019c..556fc2c1 100644 --- a/release-raccoon-app/src/test/java/com/raccoon/notify/NotifyServiceTest.java +++ b/release-raccoon-app/src/test/java/com/raccoon/notify/NotifyServiceTest.java @@ -4,8 +4,10 @@ import com.raccoon.entity.RaccoonUser; import com.raccoon.entity.Release; import com.raccoon.entity.UserArtist; +import com.raccoon.entity.UserSettings; import com.raccoon.entity.repository.ReleaseRepository; import com.raccoon.entity.repository.UserArtistRepository; +import com.raccoon.entity.repository.UserSettingsRepository; import com.raccoon.mail.RaccoonMailer; import org.junit.jupiter.api.BeforeEach; @@ -17,8 +19,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.Duration; +import java.time.LocalDate; import java.util.Collection; import java.util.List; +import java.util.Optional; import io.smallrye.mutiny.Uni; @@ -39,6 +43,8 @@ class NotifyServiceTest { @Mock UserArtistRepository mockUserArtistRepository; @Mock + UserSettingsRepository userSettingsRepository; + @Mock RaccoonMailer mockMailer; @BeforeEach @@ -48,6 +54,7 @@ public void setup() { notifyService = new NotifyService( mockReleaseRepository, mockUserArtistRepository, + userSettingsRepository, mockMailer ); } @@ -78,6 +85,28 @@ void notifyUsersSuccess() { assertTrue(success); } + @Test + void notifyUsers_should_notUpdateUser_when_tooSoon() { + RaccoonUser raccoonUser = new RaccoonUser(); + raccoonUser.setId(69L); + raccoonUser.setEmail("email"); + raccoonUser.setLastNotified(LocalDate.now().minusDays(1)); + Artist artist = new Artist(); + UserArtist ua = new UserArtist(); + ua.setUser(raccoonUser); + ua.setArtist(artist); + when(mockUserArtistRepository.getUserArtistsWithNewRelease()).thenReturn(List.of(ua)); + UserSettings mockUserSettings = mock(UserSettings.class); + when(mockUserSettings.shouldNotify(any())).thenReturn(false); + when(userSettingsRepository.findByUserId(raccoonUser.id)).thenReturn( + Optional.of(mockUserSettings) + ); + + notifyService.notifyUsers(); + + verifyNoInteractions(mockMailer); + } + @Test @DisplayName("Mail send failure, no users modified") void notifyUsersMailFailure() { diff --git a/release-raccoon-app/src/test/java/com/raccoon/templatedata/QuteTemplateLoaderTest.java b/release-raccoon-app/src/test/java/com/raccoon/templatedata/QuteTemplateLoaderTest.java index d1861a14..b5c726b2 100644 --- a/release-raccoon-app/src/test/java/com/raccoon/templatedata/QuteTemplateLoaderTest.java +++ b/release-raccoon-app/src/test/java/com/raccoon/templatedata/QuteTemplateLoaderTest.java @@ -33,10 +33,11 @@ public void setup() { void onStartParsesAndLoadsTemplates() { loader.onStart(); - verify(mockEngine, times(4)).parse(anyString()); + verify(mockEngine, times(5)).parse(anyString()); verify(mockEngine, times(1)).putTemplate(eq(QuteTemplateLoader.DIGEST_EMAIL_TEMPLATE_ID), any()); verify(mockEngine, times(1)).putTemplate(eq(QuteTemplateLoader.INDEX_TEMPLATE_ID), any()); verify(mockEngine, times(1)).putTemplate(eq(QuteTemplateLoader.PROFILE_TEMPLATE_ID), any()); + verify(mockEngine, times(1)).putTemplate(eq(QuteTemplateLoader.USER_SETTINGS_TEMPLATE_ID), any()); verify(mockEngine, times(1)).putTemplate(eq(QuteTemplateLoader.WELCOME_EMAIL_TEMPLATE_ID), any()); } } \ No newline at end of file diff --git a/release-raccoon-app/src/test/java/com/raccoon/user/settings/UserSettingsServiceTest.java b/release-raccoon-app/src/test/java/com/raccoon/user/settings/UserSettingsServiceTest.java new file mode 100644 index 00000000..6d7e362c --- /dev/null +++ b/release-raccoon-app/src/test/java/com/raccoon/user/settings/UserSettingsServiceTest.java @@ -0,0 +1,129 @@ +package com.raccoon.user.settings; + +import com.raccoon.entity.RaccoonUser; +import com.raccoon.entity.UserSettings; +import com.raccoon.entity.repository.UserRepository; +import com.raccoon.entity.repository.UserSettingsRepository; +import com.raccoon.user.settings.dto.UserSettingsDto; +import com.raccoon.user.settings.dto.UserSettingsMapper; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; + +import io.quarkus.qute.Engine; +import io.quarkus.qute.Template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class UserSettingsServiceTest { + + @Test + void getUserSettings_should_returnUserSettingsDto_when_userExists() { + UserRepository userRepository = mock(UserRepository.class); + UserSettingsRepository userSettingsRepository = mock(UserSettingsRepository.class); + UserSettingsMapper mapper = mock(UserSettingsMapper.class); + Engine engine = mock(Engine.class); + Template template = mock(Template.class); + + RaccoonUser user = new RaccoonUser(); + user.setId(1L); + user.setEmail("test@example.com"); + + UserSettings settings = new UserSettings(); + UserSettingsDto dto = new UserSettingsDto(); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + when(userSettingsRepository.findByUserId(1L)).thenReturn(Optional.of(settings)); + when(mapper.toUserSettingsDto(settings)).thenReturn(dto); + when(engine.getTemplate(Mockito.anyString())).thenReturn(template); + + UserSettingsService service = new UserSettingsService(userRepository, userSettingsRepository, mapper, engine); + + UserSettingsDto result = service.getUserSettings("test@example.com"); + + assertThat(result).isEqualTo(dto); + } + + @Test + void getUserSettings_should_returnNewUserSettingsDto_when_userSettingsNotFound() { + UserRepository userRepository = mock(UserRepository.class); + UserSettingsRepository userSettingsRepository = mock(UserSettingsRepository.class); + UserSettingsMapper mapper = mock(UserSettingsMapper.class); + Engine engine = mock(Engine.class); + Template template = mock(Template.class); + + RaccoonUser user = new RaccoonUser(); + user.setId(1L); + user.setEmail("test@example.com"); + + UserSettings newSettings = new UserSettings(); + UserSettingsDto dto = new UserSettingsDto(); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + when(userSettingsRepository.findByUserId(1L)).thenReturn(Optional.empty()); + when(mapper.toUserSettingsDto(newSettings)).thenReturn(dto); + when(engine.getTemplate(Mockito.anyString())).thenReturn(template); + + UserSettingsService service = new UserSettingsService(userRepository, userSettingsRepository, mapper, engine); + + UserSettingsDto result = service.getUserSettings("test@example.com"); + + assertThat(result).isEqualTo(dto); + } + + @Test + void addOrUpdateUserSetting_should_updateExistingSettings_when_settingsExist() { + UserRepository userRepository = mock(UserRepository.class); + UserSettingsRepository userSettingsRepository = mock(UserSettingsRepository.class); + UserSettingsMapper mapper = mock(UserSettingsMapper.class); + Engine engine = mock(Engine.class); + + RaccoonUser user = new RaccoonUser(); + user.setId(1L); + user.setEmail("test@example.com"); + + UserSettings existingSettings = mock(UserSettings.class); + UserSettings newSettings = new UserSettings(); + newSettings.setNotifyIntervalDays(5); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + when(userSettingsRepository.findByUserId(1L)).thenReturn(Optional.of(existingSettings)); + + UserSettingsService service = new UserSettingsService(userRepository, userSettingsRepository, mapper, engine); + + service.addOrUpdateUserSetting("test@example.com", newSettings); + + verify(userSettingsRepository).persist(existingSettings); + } + + @Test + void addOrUpdateUserSetting_should_addNewSettings_when_settingsDoNotExist() { + UserRepository userRepository = mock(UserRepository.class); + UserSettingsRepository userSettingsRepository = mock(UserSettingsRepository.class); + UserSettingsMapper mapper = mock(UserSettingsMapper.class); + Engine engine = mock(Engine.class); + + RaccoonUser user = new RaccoonUser(); + user.setId(1L); + user.setEmail("test@example.com"); + + UserSettings newSettings = new UserSettings(); + newSettings.setNotifyIntervalDays(5); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + when(userSettingsRepository.findByUserId(1L)).thenReturn(Optional.empty()); + + UserSettingsService service = new UserSettingsService(userRepository, userSettingsRepository, mapper, engine); + + service.addOrUpdateUserSetting("test@example.com", newSettings); + + assertThat(newSettings.getUser()).isEqualTo(user); + verify(userSettingsRepository).persist(newSettings); + } + +} \ No newline at end of file diff --git a/release-raccoon-app/src/test/resources/import-notifying-resource.sql b/release-raccoon-app/src/test/resources/import-notifying-resource.sql index e8abbf0b..e37bcdc8 100644 --- a/release-raccoon-app/src/test/resources/import-notifying-resource.sql +++ b/release-raccoon-app/src/test/resources/import-notifying-resource.sql @@ -1,9 +1,17 @@ INSERT INTO RaccoonUser - (user_id, email) + (user_id, email, lastNotified) VALUES - (200, 'user200@mail.com'), - (300, 'user300@mail.com'); + (200, 'user200@mail.com', null), + (300, 'user300@mail.com', null), + (400, 'user400@mail.com', CURRENT_DATE - INTERVAL '1' DAY), + (500, 'user500@mail.com', CURRENT_DATE - INTERVAL '1' DAY); +INSERT INTO UserSettings + (user_id, unsubscribed, notifyIntervalDays) +VALUES + (300, 'false', '1'), + (400, 'false', '7'), + (500, 'true', '1'); INSERT INTO Artist (artistId, name) @@ -27,4 +35,6 @@ INSERT INTO UserArtist (user_id, artist_id, hasNewRelease) VALUES (300, 300, true), + (400, 300, true), + (500, 300, true), (200, 300, false);