Compare commits

...
2 Commits
Author SHA1 Message Date
lobo e72222a37f feat(profile): role claim in the profile ticket
Build, Test & Push Auth Service / docker (push) Successful in 1m29s
Build, Test & Push Auth Service / quality-check (push) Successful in 54s
The public profile ticket now carries the user's role so Core can check
ownership of shop items. PLATFORM_ADMIN gets every catalog item for free.
The role comes from the users table; a ticket without the claim means USER.
2026-10-01 13:14:27 +02:00
lobo 6b6ac44551 feat(user): add welcome-seen-at timestamp for welcome page tracking
Null means the welcome page was not seen yet. Existing users are backfilled
at migration time so only newly registered users see the page.
2026-10-01 10:44:22 +02:00
20 changed files with 133 additions and 13 deletions

No files matched your search

+7 -2
View File
@@ -39,8 +39,9 @@ To Be Played is split into three repositories:
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own 10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
`style`. The ticket's claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_` `style`. The ticket also names the user's `role`, so Core can show the free looks of an admin. The ticket's
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_` tile is shown),
`profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names (`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it. its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in
@@ -78,6 +79,9 @@ To Be Played is split into three repositories:
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it" drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
entries. Editing an entry does not make it unseen again. Create, update and delete are rows in `admin_audit`. entries. Editing an entry does not make it unseen again. Create, update and delete are rows in `admin_audit`.
- **Welcome page**: `user_settings.welcome_seen_at` is null until the user sees the welcome page; "Let's go" and "Skip"
set it (`PUT /api/users/settings/welcome-seen`). V24 sets it for every existing user, so only users registered after
the deploy get the page.
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link - **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
to the admin page), and to the user "we got your registration", then "your account is active" or "your to the admin page), and to the user "we got your registration", then "your account is active" or "your
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 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
@@ -123,6 +127,7 @@ To Be Played is split into three repositories:
| `PUT` | `/api/users/settings/appearance` | access token | | `PUT` | `/api/users/settings/appearance` | access token |
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token | | `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/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/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token | | `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token | | `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
| `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/public/profiles/{displayName}` | public |
@@ -85,13 +85,14 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
} }
@Override @Override
public String generateProfileTicket(long userId, List<String> profileStats, List<String> sections) { public String generateProfileTicket(long userId, Role role, List<String> profileStats, List<String> sections) {
return Jwts.builder() return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and() .header().keyId(jwtConfig.getKid()).and()
.issuer(ISSUER) .issuer(ISSUER)
.audience().add(PROFILE_TICKET_AUDIENCE).and() .audience().add(PROFILE_TICKET_AUDIENCE).and()
.subject(PROFILE_TICKET_SUBJECT) .subject(PROFILE_TICKET_SUBJECT)
.claim("profileUserId", String.valueOf(userId)) .claim("profileUserId", String.valueOf(userId))
.claim(ROLE_CLAIM, role.name())
.claim("profileStats", profileStats) .claim("profileStats", profileStats)
.claim("sections", sections) .claim("sections", sections)
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(System.currentTimeMillis()))
@@ -1,9 +1,11 @@
package auth_service.modules.profile.model; package auth_service.modules.profile.model;
import auth_service.shared.security.Role;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
public record PublicProfile(long userId, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) { public record PublicProfile(long userId, Role role, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {} public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {}
@@ -2,6 +2,7 @@ package auth_service.modules.profile.repo;
import auth_service.modules.profile.model.PublicProfile; import auth_service.modules.profile.model.PublicProfile;
import auth_service.shared.account.ActiveUser; import auth_service.shared.account.ActiveUser;
import auth_service.shared.security.Role;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -30,12 +31,12 @@ public class PublicProfileRepository {
*/ */
public Optional<PublicProfile> findPublicByDisplayName(String displayName) { public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query(""" return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners, SELECT u.id, u.role, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
FROM users u JOIN user_settings s ON s.user_id = u.id FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
""".formatted(ActiveUser.of("u")), """.formatted(ActiveUser.of("u")),
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"), (rs, row) -> new PublicProfile(rs.getLong("id"), Role.parse(rs.getString("role")), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null rs.getTimestamp("registration_date") != null
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null, ? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"), new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
@@ -42,6 +42,6 @@ public class PublicProfileService {
if (!statGroups.isEmpty()) { if (!statGroups.isEmpty()) {
sections.add("STATS"); sections.add("STATS");
} }
return profileTicketIssuer.generateProfileTicket(profile.userId(), statGroups, sections); return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections);
} }
} }
@@ -64,6 +64,12 @@ public class UserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/settings/welcome-seen")
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
this.userService.markWelcomeSeen(principal.getIdAsLong());
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/profile-style") @PutMapping(value = "/settings/profile-style")
public ResponseEntity<Void> updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<Void> updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileStyleRequest request) { @Valid @RequestBody ProfileStyleRequest request) {
@@ -2,11 +2,12 @@ package auth_service.modules.user.api.dto.response;
import lombok.Builder; import lombok.Builder;
import java.time.Instant;
import java.util.List; import java.util.List;
@Builder @Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance, ProfileStyleResponse profileStyle, AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles, List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
String favoritePlatform) { String favoritePlatform, Instant welcomeSeenAt) {
} }
@@ -48,6 +48,7 @@ public abstract class ApiMapper {
: toApi(user.getSettings().getDashboardTiles())) : toApi(user.getSettings().getDashboardTiles()))
.profileTiles(toApi(user.getSettings().getProfileTiles())) .profileTiles(toApi(user.getSettings().getProfileTiles()))
.favoritePlatform(user.getSettings().getFavoritePlatform()) .favoritePlatform(user.getSettings().getFavoritePlatform())
.welcomeSeenAt(user.getSettings().getWelcomeSeenAt())
.build()) .build())
.build(); .build();
} }
@@ -3,6 +3,7 @@ package auth_service.modules.user.model;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.Instant;
import java.util.List; import java.util.List;
@Data @Data
@@ -20,6 +21,8 @@ public class UserSettings {
@Builder.Default @Builder.Default
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES; private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
private String favoritePlatform; private String favoritePlatform;
/** Null: the welcome page was not seen yet. */
private Instant welcomeSeenAt;
/** Also the column default of user_settings.profile_tiles. */ /** Also the column default of user_settings.profile_tiles. */
public static final List<Tile> DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"), public static final List<Tile> DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"),
@@ -9,6 +9,7 @@ import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes; import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.List; import java.util.List;
@Entity @Entity
@@ -72,4 +73,7 @@ public class UserSettingsEntity {
@JdbcTypeCode(SqlTypes.ARRAY) @JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_tiles", nullable = false) @Column(name = "profile_tiles", nullable = false)
private List<String> profileTiles; private List<String> profileTiles;
@Column(name = "welcome_seen_at")
private Instant welcomeSeenAt;
} }
@@ -35,6 +35,7 @@ public abstract class EntityMapper {
: settings.getDashboardTiles().stream().map(Tile::parse).toList()) : settings.getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList()) .profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
.favoritePlatform(settings.getFavoritePlatform()) .favoritePlatform(settings.getFavoritePlatform())
.welcomeSeenAt(settings.getWelcomeSeenAt())
.build(); .build();
return User.builder() return User.builder()
@@ -73,5 +74,6 @@ public abstract class EntityMapper {
: Tile.storedAll(settings.getDashboardTiles())); : Tile.storedAll(settings.getDashboardTiles()));
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles())); target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
target.setFavoritePlatform(settings.getFavoritePlatform()); target.setFavoritePlatform(settings.getFavoritePlatform());
target.setWelcomeSeenAt(settings.getWelcomeSeenAt());
} }
} }
@@ -22,6 +22,7 @@ import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -128,6 +129,13 @@ public class UserService {
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip())); user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
} }
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "users", key = "#userId")
public void markWelcomeSeen(long userId) {
userRepository.update(userId, user -> user.getSettings().setWelcomeSeenAt(Instant.now()));
}
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
@@ -11,5 +11,5 @@ public interface ProfileTicketIssuer {
* @param profileStats the stat groups of Core the profile shows * @param profileStats the stat groups of Core the profile shows
* @param sections the parts of the profile Core sends data for * @param sections the parts of the profile Core sends data for
*/ */
String generateProfileTicket(long userId, List<String> profileStats, List<String> sections); String generateProfileTicket(long userId, Role role, List<String> profileStats, List<String> sections);
} }
@@ -0,0 +1,4 @@
-- Null: the user has not seen the welcome page (item 20). Every existing user counts as seen, so only users
-- registered after this migration get the page. The registration insert names its columns and leaves it null.
ALTER TABLE user_settings ADD COLUMN welcome_seen_at TIMESTAMPTZ;
UPDATE user_settings SET welcome_seen_at = NOW();
@@ -113,7 +113,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() { void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L, List.of(), List.of()); var ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of());
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("tbp-public-profile"), List.copyOf(claims.getAudience())); assertEquals(List.of("tbp-public-profile"), List.copyOf(claims.getAudience()));
@@ -123,9 +123,17 @@ class JwtServiceTest extends AbstractUnitTest {
assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s"); assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s");
} }
@Test
void generateProfileTicket_CarriesTheRole() {
String ticket = jwtService.generateProfileTicket(42L, Role.PLATFORM_ADMIN, List.of(), List.of());
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals("PLATFORM_ADMIN", claims.get("role", String.class));
}
@Test @Test
void generateProfileTicket_CarriesTheSharedStatGroups() { void generateProfileTicket_CarriesTheSharedStatGroups() {
String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND"), List.of("PLAYING", "STATS")); String ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of("PILE", "TREND"), List.of("PLAYING", "STATS"));
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
@@ -134,7 +142,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void parseLoginToken_ProfileTicketIsNoLoginToken() { void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of(), List.of()))); assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of())));
} }
@Test @Test
@@ -218,7 +226,7 @@ class JwtServiceTest extends AbstractUnitTest {
assertEquals(ContractFixture.token("login"), assertEquals(ContractFixture.token("login"),
payload(jwtService.generateToken("42", 76561198000000001L, Role.USER))); payload(jwtService.generateToken("42", 76561198000000001L, Role.USER)));
assertEquals(ContractFixture.token("publicProfile"), assertEquals(ContractFixture.token("publicProfile"),
payload(jwtService.generateProfileTicket(42L, List.of("OVERVIEW"), List.of("SUMMARY")))); payload(jwtService.generateProfileTicket(42L, Role.USER, List.of("OVERVIEW"), List.of("SUMMARY"))));
assertEquals(ContractFixture.token("service"), payload(jwtService.generateServiceToken())); assertEquals(ContractFixture.token("service"), payload(jwtService.generateServiceToken()));
} }
@@ -68,6 +68,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload(); var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class)); assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class));
assertEquals(List.of(), claims.get("profileStats", List.class)); assertEquals(List.of(), claims.get("profileStats", List.class));
assertEquals("USER", claims.get("role", String.class));
} }
@Test @Test
@@ -120,6 +121,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload(); Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PLAYING"), claims.get("sections", List.class)); assertEquals(List.of("PLAYING"), claims.get("sections", List.class));
assertEquals(List.of(), claims.get("profileStats", List.class)); assertEquals(List.of(), claims.get("profileStats", List.class));
assertEquals("USER", claims.get("role", String.class));
} }
@Test @Test
@@ -33,9 +33,11 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -381,6 +383,40 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.favoritePlatform").value("PS5")); .andExpect(jsonPath("$.settings.favoritePlatform").value("PS5"));
} }
@Test
void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUser(1L)).thenReturn(User.builder().email("test@example.com")
.settings(UserSettings.builder().language("en").theme("dark").build()).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.welcomeSeenAt").value(nullValue()));
}
@Test
void getCurrentUser_SendsWelcomeSeenAtAsIsoInstant() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUser(1L)).thenReturn(User.builder().email("test@example.com")
.settings(UserSettings.builder().language("en").theme("dark")
.welcomeSeenAt(Instant.parse("2026-10-01T10:00:00Z")).build()).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.settings.welcomeSeenAt").value("2026-10-01T10:00:00Z"));
}
@Test
void markWelcomeSeen_Returns204() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/welcome-seen")).andExpect(status().isNoContent());
verify(userService).markWelcomeSeen(1L);
}
@Test @Test
void getCurrentUser_NeverChangedDashboard_SendsNullTiles() throws Exception { void getCurrentUser_NeverChangedDashboard_SendsNullTiles() throws Exception {
String email = "test@example.com"; String email = "test@example.com";
@@ -14,7 +14,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -374,6 +376,28 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertEquals(profile, findByEmail("longtile@example.com").orElseThrow().getSettings().getProfileTiles()); assertEquals(profile, findByEmail("longtile@example.com").orElseThrow().getSettings().getProfileTiles());
} }
@Test
void markWelcomeSeen_IsNullForANewRowAndSurvivesASettingsSave() {
UserEntity userEntity = persisted(UserEntity.builder()
.email("welcome@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
assertNull(findByEmail("welcome@example.com").orElseThrow().getSettings().getWelcomeSeenAt());
Instant seen = Instant.now().truncatedTo(ChronoUnit.MILLIS);
userRepository.update(userEntity.getId(), user -> user.getSettings().setWelcomeSeenAt(seen));
entityManager.flush();
entityManager.clear();
assertEquals(seen, findByEmail("welcome@example.com").orElseThrow().getSettings().getWelcomeSeenAt());
updateSettings(userEntity.getId(), null, UserSettings.builder().language("de").theme("dark").build());
entityManager.flush();
entityManager.clear();
assertEquals(seen, findByEmail("welcome@example.com").orElseThrow().getSettings().getWelcomeSeenAt());
}
private void updateSettings(long userId, String displayName, UserSettings settings) { private void updateSettings(long userId, String displayName, UserSettings settings) {
userRepository.update(userId, user -> { userRepository.update(userId, user -> {
user.setDisplayName(displayName); user.setDisplayName(displayName);
@@ -19,6 +19,8 @@ import org.mockito.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import java.time.Duration;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -26,6 +28,7 @@ import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -192,6 +195,14 @@ class UserServiceTest extends AbstractUnitTest {
assertEquals("Switch", appliedChanges(1).getFirst().getSettings().getFavoritePlatform()); assertEquals("Switch", appliedChanges(1).getFirst().getSettings().getFavoritePlatform());
} }
@Test
void markWelcomeSeen_StampsNow() {
userService.markWelcomeSeen(1L);
Instant stamp = appliedChanges(1).getFirst().getSettings().getWelcomeSeenAt();
assertTrue(Duration.between(stamp, Instant.now()).abs().getSeconds() < 5);
}
/** /**
* Runs each change user 1 got on a fresh user with default settings. * Runs each change user 1 got on a fresh user with default settings.
*/ */
@@ -13,6 +13,7 @@
"aud": ["tbp-public-profile"], "aud": ["tbp-public-profile"],
"sub": "public-profile", "sub": "public-profile",
"profileUserId": "42", "profileUserId": "42",
"role": "USER",
"profileStats": ["OVERVIEW"], "profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"] "sections": ["SUMMARY"]
}, },