feat(hint): target groups and automatic hints, V30
Build, Test & Push Auth Service / quality-check (push) Successful in 59s
Build, Test & Push Auth Service / docker (push) Successful in 1m34s

Hint targets combined with AND: without Steam, role and higher, new
users by registration_date, empty library flag. Four automatic hints
(NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE), on by default; the
admin turns each on or off (audited). Users snooze one for 7 days (X)
or hide it for good (tick), stored on the server. Hint text may use
**bold** and [text](url).
This commit is contained in:
lobo committed 2026-10-02 14:36:49 +02:00
1 parent 5a2552ddf6
commit 19463a1a4d
23 files changed
+468 -36

No files matched your search

+13 -3
View File
@@ -80,6 +80,13 @@ To Be Played is split into three repositories:
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. 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 "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`. comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
A hint can target groups, combined with AND (none = all users): without Steam, role (this role and higher, by the
active role of the token), new users (`users.registration_date` less than N days ago), empty library (Auth only
sends a flag; the Frontend checks it with Core). The text may use `**bold**` and `[text](url)`; the Frontend
renders it, never as HTML. Four automatic hints (`NO_STEAM`, `EMPTY_LIBRARY`, `PILE_GREW`, `LINK_QUEUE`) are on
by default; a PLATFORM_ADMIN turns each on or off (`auto_hint`, audited). `GET /api/hints` lists the keys that are
on and not hidden for the user; the Frontend checks the conditions. X hides one for 7 days, "Do not show again"
for good (`auto_hint_hidden`).
- **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no - **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it" drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
@@ -129,11 +136,14 @@ To Be Played is split into three repositories:
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
| `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN | | `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token | | `GET` | `/api/hints` (`{results}`: running hints for this user not closed for good, newest first, with `emptyLibraryOnly`; `automatic`: keys of the automatic hints that are on and not hidden) | access token |
| `PUT` | `/api/hints/auto/{key}/snooze` (hide 7 days; `204`; `404 HINT_NOT_FOUND`) | access token |
| `PUT` | `/api/hints/auto/{key}/done` (hide for good; `204`; `404 HINT_NOT_FOUND`) | access token |
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | 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 | | `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount` and the `target*` fields, newest first; `automatic`: `[{key, enabled}]`) | PLATFORM_ADMIN |
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | PLATFORM_ADMIN | | `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}` and optional `targetNoSteam`, `targetRole` (this role and higher), `targetNewDays` (1-365), `targetEmptyLibrary`; `201 {id}`, `400 INVALID_HINT` for the time range; `400 VALIDATION_FAILED` for a bad `targetNewDays` or `targetRole`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN | | `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/hints/auto/{key}` (`{enabled}`; `204`; `404 HINT_NOT_FOUND`) | PLATFORM_ADMIN |
| `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token | | `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
| `PUT` | `/api/news/seen` (`204`) | access token | | `PUT` | `/api/news/seen` (`204`) | access token |
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN | | `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
@@ -1,11 +1,14 @@
package auth_service.modules.hint.api; package auth_service.modules.hint.api;
import auth_service.modules.hint.api.dto.request.AutoHintRequest;
import auth_service.modules.hint.api.dto.request.CreateHintRequest; 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.AdminHintListResponse;
import auth_service.modules.hint.api.dto.response.AdminHintResponse; import auth_service.modules.hint.api.dto.response.AdminHintResponse;
import auth_service.modules.hint.api.dto.response.AutoHintResponse;
import auth_service.modules.hint.api.dto.response.CreatedHintResponse; import auth_service.modules.hint.api.dto.response.CreatedHintResponse;
import auth_service.modules.hint.model.AdminHint; import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.model.HintTarget;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.service.AdminHintService; import auth_service.modules.hint.service.AdminHintService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
@@ -32,14 +35,18 @@ public class AdminHintController {
@GetMapping @GetMapping
public ResponseEntity<AdminHintListResponse> findAll() { public ResponseEntity<AdminHintListResponse> findAll() {
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream() return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
.map(AdminHintController::toApi).toList())); .map(AdminHintController::toApi).toList(),
adminHintService.findAutoHints().stream()
.map(state -> new AutoHintResponse(state.key().name(), state.enabled())).toList()));
} }
@PostMapping @PostMapping
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody CreateHintRequest request) { @Valid @RequestBody CreateHintRequest request) {
long id = adminHintService.create(principal.getIdAsLong(), long id = adminHintService.create(principal.getIdAsLong(),
new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt())); new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt(),
new HintTarget(Boolean.TRUE.equals(request.targetNoSteam()), request.targetRole(),
request.targetNewDays(), Boolean.TRUE.equals(request.targetEmptyLibrary()))));
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id)); return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
} }
@@ -49,9 +56,19 @@ public class AdminHintController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/auto/{key}")
public ResponseEntity<Void> setAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key,
@Valid @RequestBody AutoHintRequest request) {
adminHintService.setAutoHintEnabled(principal.getIdAsLong(), key, request.enabled());
return ResponseEntity.noContent().build();
}
private static AdminHintResponse toApi(AdminHint admin) { private static AdminHintResponse toApi(AdminHint admin) {
Hint hint = admin.hint(); Hint hint = admin.hint();
HintTarget target = hint.target();
return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(), 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()); hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount(),
target.noSteam(), target.role() == null ? null : target.role().name(), target.newDays(),
target.emptyLibrary());
} }
} }
@@ -2,6 +2,7 @@ package auth_service.modules.hint.api;
import auth_service.modules.hint.api.dto.response.HintListResponse; import auth_service.modules.hint.api.dto.response.HintListResponse;
import auth_service.modules.hint.api.dto.response.HintResponse; import auth_service.modules.hint.api.dto.response.HintResponse;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.service.HintService; import auth_service.modules.hint.service.HintService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
@@ -23,8 +24,23 @@ public class HintController {
@GetMapping @GetMapping
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream() long userId = principal.getIdAsLong();
.map(HintController::toApi).toList())); return ResponseEntity.ok(new HintListResponse(
hintService.activeHints(userId, principal.role()).stream().map(HintController::toApi).toList(),
hintService.autoHints(userId).stream().map(AutoHint::name).toList()));
}
@PutMapping(value = "/auto/{key}/snooze")
public ResponseEntity<Void> snoozeAuto(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String key) {
hintService.snoozeAutoHint(principal.getIdAsLong(), key);
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/auto/{key}/done")
public ResponseEntity<Void> hideAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key) {
hintService.hideAutoHintForGood(principal.getIdAsLong(), key);
return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/{hintId}/done") @PutMapping(value = "/{hintId}/done")
@@ -35,6 +51,6 @@ public class HintController {
private static HintResponse toApi(Hint hint) { private static HintResponse toApi(Hint hint) {
return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(), return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
hint.endsAt()); hint.endsAt(), hint.target().emptyLibrary());
} }
} }
@@ -0,0 +1,6 @@
package auth_service.modules.hint.api.dto.request;
import jakarta.validation.constraints.NotNull;
public record AutoHintRequest(@NotNull Boolean enabled) {
}
@@ -1,6 +1,9 @@
package auth_service.modules.hint.api.dto.request; package auth_service.modules.hint.api.dto.request;
import auth_service.modules.hint.model.HintKind; import auth_service.modules.hint.model.HintKind;
import auth_service.shared.security.Role;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -8,8 +11,12 @@ import jakarta.validation.constraints.Size;
import java.time.Instant; import java.time.Instant;
/** /**
* Plain text, never read as HTML. * Plain text with {@code **bold**} and {@code [text](url)}; the Frontend renders it, never as HTML.
* Target fields are optional, missing = every user. Wrappers, because a missing primitive is a 400.
* {@code targetRole} means this role and higher.
*/ */
public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe, public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe,
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt) { @NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt,
Boolean targetNoSteam, Role targetRole, @Min(1) @Max(365) Integer targetNewDays,
Boolean targetEmptyLibrary) {
} }
@@ -2,5 +2,5 @@ package auth_service.modules.hint.api.dto.response;
import java.util.List; import java.util.List;
public record AdminHintListResponse(List<AdminHintResponse> results) { public record AdminHintListResponse(List<AdminHintResponse> results, List<AutoHintResponse> automatic) {
} }
@@ -3,5 +3,7 @@ package auth_service.modules.hint.api.dto.response;
import java.time.Instant; import java.time.Instant;
public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt, public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
String status, long createdBy, Instant createdAt, long doneCount) { String status, long createdBy, Instant createdAt, long doneCount,
boolean targetNoSteam, String targetRole, Integer targetNewDays,
boolean targetEmptyLibrary) {
} }
@@ -0,0 +1,4 @@
package auth_service.modules.hint.api.dto.response;
public record AutoHintResponse(String key, boolean enabled) {
}
@@ -2,5 +2,8 @@ package auth_service.modules.hint.api.dto.response;
import java.util.List; import java.util.List;
public record HintListResponse(List<HintResponse> results) { /**
* @param automatic keys of the automatic hints that are on and not hidden for this user
*/
public record HintListResponse(List<HintResponse> results, List<String> automatic) {
} }
@@ -2,5 +2,6 @@ package auth_service.modules.hint.api.dto.response;
import java.time.Instant; import java.time.Instant;
public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt) { public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
boolean emptyLibraryOnly) {
} }
@@ -4,4 +4,8 @@ public class HintNotFoundException extends RuntimeException {
public HintNotFoundException(long hintId) { public HintNotFoundException(long hintId) {
super("No hint with id " + hintId); super("No hint with id " + hintId);
} }
public HintNotFoundException(String key) {
super("No automatic hint " + key);
}
} }
@@ -0,0 +1,18 @@
package auth_service.modules.hint.model;
import auth_service.modules.hint.exception.HintNotFoundException;
/**
* Built-in hints. The Frontend owns the texts and the conditions.
*/
public enum AutoHint {
NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE;
public static AutoHint parse(String key) {
try {
return valueOf(key);
} catch (IllegalArgumentException _) {
throw new HintNotFoundException(key);
}
}
}
@@ -0,0 +1,4 @@
package auth_service.modules.hint.model;
public record AutoHintState(AutoHint key, boolean enabled) {
}
@@ -5,5 +5,6 @@ import java.time.Instant;
/** /**
* @param textDe null: German users see {@code textEn} * @param textDe null: German users see {@code textEn}
*/ */
public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) { public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
HintTarget target) {
} }
@@ -0,0 +1,15 @@
package auth_service.modules.hint.model;
import auth_service.shared.security.Role;
/**
* Target groups of a hint, combined with AND.
*
* @param role null: every role; else this role and higher (by the active role)
* @param newDays null: any registration date; else registered less than this many days ago
* @param emptyLibrary checked by the Frontend with Core's library summary
*/
public record HintTarget(boolean noSteam, Role role, Integer newDays, boolean emptyLibrary) {
public static final HintTarget ALL = new HintTarget(false, null, null, false);
}
@@ -2,5 +2,6 @@ package auth_service.modules.hint.model;
import java.time.Instant; import java.time.Instant;
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) { public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
HintTarget target) {
} }
@@ -1,23 +1,31 @@
package auth_service.modules.hint.repo; package auth_service.modules.hint.repo;
import auth_service.modules.hint.model.AdminHint; import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.AutoHintState;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.model.HintKind; import auth_service.modules.hint.model.HintKind;
import auth_service.modules.hint.model.HintStatus; import auth_service.modules.hint.model.HintStatus;
import auth_service.modules.hint.model.HintTarget;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.shared.security.Role;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@Repository @Repository
public class HintRepository { 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 String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at, "
+ "h.target_no_steam, h.target_role, h.target_new_days, h.target_empty_library";
private static final RowMapper<Hint> HINT = (rs, row) -> new Hint( private static final RowMapper<Hint> HINT = (rs, row) -> new Hint(
rs.getLong("id"), rs.getLong("id"),
@@ -25,7 +33,9 @@ public class HintRepository {
rs.getString("text_de"), rs.getString("text_de"),
HintKind.valueOf(rs.getString("kind")), HintKind.valueOf(rs.getString("kind")),
rs.getTimestamp("starts_at").toInstant(), rs.getTimestamp("starts_at").toInstant(),
rs.getTimestamp("ends_at").toInstant()); rs.getTimestamp("ends_at").toInstant(),
new HintTarget(rs.getBoolean("target_no_steam"), role(rs.getString("target_role")),
rs.getObject("target_new_days", Integer.class), rs.getBoolean("target_empty_library")));
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@@ -33,13 +43,56 @@ public class HintRepository {
this.jdbc = jdbc; this.jdbc = jdbc;
} }
public List<Hint> findActiveFor(long userId, Instant now) { /**
* @param role the active role of the caller; a target role matches this role and higher
*/
public List<Hint> findActiveFor(long userId, Role role, Instant now) {
Timestamp at = Timestamp.from(now); Timestamp at = Timestamp.from(now);
// registration_date is naive UTC
LocalDateTime utc = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
return jdbc.query(""" return jdbc.query("""
SELECT %s FROM hint h SELECT %s FROM hint h JOIN users u ON u.id = ?
WHERE h.starts_at <= ? AND h.ends_at > ? 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 = ?) AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = u.id)
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, at, at, userId); AND (NOT h.target_no_steam OR u.steam_id IS NULL)
AND (h.target_role IS NULL
OR CASE h.target_role WHEN 'USER' THEN 0 WHEN 'AUDITOR' THEN 1 ELSE 2 END <= ?)
AND (h.target_new_days IS NULL
OR u.registration_date > ?::timestamp - make_interval(days => h.target_new_days))
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, userId, at, at, role.ordinal(),
utc);
}
public List<AutoHint> findVisibleAutoHints(long userId, Instant now) {
return jdbc.query("""
SELECT a.hint_key FROM auto_hint a
WHERE a.enabled
AND NOT EXISTS (SELECT 1 FROM auto_hint_hidden x
WHERE x.user_id = ? AND x.hint_key = a.hint_key
AND (x.hidden_until IS NULL OR x.hidden_until > ?))""",
(rs, row) -> AutoHint.valueOf(rs.getString("hint_key")), userId, Timestamp.from(now))
.stream().sorted().toList();
}
/**
* @param until null: for good. A snooze never undoes "for good".
*/
public void hideAutoHint(long userId, AutoHint key, Instant until) {
jdbc.update("""
INSERT INTO auto_hint_hidden (user_id, hint_key, hidden_until) VALUES (?, ?, ?)
ON CONFLICT (user_id, hint_key) DO UPDATE SET hidden_until =
CASE WHEN auto_hint_hidden.hidden_until IS NULL THEN NULL ELSE EXCLUDED.hidden_until END""",
userId, key.name(), until == null ? null : Timestamp.from(until));
}
public List<AutoHintState> findAutoHints() {
return jdbc.query("SELECT hint_key, enabled FROM auto_hint",
(rs, row) -> new AutoHintState(AutoHint.valueOf(rs.getString("hint_key")), rs.getBoolean("enabled")))
.stream().sorted(Comparator.comparing(AutoHintState::key)).toList();
}
public void setAutoHintEnabled(AutoHint key, boolean enabled) {
jdbc.update("UPDATE auto_hint SET enabled = ? WHERE hint_key = ?", enabled, key.name());
} }
public List<AdminHint> findAll(Instant now) { public List<AdminHint> findAll(Instant now) {
@@ -61,10 +114,14 @@ public class HintRepository {
} }
public long insert(NewHint hint, long createdBy) { public long insert(NewHint hint, long createdBy) {
HintTarget target = hint.target();
return Objects.requireNonNull(jdbc.queryForObject(""" return Objects.requireNonNull(jdbc.queryForObject("""
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by) VALUES (?, ?, ?, ?, ?, ?) INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by, target_no_steam, target_role,
target_new_days, target_empty_library)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(), RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(),
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy)); Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy, target.noSteam(),
target.role() == null ? null : target.role().name(), target.newDays(), target.emptyLibrary()));
} }
public void markDone(long hintId, long userId) { public void markDone(long hintId, long userId) {
@@ -81,4 +138,9 @@ public class HintRepository {
return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?", return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?",
at, at, hintId, at) > 0; at, at, hintId, at) > 0;
} }
// Not Role.parse: it turns null into USER, and null here means "every role".
private static Role role(String name) {
return name == null ? null : Role.valueOf(name);
}
} }
@@ -3,6 +3,8 @@ package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException; import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.exception.InvalidHintException; import auth_service.modules.hint.exception.InvalidHintException;
import auth_service.modules.hint.model.AdminHint; import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.AutoHintState;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.repo.HintRepository; import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.audit.AdminAuditLog; import auth_service.shared.audit.AdminAuditLog;
@@ -38,8 +40,8 @@ public class AdminHintService {
throw new InvalidHintException("The end must be in the future"); throw new InvalidHintException("The end must be in the future");
} }
String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe(); 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()), long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt(),
actorUserId); hint.target()), actorUserId);
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id)); adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
log.info("Admin {} created hint {}", actorUserId, id); log.info("Admin {} created hint {}", actorUserId, id);
return id; return id;
@@ -55,4 +57,16 @@ public class AdminHintService {
log.info("Admin {} ended hint {}", actorUserId, hintId); log.info("Admin {} ended hint {}", actorUserId, hintId);
} }
} }
public List<AutoHintState> findAutoHints() {
return hintRepository.findAutoHints();
}
@Transactional
public void setAutoHintEnabled(long actorUserId, String key, boolean enabled) {
AutoHint hint = AutoHint.parse(key);
hintRepository.setAutoHintEnabled(hint, enabled);
adminAuditLog.record(actorUserId, enabled ? "AUTO_HINT_ON" : "AUTO_HINT_OFF", null, hint.name());
log.info("Admin {} turned automatic hint {} {}", actorUserId, hint, enabled ? "on" : "off");
}
} }
@@ -1,6 +1,7 @@
package auth_service.modules.hint.service; package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException; import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.repo.HintRepository; import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.security.RequiresRole; import auth_service.shared.security.RequiresRole;
@@ -8,6 +9,7 @@ import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -16,10 +18,24 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class HintService { public class HintService {
private static final Duration SNOOZE = Duration.ofDays(7);
private final HintRepository hintRepository; private final HintRepository hintRepository;
public List<Hint> activeHints(long userId) { public List<Hint> activeHints(long userId, Role role) {
return hintRepository.findActiveFor(userId, Instant.now()); return hintRepository.findActiveFor(userId, role, Instant.now());
}
public List<AutoHint> autoHints(long userId) {
return hintRepository.findVisibleAutoHints(userId, Instant.now());
}
public void snoozeAutoHint(long userId, String key) {
hintRepository.hideAutoHint(userId, AutoHint.parse(key), Instant.now().plus(SNOOZE));
}
public void hideAutoHintForGood(long userId, String key) {
hintRepository.hideAutoHint(userId, AutoHint.parse(key), null);
} }
public void markDone(long userId, long hintId) { public void markDone(long userId, long hintId) {
@@ -0,0 +1,23 @@
-- Target groups of an admin hint, combined with AND. All defaults = every user, so old hints keep working.
ALTER TABLE hint
ADD COLUMN target_no_steam BOOLEAN NOT NULL DEFAULT FALSE,
-- This role and higher.
ADD COLUMN target_role VARCHAR(16) CHECK (target_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')),
ADD COLUMN target_new_days SMALLINT CHECK (target_new_days BETWEEN 1 AND 365),
-- Checked by the Frontend with Core's library summary; Auth only sends the flag.
ADD COLUMN target_empty_library BOOLEAN NOT NULL DEFAULT FALSE;
-- The built-in hints; the texts live in the Frontend. A PLATFORM_ADMIN turns each on or off.
CREATE TABLE auto_hint (
hint_key VARCHAR(16) PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO auto_hint (hint_key) VALUES ('NO_STEAM'), ('EMPTY_LIBRARY'), ('PILE_GREW'), ('LINK_QUEUE');
-- hidden_until NULL: hidden for good ("Do not show again"); else hidden until then (X: 7 days).
CREATE TABLE auto_hint_hidden (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
hint_key VARCHAR(16) NOT NULL REFERENCES auto_hint (hint_key),
hidden_until TIMESTAMPTZ,
PRIMARY KEY (user_id, hint_key)
);
@@ -128,4 +128,70 @@ class HintFlowTest extends AbstractIntegrationTest {
create(admin, valid.replace("Link your Steam account", " ")).andExpect(status().isBadRequest()); 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()); create(admin, valid.replace("Link your Steam account", "x".repeat(501))).andExpect(status().isBadRequest());
} }
private ResultActions authPut(Cookie session, String path, String body) throws Exception {
return mockMvc.perform(put(path).header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(session)
.contentType(MediaType.APPLICATION_JSON).content(body));
}
@Test
void targetedHint_ReachesOnlyTheMatchingUsers() throws Exception {
jdbc.update("UPDATE users SET steam_id = 4711 WHERE email = ?", OTHER);
jdbc.update("UPDATE users SET registration_date = now() AT TIME ZONE 'UTC' WHERE email IN (?, ?)", PLAYER,
OTHER);
Cookie admin = login(ADMIN);
String body = hint(null, Instant.now().minusSeconds(60), Instant.now().plus(Duration.ofDays(1)));
create(admin, body.replace("}", ",\"targetNoSteam\":true,\"targetNewDays\":7,\"targetRole\":\"USER\"}"))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/hints").cookie(login(PLAYER))).andExpect(jsonPath("$.results.length()").value(1));
mockMvc.perform(get("/api/hints").cookie(login(OTHER))).andExpect(jsonPath("$.results.length()").value(0));
mockMvc.perform(get("/api/admin/hints").cookie(admin)).andExpect(jsonPath("$.results[0].targetNoSteam")
.value(true)).andExpect(jsonPath("$.results[0].targetNewDays").value(7))
.andExpect(jsonPath("$.results[0].targetRole").value("USER"));
}
@Test
void create_WithoutTargetFieldsReachesEveryone_AndRefusesZeroDays() throws Exception {
Cookie admin = login(ADMIN);
createRunning(admin);
String body = hint(null, Instant.now().minusSeconds(60), Instant.now().plus(Duration.ofDays(1)));
mockMvc.perform(get("/api/hints").cookie(login(PLAYER))).andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].emptyLibraryOnly").value(false));
create(admin, body.replace("}", ",\"targetNewDays\":0}")).andExpect(status().isBadRequest());
}
@Test
void autoHints_AreHiddenPerUserAndSwitchedByAdmin() throws Exception {
Cookie player = login(PLAYER);
Cookie other = login(OTHER);
Cookie admin = login(ADMIN);
mockMvc.perform(get("/api/hints").cookie(player)).andExpect(jsonPath("$.automatic.length()").value(4));
authPut(player, "/api/hints/auto/NO_STEAM/snooze", "").andExpect(status().isNoContent());
authPut(player, "/api/hints/auto/PILE_GREW/done", "").andExpect(status().isNoContent());
mockMvc.perform(get("/api/hints").cookie(player)).andExpect(jsonPath("$.automatic.length()").value(2));
mockMvc.perform(get("/api/hints").cookie(other)).andExpect(jsonPath("$.automatic.length()").value(4));
authPut(player, "/api/hints/auto/NOPE/done", "").andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("HINT_NOT_FOUND"));
authPut(admin, "/api/admin/hints/auto/LINK_QUEUE", "{\"enabled\":false}").andExpect(status().isNoContent());
try {
mockMvc.perform(get("/api/hints").cookie(other)).andExpect(jsonPath("$.automatic.length()").value(3));
mockMvc.perform(get("/api/admin/hints").cookie(admin))
.andExpect(jsonPath("$.automatic.length()").value(4))
.andExpect(jsonPath("$.automatic[3].enabled").value(false));
} finally {
authPut(admin, "/api/admin/hints/auto/LINK_QUEUE", "{\"enabled\":true}");
}
}
@Test
void autoHintToggle_RefusesUsersAndAMissingFlag() throws Exception {
authPut(login(PLAYER), "/api/admin/hints/auto/NO_STEAM", "{\"enabled\":false}")
.andExpect(status().isForbidden());
authPut(login(ADMIN), "/api/admin/hints/auto/NO_STEAM", "{}").andExpect(status().isBadRequest());
}
} }
@@ -1,19 +1,24 @@
package auth_service.modules.hint.repo; package auth_service.modules.hint.repo;
import auth_service.modules.hint.model.AdminHint; import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.model.HintKind; import auth_service.modules.hint.model.HintKind;
import auth_service.modules.hint.model.HintStatus; import auth_service.modules.hint.model.HintStatus;
import auth_service.modules.hint.model.HintTarget;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractPersistenceTest; import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple; import static org.assertj.core.api.Assertions.tuple;
@@ -38,7 +43,7 @@ class HintRepositoryTest extends AbstractPersistenceTest {
} }
private long hint(String text, Instant startsAt, Instant endsAt) { private long hint(String text, Instant startsAt, Instant endsAt) {
return repository.insert(new NewHint(text, null, HintKind.DISMISS, startsAt, endsAt), alice); return repository.insert(new NewHint(text, null, HintKind.DISMISS, startsAt, endsAt, HintTarget.ALL), alice);
} }
@BeforeEach @BeforeEach
@@ -56,7 +61,7 @@ class HintRepositoryTest extends AbstractPersistenceTest {
hint("older", NOW.minus(DAY.multipliedBy(2)), NOW.plus(DAY)); hint("older", NOW.minus(DAY.multipliedBy(2)), NOW.plus(DAY));
hint("newer", NOW.minus(DAY), NOW.plus(DAY)); hint("newer", NOW.minus(DAY), NOW.plus(DAY));
assertThat(repository.findActiveFor(alice, NOW)).extracting(Hint::textEn).containsExactly("newer", "older"); assertThat(texts(alice, Role.USER)).containsExactly("newer", "older");
} }
@Test @Test
@@ -77,8 +82,8 @@ class HintRepositoryTest extends AbstractPersistenceTest {
repository.markDone(id, alice); repository.markDone(id, alice);
repository.markDone(id, alice); repository.markDone(id, alice);
assertThat(repository.findActiveFor(alice, NOW)).isEmpty(); assertThat(repository.findActiveFor(alice, Role.USER, NOW)).isEmpty();
assertThat(repository.findActiveFor(bob, NOW)).extracting(Hint::id).containsExactly(id); assertThat(repository.findActiveFor(bob, Role.USER, NOW)).extracting(Hint::id).containsExactly(id);
assertThat(repository.findAll(NOW)).extracting(AdminHint::doneCount).containsExactly(1L); assertThat(repository.findAll(NOW)).extracting(AdminHint::doneCount).containsExactly(1L);
} }
@@ -101,7 +106,113 @@ class HintRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.end(planned, NOW)).isTrue(); assertThat(repository.end(planned, NOW)).isTrue();
assertThat(repository.end(running, NOW)).isFalse(); assertThat(repository.end(running, NOW)).isFalse();
assertThat(repository.findActiveFor(alice, NOW)).isEmpty(); assertThat(repository.findActiveFor(alice, Role.USER, NOW)).isEmpty();
assertThat(repository.findActiveFor(alice, NOW.plus(DAY.multipliedBy(3).dividedBy(2)))).isEmpty(); assertThat(repository.findActiveFor(alice, Role.USER, NOW.plus(DAY.multipliedBy(3).dividedBy(2)))).isEmpty();
}
private long targeted(String text, HintTarget target) {
return repository.insert(new NewHint(text, null, HintKind.DISMISS, NOW.minus(DAY), NOW.plus(DAY), target),
alice);
}
private long user(String email, Long steamId, Instant registered) {
long id = user(email);
jdbc.update("UPDATE users SET steam_id = ?, registration_date = ? WHERE id = ?", steamId,
registered == null ? null : Timestamp.from(registered), id);
return id;
}
private List<String> texts(long userId, Role role) {
return repository.findActiveFor(userId, role, NOW).stream().map(Hint::textEn).toList();
}
@Test
void findActiveFor_HintWithoutTargetReachesEveryone() {
hint("all", NOW.minus(DAY), NOW.plus(DAY));
long oldWithSteam = user("old@example.com", 1L, NOW.minus(DAY.multipliedBy(900)));
assertThat(texts(oldWithSteam, Role.USER)).containsExactly("all");
assertThat(texts(alice, Role.USER)).containsExactly("all");
}
@Test
void findActiveFor_NewAndWithoutSteamReachesOnlyBoth() {
targeted("both", new HintTarget(true, null, 7, false));
long newNoSteam = user("new-no-steam@example.com", null, NOW.minus(DAY.multipliedBy(2)));
long newSteam = user("new-steam@example.com", 2L, NOW.minus(DAY.multipliedBy(2)));
long oldNoSteam = user("old-no-steam@example.com", null, NOW.minus(DAY.multipliedBy(30)));
assertThat(texts(newNoSteam, Role.USER)).containsExactly("both");
assertThat(texts(newSteam, Role.USER)).isEmpty();
assertThat(texts(oldNoSteam, Role.USER)).isEmpty();
}
@Test
void findActiveFor_NullRegistrationDateIsNotNew() {
targeted("new", new HintTarget(false, null, 7, false));
long unknown = user("unknown@example.com", null, null);
assertThat(texts(unknown, Role.USER)).isEmpty();
}
@Test
void findActiveFor_RoleTargetMatchesThisRoleAndHigher() {
targeted("user", new HintTarget(false, Role.USER, null, false));
targeted("auditor", new HintTarget(false, Role.AUDITOR, null, false));
targeted("admin", new HintTarget(false, Role.PLATFORM_ADMIN, null, false));
assertThat(texts(alice, Role.USER)).containsExactly("user");
assertThat(texts(alice, Role.AUDITOR)).containsExactlyInAnyOrder("user", "auditor");
assertThat(texts(alice, Role.PLATFORM_ADMIN)).containsExactlyInAnyOrder("user", "auditor", "admin");
}
@Test
void findActiveFor_SendsTheEmptyLibraryFlag() {
targeted("empty", new HintTarget(false, null, null, true));
assertThat(repository.findActiveFor(alice, Role.USER, NOW)).extracting(h -> h.target().emptyLibrary())
.containsExactly(true);
}
@Test
void findVisibleAutoHints_HidesSnoozedUntilThenAndForever() {
repository.hideAutoHint(alice, AutoHint.NO_STEAM, NOW.plus(DAY));
repository.hideAutoHint(alice, AutoHint.PILE_GREW, null);
assertThat(repository.findVisibleAutoHints(alice, NOW))
.containsExactly(AutoHint.EMPTY_LIBRARY, AutoHint.LINK_QUEUE);
assertThat(repository.findVisibleAutoHints(alice, NOW.plus(DAY.multipliedBy(2))))
.containsExactly(AutoHint.NO_STEAM, AutoHint.EMPTY_LIBRARY, AutoHint.LINK_QUEUE);
assertThat(repository.findVisibleAutoHints(bob, NOW)).hasSize(4);
}
@Test
void hideAutoHint_SnoozeNeverUndoesForever() {
repository.hideAutoHint(alice, AutoHint.NO_STEAM, null);
repository.hideAutoHint(alice, AutoHint.NO_STEAM, NOW.plus(DAY));
assertThat(repository.findVisibleAutoHints(alice, NOW.plus(DAY.multipliedBy(2))))
.doesNotContain(AutoHint.NO_STEAM);
}
@Test
void findVisibleAutoHints_SkipsDisabled() {
repository.setAutoHintEnabled(AutoHint.LINK_QUEUE, false);
try {
assertThat(repository.findVisibleAutoHints(alice, NOW)).doesNotContain(AutoHint.LINK_QUEUE);
assertThat(repository.findAutoHints()).filteredOn(state -> !state.enabled()).hasSize(1);
} finally {
repository.setAutoHintEnabled(AutoHint.LINK_QUEUE, true);
}
}
@Test
void deletedUser_LosesTheHiddenAutoHints() {
repository.hideAutoHint(bob, AutoHint.NO_STEAM, null);
jdbc.update("DELETE FROM users WHERE id = ?", bob);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auto_hint_hidden", Long.class)).isZero();
} }
} }
@@ -2,10 +2,13 @@ package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException; import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.exception.InvalidHintException; import auth_service.modules.hint.exception.InvalidHintException;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.HintKind; import auth_service.modules.hint.model.HintKind;
import auth_service.modules.hint.model.HintTarget;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.repo.HintRepository; import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.audit.AdminAuditLog; import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -45,7 +48,7 @@ class AdminHintServiceTest extends AbstractUnitTest {
} }
private static NewHint hint(Instant startsAt, Instant endsAt) { private static NewHint hint(Instant startsAt, Instant endsAt) {
return new NewHint("Link your Steam account", null, HintKind.ACCEPT, startsAt, endsAt); return new NewHint("Link your Steam account", null, HintKind.ACCEPT, startsAt, endsAt, HintTarget.ALL);
} }
@Test @Test
@@ -85,4 +88,32 @@ class AdminHintServiceTest extends AbstractUnitTest {
assertThatThrownBy(() -> service.end(ADMIN, 7L)).isInstanceOf(HintNotFoundException.class); assertThatThrownBy(() -> service.end(ADMIN, 7L)).isInstanceOf(HintNotFoundException.class);
verify(adminAuditLog, never()).record(anyLong(), anyString(), isNull(), anyString()); verify(adminAuditLog, never()).record(anyLong(), anyString(), isNull(), anyString());
} }
@Test
void create_KeepsTheTarget() {
HintTarget target = new HintTarget(true, Role.AUDITOR, 7, true);
NewHint hint = new NewHint("x", " ", HintKind.DISMISS, Instant.now(), Instant.now().plus(DAY), target);
service.create(ADMIN, hint);
verify(hintRepository).insert(new NewHint("x", null, HintKind.DISMISS, hint.startsAt(), hint.endsAt(), target),
ADMIN);
}
@Test
void setAutoHintEnabled_Audits() {
service.setAutoHintEnabled(ADMIN, "PILE_GREW", false);
service.setAutoHintEnabled(ADMIN, "PILE_GREW", true);
verify(hintRepository).setAutoHintEnabled(AutoHint.PILE_GREW, false);
verify(adminAuditLog).record(ADMIN, "AUTO_HINT_OFF", null, "PILE_GREW");
verify(adminAuditLog).record(ADMIN, "AUTO_HINT_ON", null, "PILE_GREW");
}
@Test
void setAutoHintEnabled_UnknownKey() {
assertThatThrownBy(() -> service.setAutoHintEnabled(ADMIN, "NOPE", true))
.isInstanceOf(HintNotFoundException.class);
verifyNoInteractions(adminAuditLog);
}
} }