Public Access
feat(hint): target groups and automatic hints, V30
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:
1 parent
5a2552ddf6
commit
19463a1a4d
23 files changed
+468
-36
No files matched your search
@@ -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", "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;
|
||||
|
||||
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.HintKind;
|
||||
import auth_service.modules.hint.model.HintStatus;
|
||||
import auth_service.modules.hint.model.HintTarget;
|
||||
import auth_service.modules.hint.model.NewHint;
|
||||
import auth_service.shared.security.Role;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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) {
|
||||
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
|
||||
@@ -56,7 +61,7 @@ class HintRepositoryTest extends AbstractPersistenceTest {
|
||||
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");
|
||||
assertThat(texts(alice, Role.USER)).containsExactly("newer", "older");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -77,8 +82,8 @@ class HintRepositoryTest extends AbstractPersistenceTest {
|
||||
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.findActiveFor(alice, Role.USER, NOW)).isEmpty();
|
||||
assertThat(repository.findActiveFor(bob, Role.USER, NOW)).extracting(Hint::id).containsExactly(id);
|
||||
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(running, NOW)).isFalse();
|
||||
|
||||
assertThat(repository.findActiveFor(alice, NOW)).isEmpty();
|
||||
assertThat(repository.findActiveFor(alice, NOW.plus(DAY.multipliedBy(3).dividedBy(2)))).isEmpty();
|
||||
assertThat(repository.findActiveFor(alice, Role.USER, NOW)).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.InvalidHintException;
|
||||
import auth_service.modules.hint.model.AutoHint;
|
||||
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.repo.HintRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -45,7 +48,7 @@ class AdminHintServiceTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -85,4 +88,32 @@ class AdminHintServiceTest extends AbstractUnitTest {
|
||||
assertThatThrownBy(() -> service.end(ADMIN, 7L)).isInstanceOf(HintNotFoundException.class);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user