feat(hint): add admin hints for the dashboard
Build, Test & Push Auth Service / quality-check (push) Successful in 42s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s

This commit is contained in:
lobo committed 2026-09-28 10:07:49 +02:00
1 parent 5341e0b870
commit 32d4f40eec
24 files changed
+754

No files matched your search

@@ -0,0 +1,132 @@
package auth_service.modules.hint.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class HintFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String ADMIN = "hint-flow-admin@example.com";
private static final String PLAYER = "hint-flow-player@example.com";
private static final String OTHER = "hint-flow-other@example.com";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
private void insertUser(String email, String role) {
jdbc.update("DELETE FROM users WHERE email = ?", email);
jdbc.update("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?)",
email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16));
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM hint");
insertUser(ADMIN, "PLATFORM_ADMIN");
insertUser(PLAYER, "USER");
insertUser(OTHER, "USER");
}
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 ResultActions create(Cookie session, String body) throws Exception {
return mockMvc.perform(post("/api/admin/hints").header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(session)
.contentType(MediaType.APPLICATION_JSON).content(body));
}
private static String hint(String textDe, Instant startsAt, Instant endsAt) {
return """
{"textEn":"Link your Steam account","textDe":%s,"kind":"ACCEPT","startsAt":"%s","endsAt":"%s"}"""
.formatted(textDe == null ? "null" : "\"" + textDe + "\"", startsAt, endsAt);
}
private long createRunning(Cookie admin) throws Exception {
String body = create(admin, hint(" ", Instant.now().minusSeconds(60), Instant.now().plus(Duration.ofDays(1))))
.andExpect(status().isCreated()).andReturn().getResponse().getContentAsString();
return Long.parseLong(body.replaceAll("\\D", ""));
}
private ResultActions done(Cookie session, long hintId) throws Exception {
return mockMvc.perform(put("/api/hints/" + hintId + "/done").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(session));
}
@Test
void doneHint_ComesBackOnlyForOtherUsers() throws Exception {
Cookie admin = login(ADMIN);
Cookie player = login(PLAYER);
Cookie other = login(OTHER);
long id = createRunning(admin);
mockMvc.perform(get("/api/hints").cookie(player)).andExpect(status().isOk())
.andExpect(jsonPath("$.results[0].id").value(id))
.andExpect(jsonPath("$.results[0].kind").value("ACCEPT"))
.andExpect(jsonPath("$.results[0].textDe").doesNotExist());
done(player, id).andExpect(status().isNoContent());
mockMvc.perform(get("/api/hints").cookie(player)).andExpect(jsonPath("$.results.length()").value(0));
mockMvc.perform(get("/api/hints").cookie(other)).andExpect(jsonPath("$.results.length()").value(1));
mockMvc.perform(get("/api/admin/hints").cookie(admin)).andExpect(jsonPath("$.results[0].doneCount").value(1));
done(player, id + 1).andExpect(status().isNotFound());
}
@Test
void endedHint_IsGoneAndAudited() throws Exception {
Cookie admin = login(ADMIN);
long id = createRunning(admin);
mockMvc.perform(put("/api/admin/hints/" + id + "/end").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(admin)).andExpect(status().isNoContent());
mockMvc.perform(get("/api/hints").cookie(login(PLAYER))).andExpect(jsonPath("$.results.length()").value(0));
mockMvc.perform(get("/api/admin/hints").cookie(admin)).andExpect(jsonPath("$.results[0].status").value("ENDED"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action IN ('HINT_CREATE', 'HINT_END') "
+ "AND detail = ?", Long.class, String.valueOf(id))).isEqualTo(2);
}
@Test
void create_RefusesUsersAndInvalidInput() throws Exception {
Instant now = Instant.now();
String valid = hint(null, now, now.plus(Duration.ofDays(1)));
create(login(PLAYER), valid).andExpect(status().isForbidden());
Cookie admin = login(ADMIN);
create(admin, hint(null, now, now.minusSeconds(1))).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_HINT"));
create(admin, valid.replace("Link your Steam account", " ")).andExpect(status().isBadRequest());
create(admin, valid.replace("Link your Steam account", "x".repeat(501))).andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,109 @@
package auth_service.modules.hint.repo;
import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.model.HintKind;
import auth_service.modules.hint.model.HintStatus;
import auth_service.modules.hint.model.NewHint;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@Import(HintRepository.class)
class HintRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
private static final Duration DAY = Duration.ofDays(1);
@Autowired
private HintRepository repository;
@Autowired
private JdbcTemplate jdbc;
private long alice;
private long bob;
private long user(String email) {
return jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id",
Long.class, email);
}
private long hint(String text, Instant startsAt, Instant endsAt) {
return repository.insert(new NewHint(text, null, HintKind.DISMISS, startsAt, endsAt), alice);
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM hint");
jdbc.update("DELETE FROM users");
alice = user("alice@example.com");
bob = user("bob@example.com");
}
@Test
void findActiveFor_ReturnsOnlyRunningHintsNewestStartFirst() {
hint("ended", NOW.minus(DAY.multipliedBy(2)), NOW.minus(DAY));
hint("planned", NOW.plus(DAY), NOW.plus(DAY.multipliedBy(2)));
hint("older", NOW.minus(DAY.multipliedBy(2)), NOW.plus(DAY));
hint("newer", NOW.minus(DAY), NOW.plus(DAY));
assertThat(repository.findActiveFor(alice, NOW)).extracting(Hint::textEn).containsExactly("newer", "older");
}
@Test
void findAll_ComputesTheStatusWithTheGivenClock() {
hint("ended", NOW.minus(DAY.multipliedBy(2)), NOW);
hint("active", NOW, NOW.plus(DAY));
hint("planned", NOW.plus(DAY), NOW.plus(DAY.multipliedBy(2)));
assertThat(repository.findAll(NOW)).extracting(admin -> admin.hint().textEn(), AdminHint::status)
.containsExactlyInAnyOrder(tuple("ended", HintStatus.ENDED), tuple("active", HintStatus.ACTIVE),
tuple("planned", HintStatus.PLANNED));
}
@Test
void markDone_HidesTheHintForThisUserOnly() {
long id = hint("news", NOW.minus(DAY), NOW.plus(DAY));
repository.markDone(id, alice);
repository.markDone(id, alice);
assertThat(repository.findActiveFor(alice, NOW)).isEmpty();
assertThat(repository.findActiveFor(bob, NOW)).extracting(Hint::id).containsExactly(id);
assertThat(repository.findAll(NOW)).extracting(AdminHint::doneCount).containsExactly(1L);
}
@Test
void deletedUser_LosesTheDoneRows() {
long id = hint("news", NOW.minus(DAY), NOW.plus(DAY));
repository.markDone(id, bob);
jdbc.update("DELETE FROM users WHERE id = ?", bob);
assertThat(repository.findAll(NOW)).extracting(AdminHint::doneCount).containsExactly(0L);
}
@Test
void end_StopsARunningHintAndAPlannedOneNeverStarts() {
long running = hint("running", NOW.minus(DAY), NOW.plus(DAY));
long planned = hint("planned", NOW.plus(DAY), NOW.plus(DAY.multipliedBy(2)));
assertThat(repository.end(running, NOW)).isTrue();
assertThat(repository.end(planned, NOW)).isTrue();
assertThat(repository.end(running, NOW)).isFalse();
assertThat(repository.findActiveFor(alice, NOW)).isEmpty();
assertThat(repository.findActiveFor(alice, NOW.plus(DAY.multipliedBy(3).dividedBy(2)))).isEmpty();
}
}
@@ -0,0 +1,88 @@
package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.exception.InvalidHintException;
import auth_service.modules.hint.model.HintKind;
import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.repo.HintRepository;
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.Mock;
import java.time.Duration;
import java.time.Instant;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
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 AdminHintServiceTest extends AbstractUnitTest {
private static final long ADMIN = 1L;
private static final Duration DAY = Duration.ofDays(1);
@Mock
private HintRepository hintRepository;
@Mock
private AdminAuditLog adminAuditLog;
private AdminHintService service;
@BeforeEach
void setUp() {
service = new AdminHintService(hintRepository, adminAuditLog);
}
private static NewHint hint(Instant startsAt, Instant endsAt) {
return new NewHint("Link your Steam account", null, HintKind.ACCEPT, startsAt, endsAt);
}
@Test
void create_StoresAndAudits() {
NewHint hint = hint(Instant.now().plus(DAY), Instant.now().plus(DAY.multipliedBy(2)));
when(hintRepository.insert(hint, ADMIN)).thenReturn(7L);
assertThat(service.create(ADMIN, hint)).isEqualTo(7L);
verify(adminAuditLog).record(ADMIN, "HINT_CREATE", null, "7");
}
@Test
void create_RefusesAnEndBeforeTheStartOrInThePast() {
Instant now = Instant.now();
assertThatThrownBy(() -> service.create(ADMIN, hint(now.plus(DAY), now.plus(DAY))))
.isInstanceOf(InvalidHintException.class);
assertThatThrownBy(() -> service.create(ADMIN, hint(now.minus(DAY.multipliedBy(2)), now.minus(DAY))))
.isInstanceOf(InvalidHintException.class);
verify(hintRepository, never()).insert(any(), anyLong());
verifyNoInteractions(adminAuditLog);
}
@Test
void end_AuditsOnlyWhenItEndedTheHint() {
when(hintRepository.exists(7L)).thenReturn(true);
when(hintRepository.end(eq(7L), any())).thenReturn(true, false);
service.end(ADMIN, 7L);
service.end(ADMIN, 7L);
verify(adminAuditLog).record(ADMIN, "HINT_END", null, "7");
}
@Test
void end_UnknownHint() {
assertThatThrownBy(() -> service.end(ADMIN, 7L)).isInstanceOf(HintNotFoundException.class);
verify(adminAuditLog, never()).record(anyLong(), anyString(), isNull(), anyString());
}
}