This commit is contained in:
2
.idea/QuarkusDeploymentProjectService.xml
generated
2
.idea/QuarkusDeploymentProjectService.xml
generated
@@ -6,7 +6,7 @@
|
||||
<entry key="peakup-backend">
|
||||
<value>
|
||||
<ModuleState>
|
||||
<option name="hash" value="746995196" />
|
||||
<option name="hash" value="-1586394178" />
|
||||
<option name="version" value="1" />
|
||||
</ModuleState>
|
||||
</value>
|
||||
|
||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -8,6 +8,8 @@
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="Quarkus" url="file://$PROJECT_DIR$" />
|
||||
<file type="Qute" url="file://$PROJECT_DIR$/peakup-backend" />
|
||||
<file type="Qute" url="file://$PROJECT_DIR$/peakup-tools" />
|
||||
</component>
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-scheduler</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-mailer</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-arc</artifactId>
|
||||
|
||||
@@ -24,6 +24,9 @@ public class Athlete extends AbstractTimestampedEntity {
|
||||
@Column(nullable = false)
|
||||
private String login;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Double weight;
|
||||
|
||||
|
||||
@@ -427,6 +427,8 @@ public interface IntervalsClient {
|
||||
// Power field names and misc
|
||||
public @Nullable Object power_field_names;
|
||||
|
||||
public @Nullable Object analysis_issues;
|
||||
|
||||
public Map<String, Object> unknownFields = new LinkedHashMap<>();
|
||||
|
||||
@SuppressWarnings({"NullAway.Init", "unused"})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.peakup.goal.dao;
|
||||
|
||||
import com.peakup.common.dao.AbstractDAO;
|
||||
import com.peakup.goal.model.GoalAchievementNotification;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
@ApplicationScoped
|
||||
public class GoalAchievementNotificationDAO extends AbstractDAO<GoalAchievementNotification> {
|
||||
|
||||
@Override
|
||||
protected Class<GoalAchievementNotification> getEntityClass() {
|
||||
return GoalAchievementNotification.class;
|
||||
}
|
||||
|
||||
public boolean existsByKey(final long athleteId, final long goalId, final int year, final int periodIndex) {
|
||||
return em.createQuery(
|
||||
"SELECT COUNT(n) FROM GoalAchievementNotification n " +
|
||||
"WHERE n.athleteId = :athleteId AND n.goalId = :goalId " +
|
||||
"AND n.year = :year AND n.periodIndex = :periodIndex", Long.class)
|
||||
.setParameter("athleteId", athleteId)
|
||||
.setParameter("goalId", goalId)
|
||||
.setParameter("year", year)
|
||||
.setParameter("periodIndex", periodIndex)
|
||||
.getSingleResult() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.peakup.goal.model;
|
||||
|
||||
import com.peakup.common.model.AbstractTimestampedEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Entity
|
||||
@Table(name = "goal_achievement_notification", uniqueConstraints = @UniqueConstraint(
|
||||
columnNames = {"athlete_id", "goal_id", "year", "period_index"}))
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class GoalAchievementNotification extends AbstractTimestampedEntity {
|
||||
|
||||
@Column(name = "athlete_id", nullable = false)
|
||||
private Long athleteId;
|
||||
|
||||
@Column(name = "goal_id", nullable = false)
|
||||
private Long goalId;
|
||||
|
||||
@Column(name = "year", nullable = false)
|
||||
private int year;
|
||||
|
||||
@Column(name = "period_index", nullable = false)
|
||||
private int periodIndex;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
public GoalAchievementNotification() {
|
||||
// hibernate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.peakup.goal.service;
|
||||
|
||||
import com.peakup.athlete.model.Athlete;
|
||||
import com.peakup.athlete.service.AthleteService;
|
||||
import com.peakup.goal.dao.GoalAchievementNotificationDAO;
|
||||
import com.peakup.goal.model.Goal;
|
||||
import com.peakup.goal.model.GoalAchievementNotification;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal;
|
||||
import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import io.quarkus.mailer.Mail;
|
||||
import io.quarkus.mailer.Mailer;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.WeekFields;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@lombok.extern.slf4j.Slf4j
|
||||
@ApplicationScoped
|
||||
public class GoalNotificationService {
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalService goalService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalAchievementNotificationDAO goalAchievementNotificationDAO;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
AthleteService athleteService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
Mailer mailer;
|
||||
|
||||
@Transactional
|
||||
public void checkAndNotify(final long athleteId) {
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final int currentYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int currentCalendarWeek = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
|
||||
final List<Goal> goals = goalService.getByAthleteId(athleteId);
|
||||
for (final Goal goal : goals) {
|
||||
if (goal.getPeriod() != GoalPeriodEnum.WEEKLY && goal.getPeriod() != GoalPeriodEnum.MONTHLY) {
|
||||
continue;
|
||||
}
|
||||
checkGoal(goal, athleteId, today, currentYear, currentCalendarWeek);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkGoal(
|
||||
final Goal goal,
|
||||
final long athleteId,
|
||||
final LocalDate today,
|
||||
final int currentYear,
|
||||
final int currentCalendarWeek) {
|
||||
final int notificationYear;
|
||||
final int periodIndex;
|
||||
|
||||
if (goal.getPeriod() == GoalPeriodEnum.WEEKLY) {
|
||||
notificationYear = currentYear;
|
||||
periodIndex = currentCalendarWeek;
|
||||
} else {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = today.getMonthValue();
|
||||
}
|
||||
|
||||
final Long goalId = goal.getId();
|
||||
if (goalId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (goalAchievementNotificationDAO.existsByKey(athleteId, goalId, notificationYear, periodIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final GoalMetricPeriodTotal total = goalMetricPeriodTotalService.getAggregate(
|
||||
athleteId, goal.getMetric(), goal.getPeriod(), currentYear, currentCalendarWeek);
|
||||
|
||||
if (total == null || total.getAggregatedValue() < goal.getTargetValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Athlete athlete = athleteService.getById(athleteId);
|
||||
sendGoalAchievementEmail(athlete, goal, total.getAggregatedValue());
|
||||
|
||||
final GoalAchievementNotification notification = new GoalAchievementNotification();
|
||||
notification.setAthleteId(athleteId);
|
||||
notification.setGoalId(goalId);
|
||||
notification.setYear(notificationYear);
|
||||
notification.setPeriodIndex(periodIndex);
|
||||
goalAchievementNotificationDAO.insert(notification);
|
||||
|
||||
log.info("Goal achievement notification sent for athleteId={} goalId={} period={} year={} periodIndex={}",
|
||||
athleteId, goalId, goal.getPeriod(), notificationYear, periodIndex);
|
||||
}
|
||||
|
||||
private void sendGoalAchievementEmail(final Athlete athlete, final Goal goal, final double achievedValue) {
|
||||
final String periodLabel = goal.getPeriod() == GoalPeriodEnum.WEEKLY ? "weekly" : "monthly";
|
||||
final String subject = String.format("peakUp – %s %s goal achieved!", periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT));
|
||||
final String body = String.format(
|
||||
"Hi %s,%n%nCongratulations! You have achieved your %s %s goal.%n%nTarget: %.2f%nAchieved: %.2f%n%nKeep it up!%n%npeakUp",
|
||||
athlete.getFirstName(), periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT),
|
||||
goal.getTargetValue(), achievedValue);
|
||||
|
||||
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.peakup.connector.intervals.service.IntervalsConnectorService;
|
||||
import com.peakup.connector.strava.model.StravaConnector;
|
||||
import com.peakup.connector.strava.service.StravaConnectorService;
|
||||
import com.peakup.goal.service.GoalMetricPeriodTotalService;
|
||||
import com.peakup.goal.service.GoalNotificationService;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import io.smallrye.common.annotation.RunOnVirtualThread;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -38,6 +39,9 @@ public class ActivityImportScheduler {
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalNotificationService goalNotificationService;
|
||||
|
||||
@Scheduled(every = "20m")
|
||||
@RunOnVirtualThread
|
||||
@@ -86,6 +90,9 @@ public class ActivityImportScheduler {
|
||||
log.error("Failed to recalculate goal metric period totals for athleteId {}.", connector.getAthleteId(), e);
|
||||
}
|
||||
}
|
||||
for (final IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) {
|
||||
goalNotificationService.checkAndNotify(connector.getAthleteId());
|
||||
}
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
|
||||
@@ -19,3 +19,12 @@ strava-api/mp-rest/url=https://www.strava.com
|
||||
# intervals.icu
|
||||
intervals-api/mp-rest/url=https://intervals.icu
|
||||
peakup.stream-import.delay-ms=1000
|
||||
# mailer
|
||||
quarkus.mailer.from=peakup@peakup.app
|
||||
quarkus.mailer.host=${SMTP_HOST:smtp.gmail.com}
|
||||
quarkus.mailer.port=${SMTP_PORT:587}
|
||||
quarkus.mailer.start-tls=REQUIRED
|
||||
quarkus.mailer.username=ingo.schnabel.im@gmail.com
|
||||
quarkus.mailer.password=wghcnkgcltgydurh
|
||||
# To use DB-stored credentials instead of env vars:
|
||||
quarkus.mailer.mock=false
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE athlete ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '';
|
||||
UPDATE athlete SET email = 'ingo.schnabel@gmail.com' WHERE id = 90;
|
||||
UPDATE athlete SET email = 'mirja.schnabel@gmail.com' WHERE id = 91;
|
||||
ALTER TABLE athlete ALTER COLUMN email DROP DEFAULT;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE goal_achievement_notification (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
athlete_id BIGINT NOT NULL,
|
||||
goal_id BIGINT NOT NULL REFERENCES goal(id),
|
||||
year INTEGER NOT NULL,
|
||||
period_index INTEGER NOT NULL,
|
||||
CONSTRAINT uq_goal_achievement_notification UNIQUE (athlete_id, goal_id, year, period_index)
|
||||
);
|
||||
@@ -67,8 +67,8 @@ public abstract class AbstractPeakUpTest {
|
||||
|
||||
// Athlete 90 — Ingo
|
||||
stmt.execute("""
|
||||
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
|
||||
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 93.0, now(), now())
|
||||
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
|
||||
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 'ingo.schnabel@gmail.com', 93.0, now(), now())
|
||||
""");
|
||||
stmt.execute("""
|
||||
INSERT INTO strava_connector
|
||||
@@ -89,8 +89,8 @@ public abstract class AbstractPeakUpTest {
|
||||
|
||||
// Athlete 91 — Mirja
|
||||
stmt.execute("""
|
||||
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
|
||||
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 50.0, now(), now())
|
||||
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
|
||||
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 'mirja.schnabel@gmail.com', 50.0, now(), now())
|
||||
""");
|
||||
stmt.execute("""
|
||||
INSERT INTO strava_connector
|
||||
|
||||
@@ -23,7 +23,8 @@ public class PostgresTestProfile implements QuarkusTestProfile {
|
||||
return Map.of(
|
||||
"quarkus.scheduler.enabled", "false",
|
||||
"quarkus.http.test-port", "0",
|
||||
"peakup.stream-import.delay-ms", "0"
|
||||
"peakup.stream-import.delay-ms", "0",
|
||||
"quarkus.mailer.mock", "true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.peakup;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class RealMailTestProfile extends PostgresTestProfile {
|
||||
|
||||
@Override
|
||||
public Map<String, String> getConfigOverrides() {
|
||||
final Map<String, String> overrides = new HashMap<>(super.getConfigOverrides());
|
||||
overrides.put("quarkus.mailer.mock", "false");
|
||||
return overrides;
|
||||
}
|
||||
}
|
||||
@@ -210,8 +210,8 @@ public class TestDataInitializer {
|
||||
|
||||
// Athlete 90 — Ingo
|
||||
s.execute("""
|
||||
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
|
||||
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 93.0, now(), now())
|
||||
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
|
||||
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 'ingo.schnabel@gmail.com', 93.0, now(), now())
|
||||
""");
|
||||
s.execute("""
|
||||
INSERT INTO strava_connector
|
||||
@@ -232,8 +232,8 @@ public class TestDataInitializer {
|
||||
|
||||
// Athlete 91 — Mirja
|
||||
s.execute("""
|
||||
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
|
||||
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 50.0, now(), now())
|
||||
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
|
||||
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 'mirja.schnabel@gmail.com', 50.0, now(), now())
|
||||
""");
|
||||
s.execute("""
|
||||
INSERT INTO strava_connector
|
||||
|
||||
@@ -105,6 +105,7 @@ class IntervalsActivityMappingTest extends AbstractPeakUpTest {
|
||||
"hrrStartTimeSec", "hrrEndTimeSec", "hrrAverageWatts"));
|
||||
m.put("gear", List.of("gearId", "gearName", "gearDistanceM", "gearPrimary"));
|
||||
m.put("icu_achievements", List.of()); // stored in ActivityAchievement, not on entity
|
||||
m.put("analysis_issues", List.of()); // informational field, not mapped to entity
|
||||
// New fields — unit-suffix renames
|
||||
// icu_ignore_time → icuIgnoreTime (Boolean flag, direct camelCase match)
|
||||
m.put("icu_recording_time", List.of("icuRecordingTimeS"));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.peakup.goal;
|
||||
|
||||
import com.peakup.AbstractPeakUpTest;
|
||||
import com.peakup.RealMailTestProfile;
|
||||
import com.peakup.goal.model.GoalMetricEnum;
|
||||
import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import com.peakup.goal.service.GoalMetricPeriodTotalService;
|
||||
import com.peakup.goal.service.GoalNotificationService;
|
||||
import io.agroal.api.AgroalDataSource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.TestProfile;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.WeekFields;
|
||||
|
||||
@QuarkusTest
|
||||
@TestProfile(RealMailTestProfile.class)
|
||||
class GoalNotificationRealMailTest extends AbstractPeakUpTest {
|
||||
|
||||
private static final long ATHLETE_ID = 90L;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalNotificationService goalNotificationService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
AgroalDataSource dataSource;
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsRealEmail() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(1500.0);
|
||||
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
}
|
||||
|
||||
private void insertGoal(final GoalPeriodEnum period, final GoalMetricEnum metric, final double targetValue)
|
||||
throws Exception {
|
||||
try (final Connection conn = dataSource.getConnection();
|
||||
final Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
INSERT INTO goal (athlete_id, period, metric, target_value, created_at, modified_at)
|
||||
VALUES (%d, %d, %d, %f, now(), now())
|
||||
""".formatted(ATHLETE_ID, period.getKey(), metric.getKey(), targetValue));
|
||||
}
|
||||
}
|
||||
|
||||
private void insertActivity(final double distanceM) throws Exception {
|
||||
try (final Connection conn = dataSource.getConnection();
|
||||
final Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
INSERT INTO intervals_activity
|
||||
(athlete_id, start_time, distance_m, training_load, total_elevation_gain_m, moving_time_s, created_at, modified_at)
|
||||
VALUES (%d, now(), %f, 0.0, 0.0, 0, now(), now())
|
||||
""".formatted(ATHLETE_ID, distanceM));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.peakup.goal;
|
||||
|
||||
import com.peakup.AbstractPeakUpTest;
|
||||
import com.peakup.PostgresTestProfile;
|
||||
import com.peakup.goal.model.GoalMetricEnum;
|
||||
import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import com.peakup.goal.service.GoalMetricPeriodTotalService;
|
||||
import com.peakup.goal.service.GoalNotificationService;
|
||||
import io.agroal.api.AgroalDataSource;
|
||||
import io.quarkus.mailer.MockMailbox;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.TestProfile;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.WeekFields;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@QuarkusTest
|
||||
@TestProfile(PostgresTestProfile.class)
|
||||
class GoalNotificationServiceTest extends AbstractPeakUpTest {
|
||||
|
||||
private static final long ATHLETE_ID = 90L;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalNotificationService goalNotificationService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
MockMailbox mockMailbox;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
AgroalDataSource dataSource;
|
||||
|
||||
@BeforeEach
|
||||
void clearMailbox() {
|
||||
mockMailbox.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsEmailWhenGoalAchieved() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(1500.0);
|
||||
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
final var messages = mockMailbox.getMessagesSentTo("ingo.schnabel@gmail.com");
|
||||
assertEquals(1, messages.size());
|
||||
assertTrue(messages.get(0).getSubject().contains("weekly distance"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendEmailWhenGoalNotReached() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(500.0);
|
||||
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(mockMailbox.getMessagesSentTo("ingo.schnabel@gmail.com").isEmpty());
|
||||
}
|
||||
|
||||
private void insertGoal(final GoalPeriodEnum period, final GoalMetricEnum metric, final double targetValue)
|
||||
throws Exception {
|
||||
try (final Connection conn = dataSource.getConnection();
|
||||
final Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
INSERT INTO goal (athlete_id, period, metric, target_value, created_at, modified_at)
|
||||
VALUES (%d, %d, %d, %f, now(), now())
|
||||
""".formatted(ATHLETE_ID, period.getKey(), metric.getKey(), targetValue));
|
||||
}
|
||||
}
|
||||
|
||||
private void insertActivity(final double distanceM) throws Exception {
|
||||
try (final Connection conn = dataSource.getConnection();
|
||||
final Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
INSERT INTO intervals_activity
|
||||
(athlete_id, start_time, distance_m, training_load, total_elevation_gain_m, moving_time_s, created_at, modified_at)
|
||||
VALUES (%d, now(), %f, 0.0, 0.0, 0, now(), now())
|
||||
""".formatted(ATHLETE_ID, distanceM));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface AthleteDo extends AbstractTimestampedEntityDo {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
login: string;
|
||||
email: string;
|
||||
weight: number;
|
||||
sex: SexEnum | null;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ VALUES (nextval('global_id_seq'), 90, '36523216', '12c757d282015eb26c21117c3a9a5
|
||||
-- Intervals Connectors
|
||||
INSERT INTO intervals_connector (id, athlete_id, intervals_athlete_id, api_key, created_at, modified_at)
|
||||
VALUES (nextval('global_id_seq'), 90, 'i157824', '38he2acw5nakpy749jabprdtz', now(), now()),
|
||||
(nextval('global_id_seq'), 91, 'i310361', '7er8wec6ziuaitp6nrw6wteqf', now(), now());
|
||||
(nextval('global_id_seq'), 91, 'i310361', '7er8wec6ziuaitp6nrw6wteqf', now(), now());
|
||||
|
||||
Reference in New Issue
Block a user