feat(mail): send broadcasts to every active user

Remove the platform_mails opt-out. Broadcasts go to all active users.
V28 drops the column; the PUT /api/users/settings/platform-mails
endpoint and the footer line about turning mails off are gone.
This commit is contained in:
lobo committed 2026-10-02 07:53:35 +02:00
1 parent 0af76da9c7
commit 1b7bafd9f0
25 files changed
+65 -151

No files matched your search

+6 -8
View File
@@ -88,12 +88,11 @@ To Be Played is split into three repositories:
registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
the user's app language. Plain text only. See "Mail flow".
- **Broadcast mail**: a PLATFORM_ADMIN writes one mail with subject and text in English and German (plain text),
and sends it to all active users or to picked ones, each in their app language. A user can turn it off
(`user_settings.platform_mails`, default on); the footer of the mail says where. "Send test to me" mails the draft
to the admin only, in the admin's language. One `mail_broadcast` row keeps the texts and the counts (recipients,
sent, failed); `BROADCAST_SEND` goes to `admin_audit`. The sender takes account mails first and stays under
`APP_MAIL_DAILY_LIMIT` per 24 h, 10 of them kept for account mails. A recipient who is blocked, deleted or opted
out before the send counts as failed.
and sends it to all active users or to picked ones, each in their app language. Every active user gets it.
"Send test to me" mails the draft to the admin only, in the admin's language. One `mail_broadcast` row keeps the
texts and the counts (recipients, sent, failed); `BROADCAST_SEND` goes to `admin_audit`. The sender takes account
mails first and stays under `APP_MAIL_DAILY_LIMIT` per 24 h, 10 of them kept for account mails. A recipient who is
blocked or deleted before the send counts as failed.
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
@@ -139,7 +138,6 @@ To Be Played is split into three repositories:
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token |
| `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | access token |
| `PUT` | `/api/users/settings/platform-mails` (`{enabled}`; `204`) | access token |
| `GET` | `/api/admin/broadcasts` (`{results}` with the counts, newest first) | PLATFORM_ADMIN |
| `GET` | `/api/admin/broadcasts/recipient-count?userIds=` (`{count}`; no `userIds` = all) | PLATFORM_ADMIN |
| `POST` | `/api/admin/broadcasts` (`{subjectEn, subjectDe, bodyEn, bodyDe, userIds}`; `201 {recipientCount}`; `409 NO_RECIPIENTS`) | PLATFORM_ADMIN |
@@ -296,7 +294,7 @@ sequenceDiagram
participant A as Auth
participant M as SMTP (Hostinger)
B->>A: admin sends a broadcast
A->>A: mail_broadcast row + one outbox row per active, opted-in user
A->>A: mail_broadcast row + one outbox row per active user
B->>A: register / admin decides / admin blocks / delete account
A->>A: event + mail_outbox row (PENDING), one transaction
loop every minute (MailOutboxService)
@@ -8,7 +8,7 @@ import java.util.List;
/**
* Plain text, never HTML.
*
* @param userIds null = every active user with platform mails on; the test mail ignores it
* @param userIds null = every active user; the test mail ignores it
*/
public record BroadcastRequest(@NotBlank @Size(max = 150) String subjectEn, @NotBlank @Size(max = 150) String subjectDe,
@NotBlank @Size(max = 5000) String bodyEn, @NotBlank @Size(max = 5000) String bodyDe,
@@ -3,6 +3,6 @@ package auth_service.modules.mail.exception;
public class NoRecipientsException extends RuntimeException {
public NoRecipientsException() {
super("No active user with platform mails on among the recipients");
super("No active user among the recipients");
}
}
@@ -126,21 +126,19 @@ public enum MailTemplate {
ToBePlayed
"""),
// Admin text only: %1$s subject, %2$s body, %3$s settings URL.
// Admin text only: %1$s subject, %2$s body.
BROADCAST(
"%1$s", """
%2$s
--
You get this mail because you have an account on ToBePlayed. Turn these mails off in your settings:
%3$s
You get this mail because you have an account on ToBePlayed.
""",
"%1$s", """
%2$s
--
Du bekommst diese Mail, weil du ein Konto bei ToBePlayed hast. In den Einstellungen kannst du sie abschalten:
%3$s
Du bekommst diese Mail, weil du ein Konto bei ToBePlayed hast.
""");
private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
@@ -18,7 +18,7 @@ import java.util.Objects;
public class BroadcastRepository {
static final String RECIPIENTS = "FROM users u JOIN user_settings s ON s.user_id = u.id WHERE "
+ ActiveUser.of("u") + " AND s.platform_mails";
+ ActiveUser.of("u");
private final JdbcTemplate jdbc;
private final NamedParameterJdbcTemplate namedJdbc;
@@ -70,14 +70,14 @@ public class MailOutboxRepository {
}
/**
* Fails the pending broadcast rows whose user is no longer active or turned the mails off since the queue time.
* Fails the pending broadcast rows whose user is no longer active since the queue time.
*
* @return the number of rows failed
*/
public int failBroadcastRowsOfInactiveUsers() {
return Objects.requireNonNull(jdbc.queryForObject("""
WITH skipped AS (
UPDATE mail_outbox o SET status = 'FAILED', last_error = 'recipient inactive or opted out'
UPDATE mail_outbox o SET status = 'FAILED', last_error = 'recipient inactive'
WHERE o.status = 'PENDING' AND o.broadcast_id IS NOT NULL
AND NOT EXISTS (SELECT 1 %s AND u.id = o.user_id)
RETURNING o.broadcast_id),
@@ -12,7 +12,6 @@ import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -27,14 +26,11 @@ public class BroadcastService {
private final BroadcastRepository broadcasts;
private final MailOutboxRepository outbox;
private final AdminAuditLog adminAuditLog;
private final String settingsUrl;
public BroadcastService(BroadcastRepository broadcasts, MailOutboxRepository outbox, AdminAuditLog adminAuditLog,
@Value("${app.frontend-url}") String frontendUrl) {
public BroadcastService(BroadcastRepository broadcasts, MailOutboxRepository outbox, AdminAuditLog adminAuditLog) {
this.broadcasts = broadcasts;
this.outbox = outbox;
this.adminAuditLog = adminAuditLog;
this.settingsUrl = frontendUrl + "/settings";
}
public List<Broadcast> findAll() {
@@ -42,21 +38,21 @@ public class BroadcastService {
}
/**
* @param userIds null = every active user with platform mails on
* @param userIds null = every active user
*/
public long countRecipients(List<Long> userIds) {
return broadcasts.countRecipients(userIds);
}
/**
* @param userIds null = every active user with platform mails on
* @param userIds null = every active user
* @return the number of mails queued
* @throws NoRecipientsException when nobody is left; nothing is stored then
*/
@Transactional
public int send(long actorUserId, BroadcastText text, List<Long> userIds) {
Mail en = MailTemplate.BROADCAST.render("en", text.subjectEn(), text.bodyEn(), settingsUrl);
Mail de = MailTemplate.BROADCAST.render("de", text.subjectDe(), text.bodyDe(), settingsUrl);
Mail en = MailTemplate.BROADCAST.render("en", text.subjectEn(), text.bodyEn());
Mail de = MailTemplate.BROADCAST.render("de", text.subjectDe(), text.bodyDe());
long id = broadcasts.insert(text, actorUserId, Instant.now());
int queued = broadcasts.queue(id, userIds, en, de);
if (queued == 0) {
@@ -76,7 +72,7 @@ public class BroadcastService {
Recipient admin = outbox.findRecipient(actorUserId).orElseThrow();
boolean german = MailTemplate.isGerman(admin.language());
Mail mail = MailTemplate.BROADCAST.render(admin.language(), german ? text.subjectDe() : text.subjectEn(),
german ? text.bodyDe() : text.bodyEn(), settingsUrl);
german ? text.bodyDe() : text.bodyEn());
outbox.insert(MailTemplate.BROADCAST, actorUserId, admin.email(), mail);
}
}
@@ -68,7 +68,7 @@ public class MailOutboxService {
Instant now = Instant.now();
int skipped = repository.failBroadcastRowsOfInactiveUsers();
if (skipped > 0) {
log.info("{} broadcast mails skipped: recipient inactive or opted out", skipped);
log.info("{} broadcast mails skipped: recipient inactive", skipped);
}
int budget = dailyLimit - repository.countSentSince(now.minus(Duration.ofHours(24)));
for (OutboxRow row : repository.findDue(now, Math.min(BATCH, Math.max(budget, 0)))) {
@@ -4,7 +4,6 @@ import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
import auth_service.modules.user.api.dto.request.PlatformMailsRequest;
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
@@ -65,13 +64,6 @@ public class UserController {
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/platform-mails")
public ResponseEntity<Void> updatePlatformMails(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody PlatformMailsRequest request) {
this.userService.updatePlatformMails(principal.getIdAsLong(), request.enabled());
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/welcome-seen")
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
this.userService.markWelcomeSeen(principal.getIdAsLong());
@@ -1,9 +0,0 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
/**
* @param enabled boxed, so a missing field is a 400 and not an opt-out
*/
public record PlatformMailsRequest(@NotNull Boolean enabled) {
}
@@ -9,5 +9,5 @@ import java.util.List;
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
String favoritePlatform, boolean platformMails, Instant welcomeSeenAt) {
String favoritePlatform, Instant welcomeSeenAt) {
}
@@ -48,7 +48,6 @@ public abstract class ApiMapper {
: toApi(user.getSettings().getDashboardTiles()))
.profileTiles(toApi(user.getSettings().getProfileTiles()))
.favoritePlatform(user.getSettings().getFavoritePlatform())
.platformMails(user.getSettings().isPlatformMails())
.welcomeSeenAt(user.getSettings().getWelcomeSeenAt())
.build())
.build();
@@ -21,9 +21,6 @@ public class UserSettings {
@Builder.Default
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
private String favoritePlatform;
/** false = no broadcast mails; account mails always go. */
@Builder.Default
private boolean platformMails = true;
/** Null: the welcome page was not seen yet. */
private Instant welcomeSeenAt;
@@ -73,9 +73,6 @@ public class UserSettingsEntity {
@Column(name = "favorite_platform")
private String favoritePlatform;
@Column(name = "platform_mails", nullable = false)
private boolean platformMails;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_tiles", nullable = false)
private List<String> profileTiles;
@@ -36,7 +36,6 @@ public abstract class EntityMapper {
: settings.getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
.favoritePlatform(settings.getFavoritePlatform())
.platformMails(settings.isPlatformMails())
.welcomeSeenAt(settings.getWelcomeSeenAt())
.build();
@@ -77,7 +76,6 @@ public abstract class EntityMapper {
: Tile.storedAll(settings.getDashboardTiles()));
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
target.setFavoritePlatform(settings.getFavoritePlatform());
target.setPlatformMails(settings.isPlatformMails());
target.setWelcomeSeenAt(settings.getWelcomeSeenAt());
}
}
@@ -129,13 +129,6 @@ public class UserService {
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "users", key = "#userId")
public void updatePlatformMails(long userId, boolean enabled) {
userRepository.update(userId, user -> user.getSettings().setPlatformMails(enabled));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "users", key = "#userId")
@@ -0,0 +1,2 @@
-- Broadcasts go to every active user (user decision 2026-10-02), so there is no opt-out any more.
ALTER TABLE user_settings DROP COLUMN platform_mails;
@@ -2,6 +2,7 @@ package auth_service.modules.mail.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.mail.service.MailOutboxService;
import auth_service.shared.account.ActiveUser;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
@@ -34,7 +35,6 @@ 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";
@@ -50,16 +50,16 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
@Autowired
private MailOutboxService mailOutboxService;
private long optedOutId;
private List<Long> foreignOptedIn;
private long adminId;
private long playerId;
private long blockedId;
private long insertUser(String email, String role, String language, boolean platformMails, boolean enabled) {
private long insertUser(String email, String role, String language, 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);
jdbc.update("UPDATE user_settings SET language = ? WHERE user_id = ?", language, id);
return id;
}
@@ -68,23 +68,15 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
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);
adminId = insertUser(ADMIN, "PLATFORM_ADMIN", "de", true);
playerId = insertUser(PLAYER, "USER", "en", true);
blockedId = insertUser(BLOCKED, "USER", "en", true);
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 {
@@ -109,11 +101,16 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
}
@Test
void sendToAll_MailsActiveOptedInUsersInTheirLanguageAndCountsTheSend() throws Exception {
void sendToPicked_MailsActiveUsersInTheirLanguageAndCountsTheSend() 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())
String picked = adminId + "," + playerId + "," + blockedId;
send(get(BASE + "/recipient-count?userIds=" + picked), admin).andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(2));
long active = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class);
send(get(BASE + "/recipient-count"), admin).andExpect(jsonPath("$.count").value(active));
send(post(BASE).content(broadcast("Hallo", "[" + picked + "]")), admin).andExpect(status().isCreated())
.andExpect(jsonPath("$.recipientCount").value(2));
assertThat(rows()).extracting(row -> row.get("to_address") + " " + row.get("subject") + " " + row.get("kind"))
@@ -136,7 +133,7 @@ class BroadcastFlowTest extends AbstractIntegrationTest {
void pickedAndTest_StayNarrow() throws Exception {
Cookie admin = login(ADMIN);
send(post(BASE).content(broadcast("Hallo", "[" + optedOutId + "]")), admin).andExpect(status().isConflict())
send(post(BASE).content(broadcast("Hallo", "[" + blockedId + "]")), admin).andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("NO_RECIPIENTS"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_broadcast", Long.class)).isZero();
@@ -27,12 +27,12 @@ class MailTemplateTest extends AbstractUnitTest {
}
@Test
void render_Broadcast_PutsSubjectBodyAndTheSettingsLink() {
void render_Broadcast_PutsSubjectAndBody() {
for (String language : new String[]{"de", "en"}) {
Mail mail = MailTemplate.BROADCAST.render(language, "50% off", "Hello 100%s", "https://tbp.example/settings");
Mail mail = MailTemplate.BROADCAST.render(language, "50% off", "Hello 100%s");
assertThat(mail.subject()).isEqualTo("50% off");
assertThat(mail.body()).startsWith("Hello 100%s").endsWith("https://tbp.example/settings\n");
assertThat(mail.body()).startsWith("Hello 100%s").contains("ToBePlayed").doesNotContain("%1$s", "%2$s", "%3$s");
}
}
@@ -25,7 +25,6 @@ class BroadcastRepositoryTest extends AbstractPersistenceTest {
private long german;
private long noLanguage;
private long optedOut;
private long blocked;
private long pending;
private long deleted;
@@ -35,30 +34,28 @@ class BroadcastRepositoryTest extends AbstractPersistenceTest {
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);
german = user("german", "true, 'ACTIVE', NULL", "de");
noLanguage = user("nolang", "true, 'ACTIVE', NULL", "");
blocked = user("blocked", "false, 'ACTIVE', NULL", "de");
pending = user("pending", "true, 'PENDING', NULL", "de");
deleted = user("deleted", "true, 'ACTIVE', NOW()", "de");
}
private long user(String name, String flags, String language, boolean platformMails) {
private long user(String name, String flags, String language) {
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);
jdbc.update("INSERT INTO user_settings (user_id, language) VALUES (?, ?)", id, language);
return id;
}
@Test
void countRecipients_TakesOnlyActiveOptedInUsers() {
void countRecipients_TakesOnlyActiveUsers() {
// 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);
+ "WHERE u.enabled AND u.deleted_at IS NULL AND u.status = 'ACTIVE'", 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(german, blocked, pending, deleted))).isEqualTo(1);
assertThat(repository.countRecipients(List.of())).isZero();
}
@@ -66,7 +63,7 @@ class BroadcastRepositoryTest extends AbstractPersistenceTest {
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),
int queued = repository.queue(id, List.of(german, noLanguage, blocked),
new Mail("Hi", "Body"), new Mail("Hallo", "Inhalt"));
repository.setRecipientCount(id, queued);
@@ -162,28 +162,27 @@ class MailOutboxRepositoryTest extends AbstractPersistenceTest {
}
@Test
void failBroadcastRowsOfInactiveUsers_FailsBlockedDeletedPendingAndOptedOut() {
void failBroadcastRowsOfInactiveUsers_FailsBlockedDeletedAndPending() {
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);
for (long id : new long[]{active, blocked, deleted, pending}) {
jdbc.update("INSERT INTO user_settings (user_id) VALUES (?)", id);
}
long keep = broadcastRow(broadcast, active);
for (long id : new long[]{blocked, deleted, pending, optedOut}) {
for (long id : new long[]{blocked, deleted, pending}) {
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(repository.failBroadcastRowsOfInactiveUsers()).isEqualTo(3);
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);
assertThat(counter(broadcast, "failed_count")).isEqualTo(3);
}
@Test
@@ -32,7 +32,6 @@ 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
@@ -48,7 +47,7 @@ class BroadcastServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new BroadcastService(broadcasts, outbox, adminAuditLog, "https://tbp.example");
service = new BroadcastService(broadcasts, outbox, adminAuditLog);
}
@Test
@@ -62,9 +61,9 @@ class BroadcastServiceTest extends AbstractUnitTest {
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(en.getValue().body()).startsWith("Body").contains("You get this mail").doesNotContain("settings");
assertThat(de.getValue().subject()).isEqualTo("Hallo");
assertThat(de.getValue().body()).startsWith("Inhalt").contains("Du bekommst diese Mail").endsWith(SETTINGS + "\n");
assertThat(de.getValue().body()).startsWith("Inhalt").contains("Du bekommst diese Mail").doesNotContain("settings");
verify(broadcasts).setRecipientCount(5L, 3);
verify(adminAuditLog).record(ADMIN, "BROADCAST_SEND", null, "5");
}
@@ -491,22 +491,6 @@ 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,22 +251,6 @@ 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,13 +195,6 @@ 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);