Public Access
feat(mail): add broadcast mail for admins
Adds endpoints for PLATFORM_ADMIN to send mail to all active users or picked ones. Broadcasts and account mails share the same outbox; broadcasts take the rest of the daily limit after 10 mails are kept free for account mail. Users can opt out via settings. History is kept in mail_broadcast with recipient, sent and failed counts. The footer says where to opt out.
This commit is contained in:
1 parent
e72222a37f
commit
a60011ce9f
36 files changed
+1027
-20
No files matched your search
@@ -0,0 +1,172 @@
|
||||
package auth_service.modules.mail.api;
|
||||
|
||||
import auth_service.configuration.security.ClientHeaderFilter;
|
||||
import auth_service.modules.mail.service.MailOutboxService;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.test.AbstractIntegrationTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class BroadcastFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
private static final String ADMIN = "broadcast-flow-admin@example.com";
|
||||
private static final String PLAYER = "broadcast-flow-player@example.com";
|
||||
private static final String OPTED_OUT = "broadcast-flow-optedout@example.com";
|
||||
private static final String BLOCKED = "broadcast-flow-blocked@example.com";
|
||||
private static final String BASE = "/api/admin/broadcasts";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
private MailOutboxService mailOutboxService;
|
||||
|
||||
private long optedOutId;
|
||||
private List<Long> foreignOptedIn;
|
||||
|
||||
private long insertUser(String email, String role, String language, boolean platformMails, boolean enabled) {
|
||||
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
|
||||
+ "VALUES (?, ?, ?, ?, ?) RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD),
|
||||
enabled, role, email.substring(15, 20));
|
||||
addSettingsRows();
|
||||
jdbc.update("UPDATE user_settings SET language = ?, platform_mails = ? WHERE user_id = ?", language,
|
||||
platformMails, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("DELETE FROM mail_outbox");
|
||||
jdbc.update("DELETE FROM mail_broadcast");
|
||||
jdbc.update("DELETE FROM users WHERE email LIKE 'broadcast-flow-%'");
|
||||
// Users that other test classes left behind must not count as recipients; tearDown restores them.
|
||||
foreignOptedIn = jdbc.queryForList("SELECT user_id FROM user_settings WHERE platform_mails", Long.class);
|
||||
jdbc.update("UPDATE user_settings SET platform_mails = FALSE");
|
||||
insertUser(ADMIN, "PLATFORM_ADMIN", "de", true, true);
|
||||
insertUser(PLAYER, "USER", "en", true, true);
|
||||
optedOutId = insertUser(OPTED_OUT, "USER", "en", false, true);
|
||||
insertUser(BLOCKED, "USER", "en", true, true);
|
||||
jdbc.update("UPDATE user_settings SET platform_mails = TRUE WHERE user_id IN "
|
||||
+ "(SELECT id FROM users WHERE email LIKE 'broadcast-flow-%' AND email <> ?)", OPTED_OUT);
|
||||
jdbc.update("UPDATE users SET enabled = FALSE WHERE email = ?", BLOCKED);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
jdbc.update("DELETE FROM users WHERE email LIKE 'broadcast-flow-%'");
|
||||
jdbc.batchUpdate("UPDATE user_settings SET platform_mails = TRUE WHERE user_id = ?",
|
||||
foreignOptedIn.stream().map(id -> new Object[]{id}).toList());
|
||||
}
|
||||
|
||||
private Cookie login(String email) throws Exception {
|
||||
return new Cookie(AuthCookies.ACCESS_COOKIE, mockMvc.perform(post("/api/auth/login")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue());
|
||||
}
|
||||
|
||||
private static String broadcast(String subjectDe, String userIds) {
|
||||
return "{\"subjectEn\":\"Hello\",\"subjectDe\":\"" + subjectDe + "\",\"bodyEn\":\"Body\",\"bodyDe\":\"Inhalt\""
|
||||
+ ",\"userIds\":" + userIds + "}";
|
||||
}
|
||||
|
||||
private ResultActions send(MockHttpServletRequestBuilder request, Cookie session) throws Exception {
|
||||
return mockMvc.perform(request.header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(session)
|
||||
.contentType(MediaType.APPLICATION_JSON));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> rows() {
|
||||
return jdbc.queryForList("SELECT to_address, subject, kind, broadcast_id FROM mail_outbox ORDER BY to_address");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendToAll_MailsActiveOptedInUsersInTheirLanguageAndCountsTheSend() throws Exception {
|
||||
Cookie admin = login(ADMIN);
|
||||
|
||||
send(get(BASE + "/recipient-count"), admin).andExpect(status().isOk()).andExpect(jsonPath("$.count").value(2));
|
||||
send(post(BASE).content(broadcast("Hallo", "null")), admin).andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.recipientCount").value(2));
|
||||
|
||||
assertThat(rows()).extracting(row -> row.get("to_address") + " " + row.get("subject") + " " + row.get("kind"))
|
||||
.containsExactly(ADMIN + " Hallo BROADCAST", PLAYER + " Hello BROADCAST");
|
||||
|
||||
jdbc.update("UPDATE users SET enabled = FALSE WHERE email = ?", PLAYER);
|
||||
mailOutboxService.sendDue();
|
||||
|
||||
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||
send(get(BASE), admin).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.results[0].subjectDe").value("Hallo"))
|
||||
.andExpect(jsonPath("$.results[0].recipientCount").value(2))
|
||||
.andExpect(jsonPath("$.results[0].sentCount").value(1))
|
||||
.andExpect(jsonPath("$.results[0].failedCount").value(1));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action = 'BROADCAST_SEND'", Long.class))
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickedAndTest_StayNarrow() throws Exception {
|
||||
Cookie admin = login(ADMIN);
|
||||
|
||||
send(post(BASE).content(broadcast("Hallo", "[" + optedOutId + "]")), admin).andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("NO_RECIPIENTS"));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
|
||||
|
||||
send(post(BASE + "/test").content(broadcast("Hallo", "null")), admin).andExpect(status().isNoContent());
|
||||
assertThat(rows()).extracting(row -> row.get("to_address") + " " + row.get("subject") + " " + row.get("broadcast_id"))
|
||||
.containsExactly(ADMIN + " Hallo null");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
|
||||
|
||||
send(post(BASE).content(broadcast(" ", "null")), admin).andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyEndpoint_RefusesUsersAndStrangers() throws Exception {
|
||||
Cookie player = login(PLAYER);
|
||||
String body = broadcast("Hallo", "null");
|
||||
|
||||
send(get(BASE), player).andExpect(status().isForbidden());
|
||||
send(get(BASE + "/recipient-count"), player).andExpect(status().isForbidden());
|
||||
send(post(BASE).content(body), player).andExpect(status().isForbidden());
|
||||
send(post(BASE + "/test").content(body), player).andExpect(status().isForbidden());
|
||||
|
||||
mockMvc.perform(get(BASE).header(ClientHeaderFilter.CLIENT_HEADER, "web")).andExpect(status().isUnauthorized());
|
||||
mockMvc.perform(get(BASE + "/recipient-count").header(ClientHeaderFilter.CLIENT_HEADER, "web")).andExpect(status().isUnauthorized());
|
||||
mockMvc.perform(post(BASE).header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isUnauthorized());
|
||||
mockMvc.perform(post(BASE + "/test").header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
assertThat(rows()).isEmpty();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,20 @@ class MailTemplateTest extends AbstractUnitTest {
|
||||
for (String language : new String[]{"de", "en"}) {
|
||||
Mail mail = template.render(language, "Alice", "alice@example.com", "https://tbp.example");
|
||||
|
||||
assertThat(mail.subject()).isNotBlank().doesNotContain("%");
|
||||
assertThat(mail.body()).contains("Alice").doesNotContain("%s");
|
||||
// BROADCAST puts its first argument into the subject, so look at both.
|
||||
String text = mail.subject() + mail.body();
|
||||
assertThat(mail.subject()).isNotBlank();
|
||||
assertThat(text).contains("Alice").doesNotContain("%");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_Broadcast_PutsSubjectBodyAndTheSettingsLink() {
|
||||
for (String language : new String[]{"de", "en"}) {
|
||||
Mail mail = MailTemplate.BROADCAST.render(language, "50% off", "Hello 100%s", "https://tbp.example/settings");
|
||||
|
||||
assertThat(mail.subject()).isEqualTo("50% off");
|
||||
assertThat(mail.body()).startsWith("Hello 100%s").endsWith("https://tbp.example/settings\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package auth_service.modules.mail.repo;
|
||||
|
||||
import auth_service.modules.mail.model.Broadcast;
|
||||
import auth_service.modules.mail.model.BroadcastText;
|
||||
import auth_service.modules.mail.model.Mail;
|
||||
import auth_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BroadcastRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private BroadcastRepository repository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private long german;
|
||||
private long noLanguage;
|
||||
private long optedOut;
|
||||
private long blocked;
|
||||
private long pending;
|
||||
private long deleted;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("DELETE FROM mail_outbox");
|
||||
jdbc.update("DELETE FROM mail_broadcast");
|
||||
jdbc.update("DELETE FROM users WHERE email LIKE 'bc-repo-%'");
|
||||
german = user("german", "true, 'ACTIVE', NULL", "de", true);
|
||||
noLanguage = user("nolang", "true, 'ACTIVE', NULL", "", true);
|
||||
optedOut = user("optedout", "true, 'ACTIVE', NULL", "de", false);
|
||||
blocked = user("blocked", "false, 'ACTIVE', NULL", "de", true);
|
||||
pending = user("pending", "true, 'PENDING', NULL", "de", true);
|
||||
deleted = user("deleted", "true, 'ACTIVE', NOW()", "de", true);
|
||||
}
|
||||
|
||||
private long user(String name, String flags, String language, boolean platformMails) {
|
||||
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, status, deleted_at) VALUES (?, 'x', "
|
||||
+ flags + ") RETURNING id", Long.class, "bc-repo-" + name + "@example.com");
|
||||
jdbc.update("INSERT INTO user_settings (user_id, language, platform_mails) VALUES (?, ?, ?)", id, language,
|
||||
platformMails);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Test
|
||||
void countRecipients_TakesOnlyActiveOptedInUsers() {
|
||||
// Other tests of the suite may leave users behind, so compare with the same query by SQL.
|
||||
long all = jdbc.queryForObject("SELECT COUNT(*) FROM users u JOIN user_settings s ON s.user_id = u.id "
|
||||
+ "WHERE u.enabled AND u.deleted_at IS NULL AND u.status = 'ACTIVE' AND s.platform_mails", Long.class);
|
||||
|
||||
assertThat(repository.countRecipients(null)).isEqualTo(all).isGreaterThanOrEqualTo(2);
|
||||
assertThat(repository.countRecipients(List.of(german, blocked, optedOut, pending, deleted))).isEqualTo(1);
|
||||
assertThat(repository.countRecipients(List.of())).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queue_WritesOneRowPerRecipientInTheirLanguage() {
|
||||
long id = repository.insert(new BroadcastText("Hi", "Hallo", "Body", "Inhalt"), 1L, Instant.now());
|
||||
|
||||
int queued = repository.queue(id, List.of(german, noLanguage, optedOut, blocked),
|
||||
new Mail("Hi", "Body"), new Mail("Hallo", "Inhalt"));
|
||||
repository.setRecipientCount(id, queued);
|
||||
|
||||
assertThat(queued).isEqualTo(2);
|
||||
List<Map<String, Object>> rows = jdbc.queryForList("SELECT user_id, kind, subject, body, broadcast_id "
|
||||
+ "FROM mail_outbox ORDER BY user_id");
|
||||
assertThat(rows).extracting(row -> row.get("user_id")).containsExactly(german, noLanguage);
|
||||
assertThat(rows).extracting(row -> row.get("subject")).containsExactly("Hallo", "Hi");
|
||||
assertThat(rows).extracting(row -> row.get("body")).containsExactly("Inhalt", "Body");
|
||||
assertThat(rows).extracting(row -> row.get("kind")).containsOnly("BROADCAST");
|
||||
assertThat(rows).extracting(row -> row.get("broadcast_id")).containsOnly(id);
|
||||
|
||||
long newer = repository.insert(new BroadcastText("a", "b", "c", "d"), 1L, Instant.now().plusSeconds(5));
|
||||
List<Broadcast> all = repository.findAll();
|
||||
assertThat(all).extracting(Broadcast::id).containsExactly(newer, id);
|
||||
assertThat(all.get(1)).satisfies(b -> {
|
||||
assertThat(b.recipientCount()).isEqualTo(2);
|
||||
assertThat(b.sentCount()).isZero();
|
||||
assertThat(b.failedCount()).isZero();
|
||||
assertThat(b.text().subjectDe()).isEqualTo("Hallo");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void queue_NeverWritesTwoRowsForOneUserAndBroadcast() {
|
||||
long id = repository.insert(new BroadcastText("a", "b", "c", "d"), 1L, Instant.now());
|
||||
repository.queue(id, List.of(german), new Mail("a", "c"), new Mail("b", "d"));
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
|
||||
() -> repository.queue(id, List.of(german), new Mail("a", "c"), new Mail("b", "d")));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
jdbc.update("DELETE FROM mail_outbox");
|
||||
jdbc.update("DELETE FROM mail_broadcast");
|
||||
jdbc.update("DELETE FROM users WHERE email LIKE 'mail-repo%'");
|
||||
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled) "
|
||||
+ "VALUES ('mail-repo-user@example.com', 'x', true) RETURNING id", Long.class);
|
||||
@@ -49,7 +50,7 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
|
||||
long id = insert();
|
||||
|
||||
assertThat(repository.findDue(now.plusSeconds(1), 10))
|
||||
.containsExactly(new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", 0));
|
||||
.containsExactly(new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", 0, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -117,6 +118,85 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
private long broadcast() {
|
||||
return jdbc.queryForObject("INSERT INTO mail_broadcast (subject_en, subject_de, body_en, body_de, created_by, "
|
||||
+ "created_at) VALUES ('s', 's', 'b', 'b', 1, NOW()) RETURNING id", Long.class);
|
||||
}
|
||||
|
||||
private long broadcastRow(long broadcastId, long forUser) {
|
||||
return jdbc.queryForObject("INSERT INTO mail_outbox (kind, user_id, to_address, subject, body, broadcast_id) "
|
||||
+ "VALUES ('BROADCAST', ?, 'x@example.com', 's', 'b', ?) RETURNING id", Long.class, forUser, broadcastId);
|
||||
}
|
||||
|
||||
private long user(String name, String flags) {
|
||||
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, status, deleted_at) "
|
||||
+ "VALUES (?, 'x', " + flags + ") RETURNING id", Long.class, "mail-repo-" + name + "@example.com");
|
||||
}
|
||||
|
||||
private int counter(long broadcastId, String column) {
|
||||
return jdbc.queryForObject("SELECT " + column + " FROM mail_broadcast WHERE id = ?", Integer.class, broadcastId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDue_PutsAccountMailsBeforeBroadcastRows() {
|
||||
long broadcastRow = broadcastRow(broadcast(), userId);
|
||||
long account = insert();
|
||||
|
||||
assertThat(repository.findDue(now.plusSeconds(1), 10)).extracting(OutboxRow::id)
|
||||
.containsExactly(account, broadcastRow);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markSentAndMarkFailed_CountOnTheBroadcast() {
|
||||
long broadcast = broadcast();
|
||||
long sent = broadcastRow(broadcast, userId);
|
||||
long failed = broadcastRow(broadcast, user("other", "true, 'ACTIVE', NULL"));
|
||||
long account = insert();
|
||||
|
||||
repository.markSent(sent, now);
|
||||
repository.markFailed(failed, 10, "rejected");
|
||||
repository.markSent(account, now);
|
||||
|
||||
assertThat(counter(broadcast, "sent_count")).isEqualTo(1);
|
||||
assertThat(counter(broadcast, "failed_count")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failBroadcastRowsOfInactiveUsers_FailsBlockedDeletedPendingAndOptedOut() {
|
||||
long broadcast = broadcast();
|
||||
long active = user("active", "true, 'ACTIVE', NULL");
|
||||
long blocked = user("blocked", "false, 'ACTIVE', NULL");
|
||||
long deleted = user("deleted", "true, 'ACTIVE', NOW()");
|
||||
long pending = user("pending", "true, 'PENDING', NULL");
|
||||
long optedOut = user("optedout", "true, 'ACTIVE', NULL");
|
||||
for (long id : new long[]{active, blocked, deleted, pending, optedOut}) {
|
||||
jdbc.update("INSERT INTO user_settings (user_id, platform_mails) VALUES (?, ?)", id, id != optedOut);
|
||||
}
|
||||
long keep = broadcastRow(broadcast, active);
|
||||
for (long id : new long[]{blocked, deleted, pending, optedOut}) {
|
||||
broadcastRow(broadcast, id);
|
||||
}
|
||||
long accountOfBlocked = jdbc.queryForObject("INSERT INTO mail_outbox (kind, user_id, to_address, subject, body) "
|
||||
+ "VALUES ('ACCOUNT_BLOCKED', ?, 'x@example.com', 's', 'b') RETURNING id", Long.class, blocked);
|
||||
|
||||
assertThat(repository.failBroadcastRowsOfInactiveUsers()).isEqualTo(4);
|
||||
|
||||
assertThat(jdbc.queryForList("SELECT id FROM mail_outbox WHERE status = 'PENDING' ORDER BY id", Long.class))
|
||||
.containsExactly(keep, accountOfBlocked);
|
||||
assertThat(counter(broadcast, "failed_count")).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countSentSince_CountsOnlySentRowsAfterTheInstant() {
|
||||
long recent = insert();
|
||||
long old = insert();
|
||||
insert();
|
||||
repository.markSent(recent, now);
|
||||
repository.markSent(old, now.minus(Duration.ofHours(25)));
|
||||
|
||||
assertThat(repository.countSentSince(now.minus(Duration.ofHours(24)))).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipient_ReadsEmailNameAndLanguage() {
|
||||
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) "
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package auth_service.modules.mail.service;
|
||||
|
||||
import auth_service.modules.mail.exception.NoRecipientsException;
|
||||
import auth_service.modules.mail.model.BroadcastText;
|
||||
import auth_service.modules.mail.model.Mail;
|
||||
import auth_service.modules.mail.model.MailTemplate;
|
||||
import auth_service.modules.mail.model.Recipient;
|
||||
import auth_service.modules.mail.repo.BroadcastRepository;
|
||||
import auth_service.modules.mail.repo.MailOutboxRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class BroadcastServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final long ADMIN = 1L;
|
||||
private static final String SETTINGS = "https://tbp.example/settings";
|
||||
private static final BroadcastText TEXT = new BroadcastText("Hi", "Hallo", "Body", "Inhalt");
|
||||
|
||||
@Mock
|
||||
private BroadcastRepository broadcasts;
|
||||
|
||||
@Mock
|
||||
private MailOutboxRepository outbox;
|
||||
|
||||
@Mock
|
||||
private AdminAuditLog adminAuditLog;
|
||||
|
||||
private BroadcastService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new BroadcastService(broadcasts, outbox, adminAuditLog, "https://tbp.example");
|
||||
}
|
||||
|
||||
@Test
|
||||
void send_QueuesBothLanguagesAuditsAndReturnsTheCount() {
|
||||
when(broadcasts.insert(any(), anyLong(), any())).thenReturn(5L);
|
||||
when(broadcasts.queue(eq(5L), isNull(), any(), any())).thenReturn(3);
|
||||
|
||||
assertThat(service.send(ADMIN, TEXT, null)).isEqualTo(3);
|
||||
|
||||
ArgumentCaptor<Mail> en = ArgumentCaptor.forClass(Mail.class);
|
||||
ArgumentCaptor<Mail> de = ArgumentCaptor.forClass(Mail.class);
|
||||
verify(broadcasts).queue(eq(5L), isNull(), en.capture(), de.capture());
|
||||
assertThat(en.getValue().subject()).isEqualTo("Hi");
|
||||
assertThat(en.getValue().body()).startsWith("Body").contains("You get this mail").endsWith(SETTINGS + "\n");
|
||||
assertThat(de.getValue().subject()).isEqualTo("Hallo");
|
||||
assertThat(de.getValue().body()).startsWith("Inhalt").contains("Du bekommst diese Mail").endsWith(SETTINGS + "\n");
|
||||
verify(broadcasts).setRecipientCount(5L, 3);
|
||||
verify(adminAuditLog).record(ADMIN, "BROADCAST_SEND", null, "5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void send_RefusesZeroRecipients() {
|
||||
when(broadcasts.insert(any(), anyLong(), any())).thenReturn(5L);
|
||||
when(broadcasts.queue(anyLong(), any(), any(), any())).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service.send(ADMIN, TEXT, List.of(9L))).isInstanceOf(NoRecipientsException.class);
|
||||
|
||||
verify(broadcasts, never()).setRecipientCount(anyLong(), anyInt());
|
||||
verifyNoInteractions(adminAuditLog);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendTest_MailsOnlyTheAdminInTheirLanguage() {
|
||||
when(outbox.findRecipient(ADMIN)).thenReturn(Optional.of(new Recipient("admin@example.com", "Admin", "de")));
|
||||
|
||||
service.sendTest(ADMIN, TEXT);
|
||||
|
||||
ArgumentCaptor<Mail> mail = ArgumentCaptor.forClass(Mail.class);
|
||||
verify(outbox).insert(eq(MailTemplate.BROADCAST), eq(ADMIN), eq("admin@example.com"), mail.capture());
|
||||
assertThat(mail.getValue().subject()).isEqualTo("Hallo");
|
||||
assertThat(mail.getValue().body()).startsWith("Inhalt");
|
||||
verifyNoInteractions(broadcasts, adminAuditLog);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import auth_service.modules.mail.repo.MailOutboxRepository;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.mail.MailSendException;
|
||||
@@ -22,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
@@ -43,11 +45,15 @@ class MailOutboxServiceTest extends AbstractUnitTest {
|
||||
|
||||
private MailOutboxService service() {
|
||||
when(senderProvider.getObject()).thenReturn(sender);
|
||||
return new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN);
|
||||
return new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN, 100);
|
||||
}
|
||||
|
||||
private static OutboxRow row(long id, int attempts) {
|
||||
return new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", attempts);
|
||||
return new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", attempts, null);
|
||||
}
|
||||
|
||||
private static OutboxRow broadcastRow(long id) {
|
||||
return new OutboxRow(id, "BROADCAST", "alice@example.com", "Subject", "Body", 0, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,7 +131,7 @@ class MailOutboxServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void sendDue_DoesNothingWithoutAHost() {
|
||||
MailOutboxService off = new MailOutboxService(repository, senderProvider, "", "", "");
|
||||
MailOutboxService off = new MailOutboxService(repository, senderProvider, "", "", "", 100);
|
||||
|
||||
off.sendDue();
|
||||
|
||||
@@ -134,9 +140,54 @@ class MailOutboxServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void constructor_RefusesAHostWithoutSenderOrAdminAddress() {
|
||||
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", "", ADMIN))
|
||||
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", "", ADMIN, 100))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_FROM");
|
||||
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, " "))
|
||||
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, " ", 100))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_ADMIN_TO");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendDue_SendsAccountMailsAndStopsBroadcastRowsAtTheReserve() {
|
||||
when(senderProvider.getObject()).thenReturn(sender);
|
||||
MailOutboxService small = new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN, 12);
|
||||
when(repository.countSentSince(any())).thenReturn(0);
|
||||
when(repository.findDue(any(), anyInt()))
|
||||
.thenReturn(List.of(row(1, 0), broadcastRow(2), broadcastRow(3), broadcastRow(4)));
|
||||
|
||||
small.sendDue();
|
||||
|
||||
verify(repository).markSent(eq(1L), any());
|
||||
verify(repository).markSent(eq(2L), any());
|
||||
verify(repository, never()).markSent(eq(3L), any());
|
||||
verify(repository, never()).markSent(eq(4L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendDue_SendsNothingAboveTheDailyLimit() {
|
||||
when(repository.countSentSince(any())).thenReturn(100);
|
||||
when(repository.findDue(any(), eq(0))).thenReturn(List.of());
|
||||
|
||||
service().sendDue();
|
||||
|
||||
verify(repository).findDue(any(), eq(0));
|
||||
verifyNoInteractions(sender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendDue_FailsRowsOfInactiveUsersBeforeTheSend() {
|
||||
when(repository.findDue(any(), anyInt())).thenReturn(List.of());
|
||||
|
||||
service().sendDue();
|
||||
|
||||
InOrder order = inOrder(repository);
|
||||
order.verify(repository).failBroadcastRowsOfInactiveUsers();
|
||||
order.verify(repository).findDue(any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorRefusesADailyLimitAtOrBelowTheReserve() {
|
||||
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN,
|
||||
MailOutboxService.ACCOUNT_RESERVE))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_DAILY_LIMIT");
|
||||
}
|
||||
}
|
||||
@@ -490,6 +490,22 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
verify(userService, never()).updateFavoritePlatform(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePlatformMails_StoresTheFlagAndRefusesAMissingOne() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new UserPrincipal("1", null), null, Collections.emptyList()));
|
||||
|
||||
mockMvc.perform(put("/api/users/settings/platform-mails")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"enabled\":false}"))
|
||||
.andExpect(status().isNoContent());
|
||||
mockMvc.perform(put("/api/users/settings/platform-mails")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(userService).updatePlatformMails(1L, false);
|
||||
verify(userService, never()).updatePlatformMails(anyLong(), eq(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileTiles_StoresTheTiles() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
|
||||
@@ -251,6 +251,22 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePlatformMails_IsKeptByOtherSettingsWrites() {
|
||||
var userEntity = persisted(UserEntity.builder().email("optout@example.com").password("password")
|
||||
.enabled(true).registrationDate(LocalDateTime.now()).build());
|
||||
assertTrue(findByEmail("optout@example.com").orElseThrow().getSettings().isPlatformMails());
|
||||
|
||||
userRepository.update(userEntity.getId(), user -> user.getSettings().setPlatformMails(false));
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
userRepository.update(userEntity.getId(), user -> user.getSettings().setFavoritePlatform("PS5"));
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertFalse(findByEmail("optout@example.com").orElseThrow().getSettings().isPlatformMails());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSettings_KeepsTheAppearance() {
|
||||
var userEntity = persisted(UserEntity.builder()
|
||||
|
||||
@@ -195,6 +195,13 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
assertEquals("Switch", appliedChanges(1).getFirst().getSettings().getFavoritePlatform());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePlatformMails_StoresTheFlag() {
|
||||
userService.updatePlatformMails(1L, false);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(appliedChanges(1).getFirst().getSettings().isPlatformMails());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markWelcomeSeen_StampsNow() {
|
||||
userService.markWelcomeSeen(1L);
|
||||
|
||||
Reference in new issue
Block a user