Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 6e7fb1e066 feat(account): send display name in account state to Core
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
Build, Test & Push Auth Service / quality-check (push) Successful in 49s
The account state push carries displayName. It is published when the
name changes. Contract test updated.
2026-10-02 09:10:56 +02:00
lobo 1b7bafd9f0 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.
2026-10-02 07:53:35 +02:00
32 changed files with 103 additions and 169 deletions

No files matched your search

+9 -11
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)
@@ -352,9 +350,9 @@ nor a database connection waits for Core; a failed call only warns.
sequenceDiagram
participant A as Auth
participant C as Core
A->>A: link / unlink / block / unblock (commit)
A->>C: POST /internal/accounts/states {userId, steamId, blocked}
C->>C: blocked_user, Steam ID in user_steam_sync
A->>A: link / unlink / block / unblock / name change (commit)
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked}
C->>C: blocked_user, Steam ID in user_steam_sync, display name in account_name
Note over A,C: nightly 04:15
A->>C: PUT /internal/accounts/states (every user)
```
@@ -3,5 +3,5 @@ package auth_service.modules.account.model;
/**
* One user as Core copies it.
*/
public record CoreAccountState(long userId, Long steamId, boolean blocked) {
public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked) {
}
@@ -60,17 +60,18 @@ public class AccountRepository {
}
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), !rs.getBoolean("enabled"));
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
!rs.getBoolean("enabled"));
public Optional<CoreAccountState> findState(long userId) {
return jdbc.query("SELECT id, steam_id, enabled FROM users WHERE id = ?", STATE_ROW, userId).stream().findFirst();
return jdbc.query("SELECT id, steam_id, display_name, enabled FROM users WHERE id = ?", STATE_ROW, userId).stream().findFirst();
}
/**
* Every user, also the flagged ones.
*/
public List<CoreAccountState> findAllStates() {
return jdbc.query("SELECT id, steam_id, enabled FROM users ORDER BY id", STATE_ROW);
return jdbc.query("SELECT id, steam_id, display_name, enabled FROM users ORDER BY id", STATE_ROW);
}
/**
@@ -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());
}
}
@@ -51,12 +51,17 @@ public class UserService {
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
}
boolean renamed = displayName != null && userRepository.findById(userId)
.map(user -> !displayName.equals(user.getDisplayName())).orElse(true);
userRepository.update(userId, user -> {
user.setDisplayName(displayName);
user.getSettings().setLanguage(userSettings.getLanguage());
user.getSettings().setTheme(userSettings.getTheme());
user.getSettings().setPublicProfile(userSettings.isPublicProfile());
});
if (renamed) {
accountStatePublisher.publish(userId);
}
log.info("User settings updated for user {}", userId);
}
@@ -129,13 +134,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;
@@ -52,12 +52,12 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
}
@Test
void findStates_CarryTheSteamIdAndTheBlock() {
jdbc.update("update users set enabled = false, steam_id = 76561198000000001 where id = ?", alice);
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, true));
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, true),
new CoreAccountState(bob, null, false));
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true));
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true),
new CoreAccountState(bob, null, null, false));
assertThat(repository.findState(-1L)).isEmpty();
}
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest {
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, true));
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true));
private static final Duration RETENTION = Duration.ofDays(30);
@@ -71,8 +71,8 @@ class CoreAccountServiceTest extends AbstractUnitTest {
expect("applyStates");
expect("replaceStates");
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, true)));
service.replaceStates(List.of(new CoreAccountState(42L, null, false)));
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true)));
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false)));
core.verify();
}
@@ -22,7 +22,7 @@ import static org.mockito.Mockito.when;
class CoreAccountStatePublisherTest extends AbstractUnitTest {
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, true);
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true);
@Mock
private AccountRepository accountRepository;
@@ -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()
@@ -93,6 +93,7 @@ class UserServiceTest extends AbstractUnitTest {
verify(userRepository, never()).clearSteamId(anyLong());
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
verifyNoInteractions(tokenGenerator);
verifyNoInteractions(accountStatePublisher);
}
@Test
@@ -120,6 +121,18 @@ class UserServiceTest extends AbstractUnitTest {
User changed = appliedChanges(1).getFirst();
assertEquals("Lobo", changed.getDisplayName());
assertEquals("en", changed.getSettings().getLanguage());
verify(accountStatePublisher).publish(1L);
}
@Test
void updateUserSettings_UnchangedDisplayName_PublishesNothing() {
var userSettings = UserSettings.builder().language("en").build();
when(userRepository.findById(1L)).thenReturn(Optional.of(User.builder().id(1L).displayName("Lobo").build()));
userService.updateUserSettings(1L, "Lobo", userSettings);
appliedChanges(1);
verifyNoInteractions(accountStatePublisher);
}
private static final long STEAM_ID = 76561198000000001L;
@@ -186,6 +199,7 @@ class UserServiceTest extends AbstractUnitTest {
assertEquals(appearance, appliedChanges(1).getFirst().getSettings().getAppearance());
verifyNoInteractions(tokenGenerator);
verifyNoInteractions(accountStatePublisher);
}
@Test
@@ -195,13 +209,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);
@@ -37,12 +37,12 @@
"applyStates": {
"method": "POST",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]}
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]}
},
"replaceStates": {
"method": "PUT",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]}
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]}
},
"purge": {
"method": "POST",