Public Access
feat(hint): add admin hints for the dashboard
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user