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,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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user