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

+11
View File
@@ -54,6 +54,10 @@ To Be Played is split into three repositories:
(`users.enabled`; blocking ends every login, the login then answers `403 ACCOUNT_BLOCKED`, but only after the
right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account
or on another admin. Every action and every "view as" is a row in `admin_audit`.
- **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
- **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 public profile, no Steam
link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a
@@ -77,6 +81,11 @@ To Be Played is split into three repositories:
| `GET` | `/api/admin/users?query=&page=&size=` (`{content, page, size, totalElements, totalPages}`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token |
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token |
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN |
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token |
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
| `PUT` | `/api/users/settings/appearance` | access token |
@@ -114,6 +123,8 @@ through `CoreAccountService`.
`admin` (users page, audit) works the same way. It deletes through the port `shared/account/AccountDeleter`, and
the login module writes "view as" to the audit table through `shared/audit/AdminAuditLog`, because modules never
use each other.
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
`shared/audit/AdminAuditLog`.
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
@@ -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());
}
}