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,57 @@
package auth_service.modules.hint.api;
import auth_service.modules.hint.api.dto.request.CreateHintRequest;
import auth_service.modules.hint.api.dto.response.AdminHintListResponse;
import auth_service.modules.hint.api.dto.response.AdminHintResponse;
import auth_service.modules.hint.api.dto.response.CreatedHintResponse;
import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.service.AdminHintService;
import auth_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin/hints")
@RequiredArgsConstructor
public class AdminHintController {
private final AdminHintService adminHintService;
@GetMapping(version = "1.0")
public ResponseEntity<AdminHintListResponse> findAll() {
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
.map(AdminHintController::toApi).toList()));
}
@PostMapping(version = "1.0")
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody CreateHintRequest request) {
long id = adminHintService.create(principal.getIdAsLong(),
new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt()));
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
}
@PutMapping(value = "/{hintId}/end", version = "1.0")
public ResponseEntity<Void> end(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
adminHintService.end(principal.getIdAsLong(), hintId);
return ResponseEntity.noContent().build();
}
private static AdminHintResponse toApi(AdminHint admin) {
Hint hint = admin.hint();
return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount());
}
}
@@ -0,0 +1,40 @@
package auth_service.modules.hint.api;
import auth_service.modules.hint.api.dto.response.HintListResponse;
import auth_service.modules.hint.api.dto.response.HintResponse;
import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.service.HintService;
import auth_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/hints")
@RequiredArgsConstructor
public class HintController {
private final HintService hintService;
@GetMapping(version = "1.0")
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream()
.map(HintController::toApi).toList()));
}
@PutMapping(value = "/{hintId}/done", version = "1.0")
public ResponseEntity<Void> markDone(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
hintService.markDone(principal.getIdAsLong(), hintId);
return ResponseEntity.noContent().build();
}
private static HintResponse toApi(Hint hint) {
return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
hint.endsAt());
}
}
@@ -0,0 +1,28 @@
package auth_service.modules.hint.api;
import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.exception.InvalidHintException;
import auth_service.shared.api.dto.response.ErrorResponse;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = {HintController.class, AdminHintController.class})
@Order(value = 1)
public class HintExceptionController {
@ExceptionHandler(HintNotFoundException.class)
public ResponseEntity<ErrorResponse> clientError(HintNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("HINT_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(InvalidHintException.class)
public ResponseEntity<ErrorResponse> clientError(InvalidHintException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("INVALID_HINT", ex.getMessage()));
}
}
@@ -0,0 +1,15 @@
package auth_service.modules.hint.api.dto.request;
import auth_service.modules.hint.model.HintKind;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
/**
* Plain text, never read as HTML.
*/
public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe,
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.hint.api.dto.response;
import java.util.List;
public record AdminHintListResponse(List<AdminHintResponse> results) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.hint.api.dto.response;
import java.time.Instant;
public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
String status, long createdBy, Instant createdAt, long doneCount) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.hint.api.dto.response;
public record CreatedHintResponse(long id) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.hint.api.dto.response;
import java.util.List;
public record HintListResponse(List<HintResponse> results) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.hint.api.dto.response;
import java.time.Instant;
public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.hint.exception;
public class HintNotFoundException extends RuntimeException {
public HintNotFoundException(long hintId) {
super("No hint with id " + hintId);
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.hint.exception;
public class InvalidHintException extends RuntimeException {
public InvalidHintException(String message) {
super(message);
}
}
@@ -0,0 +1,6 @@
package auth_service.modules.hint.model;
import java.time.Instant;
public record AdminHint(Hint hint, HintStatus status, long createdBy, Instant createdAt, long doneCount) {
}
@@ -0,0 +1,9 @@
package auth_service.modules.hint.model;
import java.time.Instant;
/**
* @param textDe null: German users see {@code textEn}
*/
public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) {
}
@@ -0,0 +1,8 @@
package auth_service.modules.hint.model;
/**
* DISMISS closes by itself after 30 s or with X; ACCEPT stays until the user clicks "OK".
*/
public enum HintKind {
DISMISS, ACCEPT
}
@@ -0,0 +1,8 @@
package auth_service.modules.hint.model;
/**
* Computed with the server's clock, so the admin page does not depend on the browser's.
*/
public enum HintStatus {
PLANNED, ACTIVE, ENDED
}
@@ -0,0 +1,6 @@
package auth_service.modules.hint.model;
import java.time.Instant;
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) {
}
@@ -0,0 +1,84 @@
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 org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@Repository
public class HintRepository {
private static final String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at";
private static final RowMapper<Hint> HINT = (rs, row) -> new Hint(
rs.getLong("id"),
rs.getString("text_en"),
rs.getString("text_de"),
HintKind.valueOf(rs.getString("kind")),
rs.getTimestamp("starts_at").toInstant(),
rs.getTimestamp("ends_at").toInstant());
private final JdbcTemplate jdbc;
public HintRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public List<Hint> findActiveFor(long userId, Instant now) {
Timestamp at = Timestamp.from(now);
return jdbc.query("""
SELECT %s FROM hint h
WHERE h.starts_at <= ? AND h.ends_at > ?
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = ?)
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, at, at, userId);
}
public List<AdminHint> findAll(Instant now) {
Timestamp at = Timestamp.from(now);
return jdbc.query("""
SELECT %s, h.created_by, h.created_at,
CASE WHEN h.ends_at <= ? THEN 'ENDED' WHEN h.starts_at > ? THEN 'PLANNED' ELSE 'ACTIVE' END
AS status,
(SELECT COUNT(*) FROM hint_done d WHERE d.hint_id = h.id) AS done_count
FROM hint h ORDER BY h.created_at DESC, h.id DESC""".formatted(COLUMNS),
(rs, row) -> new AdminHint(HINT.mapRow(rs, row), HintStatus.valueOf(rs.getString("status")),
rs.getLong("created_by"), rs.getTimestamp("created_at").toInstant(), rs.getLong("done_count")),
at, at);
}
public boolean exists(long hintId) {
return Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS (SELECT 1 FROM hint WHERE id = ?)", Boolean.class,
hintId));
}
public long insert(NewHint hint, long createdBy) {
return Objects.requireNonNull(jdbc.queryForObject("""
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(),
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy));
}
public void markDone(long hintId, long userId) {
jdbc.update("INSERT INTO hint_done (hint_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", hintId, userId);
}
/**
* A planned hint ends before it starts, so it never shows.
*
* @return false when the hint had already ended
*/
public boolean end(long hintId, Instant now) {
Timestamp at = Timestamp.from(now);
return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?",
at, at, hintId, at) > 0;
}
}
@@ -0,0 +1,58 @@
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.AdminHint;
import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@Service
@RequiresRole(Role.PLATFORM_ADMIN)
@RequiredArgsConstructor
@Slf4j
public class AdminHintService {
private final HintRepository hintRepository;
private final AdminAuditLog adminAuditLog;
public List<AdminHint> findAll() {
return hintRepository.findAll(Instant.now());
}
@Transactional
public long create(long actorUserId, NewHint hint) {
if (!hint.endsAt().isAfter(hint.startsAt())) {
throw new InvalidHintException("The end must be after the start");
}
if (!hint.endsAt().isAfter(Instant.now())) {
throw new InvalidHintException("The end must be in the future");
}
String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe();
long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt()),
actorUserId);
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
log.info("Admin {} created hint {}", actorUserId, id);
return id;
}
@Transactional
public void end(long actorUserId, long hintId) {
if (!hintRepository.exists(hintId)) {
throw new HintNotFoundException(hintId);
}
if (hintRepository.end(hintId, Instant.now())) {
adminAuditLog.record(actorUserId, "HINT_END", null, String.valueOf(hintId));
log.info("Admin {} ended hint {}", actorUserId, hintId);
}
}
}
@@ -0,0 +1,31 @@
package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class HintService {
private final HintRepository hintRepository;
public List<Hint> activeHints(long userId) {
return hintRepository.findActiveFor(userId, Instant.now());
}
public void markDone(long userId, long hintId) {
if (!hintRepository.exists(hintId)) {
throw new HintNotFoundException(hintId);
}
hintRepository.markDone(hintId, userId);
}
}
@@ -0,0 +1,21 @@
-- Admin hints on the dashboard. No FK on created_by, like admin_audit: the row outlives a deleted admin.
-- ends_at = starts_at is an ended hint that never started.
CREATE TABLE hint (
id BIGSERIAL PRIMARY KEY,
text_en VARCHAR(500) NOT NULL,
text_de VARCHAR(500),
kind VARCHAR(8) NOT NULL CHECK (kind IN ('DISMISS', 'ACCEPT')),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL CHECK (ends_at >= starts_at),
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A row: this user never sees this hint again.
CREATE TABLE hint_done (
hint_id BIGINT NOT NULL REFERENCES hint (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
done_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (hint_id, user_id)
);
CREATE INDEX idx_hint_done_user ON hint_done (user_id);
@@ -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());
}
}