From 57735a338df3f38808ef8f9160c6ccfeed461610 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Fri, 1 May 2026 14:26:39 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=EC=BB=A8=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=ED=9B=84=20=EC=95=8C=EB=A6=BC=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EC=9D=98=20=ED=83=80=EC=9E=85=EC=9D=84=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/event/AdminNotificationWorker.java | 11 +++++++++-- .../notification/core/entity/NotificationType.java | 1 - src/main/resources/config/notification-messages.yml | 3 --- .../V90__merge_content_deleted_notification_type.sql | 3 +++ .../event/AdminNotificationWorkerTest.java | 7 +++++-- 5 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/db/migration/V90__merge_content_deleted_notification_type.sql diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java index 263a2b54..d8a7695d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java @@ -16,6 +16,9 @@ @RequiredArgsConstructor public class AdminNotificationWorker { + private static final String CONTENT_DELETED_TITLE = "콘텐츠 삭제 안내"; + private static final String CONTENT_DELETED_BODY_FORMAT = "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: %s"; + private final NotifySystemService notifySystemService; @Async("pushNotificationExecutor") @@ -42,10 +45,14 @@ public void handle(AdminNotificationEvent.AdminMessageSent event) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(AdminNotificationEvent.ContentDeletedByReport event) { try { - Map extras = Map.of("reason", event.reason()); + Map extras = Map.of( + "title", CONTENT_DELETED_TITLE, + "body", CONTENT_DELETED_BODY_FORMAT.formatted(event.reason()), + "reason", event.reason() + ); notifySystemService.notifyUser( event.contentOwnerId(), - NotificationType.SYSTEM_CONTENT_DELETED, + NotificationType.SYSTEM_ADMIN_MESSAGE, null, extras ); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index 98c9d22c..84f01bd8 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -19,5 +19,4 @@ public enum NotificationType { // ---- System Notification Types ---- SYSTEM_PUBLISHED_ANNOUNCEMENT, SYSTEM_ADMIN_MESSAGE, - SYSTEM_CONTENT_DELETED, } diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 41023359..516e772b 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -26,6 +26,3 @@ notification-messages: SYSTEM_ADMIN_MESSAGE: push-title: "{title}" push-body: "{body}" - SYSTEM_CONTENT_DELETED: - push-title: "콘텐츠 삭제 안내" - push-body: "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: {reason}" \ No newline at end of file diff --git a/src/main/resources/db/migration/V90__merge_content_deleted_notification_type.sql b/src/main/resources/db/migration/V90__merge_content_deleted_notification_type.sql new file mode 100644 index 00000000..8550832e --- /dev/null +++ b/src/main/resources/db/migration/V90__merge_content_deleted_notification_type.sql @@ -0,0 +1,3 @@ +UPDATE notification +SET type = 'SYSTEM_ADMIN_MESSAGE' +WHERE type = 'SYSTEM_CONTENT_DELETED'; diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java index 96db8ed9..41cde3ad 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java @@ -71,12 +71,15 @@ void handle_contentDeletedByReport_callsNotifyUser() { verify(notifySystemService).notifyUser( eq(42L), - eq(NotificationType.SYSTEM_CONTENT_DELETED), + eq(NotificationType.SYSTEM_ADMIN_MESSAGE), isNull(), extrasCaptor.capture() ); - assertThat(extrasCaptor.getValue()).containsEntry("reason", "커뮤니티 가이드라인 위반"); + assertThat(extrasCaptor.getValue()) + .containsEntry("title", "콘텐츠 삭제 안내") + .containsEntry("body", "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: 커뮤니티 가이드라인 위반") + .containsEntry("reason", "커뮤니티 가이드라인 위반"); } @Test From 156cad32ff447940767b03f7b5366f11c93beebd Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Fri, 1 May 2026 15:08:48 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=ED=99=9C=EC=84=B1=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20id=EB=8F=84=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AdminUserQueryService.java | 17 ++++- .../user/core/repository/UserRepository.java | 43 +++++++---- .../core/repository/UserRepositoryTest.java | 76 +++++++++++++++---- 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java index d9ab9981..6d8f1061 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java @@ -18,10 +18,11 @@ public class AdminUserQueryService { public AdminUserListResponse listUsers(String query, int page, int size) { String normalizedQuery = normalizeQuery(query); + Long idQuery = parseIdQuery(normalizedQuery); int offset = (page - 1) * size; - List users = userRepository.findActiveNicknameUsers(normalizedQuery, offset, size); - long totalElements = userRepository.countActiveNicknameUsers(normalizedQuery); + List users = userRepository.searchActiveUsers(normalizedQuery, idQuery, offset, size); + long totalElements = userRepository.countSearchActiveUsers(normalizedQuery, idQuery); int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size); List items = users.stream() @@ -37,4 +38,16 @@ private String normalizeQuery(String query) { } return query.trim(); } + + private Long parseIdQuery(String query) { + if (query == null) { + return null; + } + try { + long value = Long.parseLong(query); + return value > 0 ? value : null; + } catch (NumberFormatException ignored) { + return null; + } + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java index 7bb154f5..c0a33346 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.user.core.repository; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; import org.devkor.apu.saerok_server.domain.user.core.entity.User; @@ -62,7 +63,7 @@ public List findActiveUserIds(int offset, int limit) { .getResultList(); } - public List findActiveNicknameUsers(String nicknameQuery, int offset, int limit) { + public List searchActiveUsers(String nicknameQuery, Long idQuery, int offset, int limit) { String jpql = """ SELECT u FROM User u WHERE u.deletedAt IS NULL @@ -70,9 +71,7 @@ public List findActiveNicknameUsers(String nicknameQuery, int offset, int AND u.nickname IS NOT NULL AND TRIM(u.nickname) <> '' """; - if (nicknameQuery != null) { - jpql += " AND u.nickname LIKE :nicknameQuery"; - } + jpql += buildSearchPredicate(nicknameQuery, idQuery); jpql += " ORDER BY u.nickname ASC, u.id ASC"; var query = em.createQuery(jpql, User.class) @@ -80,14 +79,12 @@ AND TRIM(u.nickname) <> '' .setFirstResult(offset) .setMaxResults(limit); - if (nicknameQuery != null) { - query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); - } + bindSearchParameters(query, nicknameQuery, idQuery); return query.getResultList(); } - public long countActiveNicknameUsers(String nicknameQuery) { + public long countSearchActiveUsers(String nicknameQuery, Long idQuery) { String jpql = """ SELECT COUNT(u) FROM User u WHERE u.deletedAt IS NULL @@ -95,18 +92,38 @@ SELECT COUNT(u) FROM User u AND u.nickname IS NOT NULL AND TRIM(u.nickname) <> '' """; - if (nicknameQuery != null) { - jpql += " AND u.nickname LIKE :nicknameQuery"; - } + jpql += buildSearchPredicate(nicknameQuery, idQuery); var query = em.createQuery(jpql, Long.class) .setParameter("withdrawn", SignupStatusType.WITHDRAWN); + bindSearchParameters(query, nicknameQuery, idQuery); + + return query.getSingleResult(); + } + + private String buildSearchPredicate(String nicknameQuery, Long idQuery) { + boolean hasNickname = nicknameQuery != null; + boolean hasId = idQuery != null; + if (hasNickname && hasId) { + return " AND (u.nickname LIKE :nicknameQuery OR u.id = :idQuery)"; + } + if (hasNickname) { + return " AND u.nickname LIKE :nicknameQuery"; + } + if (hasId) { + return " AND u.id = :idQuery"; + } + return ""; + } + + private void bindSearchParameters(Query query, String nicknameQuery, Long idQuery) { if (nicknameQuery != null) { query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); } - - return query.getSingleResult(); + if (idQuery != null) { + query.setParameter("idQuery", idQuery); + } } public List findByIds(List ids) { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java index 1f53c113..55859865 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java @@ -138,8 +138,8 @@ void findActiveUserIds_empty() { assertThat(activeIds).isEmpty(); } - @Test @DisplayName("findActiveNicknameUsers - 활성 닉네임 사용자만 닉네임순으로 조회") - void findActiveNicknameUsers() { + @Test @DisplayName("searchActiveUsers - 활성 닉네임 사용자만 닉네임순으로 조회") + void searchActiveUsers() { User charlie = user("charlie@example.com", "charlie"); charlie.setSignupStatus(SignupStatusType.COMPLETED); User bravo = user("bravo@example.com", "bravo"); @@ -156,16 +156,16 @@ void findActiveNicknameUsers() { deleted.softDelete(); em.flush(); em.clear(); - List users = repo.findActiveNicknameUsers(null, 0, 20); + List users = repo.searchActiveUsers(null, null, 0, 20); assertThat(users) .extracting(User::getNickname) .containsExactly("bravo", "charlie"); - assertThat(repo.countActiveNicknameUsers(null)).isEqualTo(2); + assertThat(repo.countSearchActiveUsers(null, null)).isEqualTo(2); } - @Test @DisplayName("findActiveNicknameUsers - 닉네임 검색과 페이징") - void findActiveNicknameUsers_queryAndPagination() { + @Test @DisplayName("searchActiveUsers - 닉네임 검색과 페이징") + void searchActiveUsers_queryAndPagination() { User alpha = user("alpha@example.com", "alpha"); alpha.setSignupStatus(SignupStatusType.COMPLETED); User alpine = user("alpine@example.com", "alpine"); @@ -174,8 +174,8 @@ void findActiveNicknameUsers_queryAndPagination() { bravo.setSignupStatus(SignupStatusType.COMPLETED); em.flush(); em.clear(); - List firstPage = repo.findActiveNicknameUsers("alp", 0, 1); - List secondPage = repo.findActiveNicknameUsers("alp", 1, 1); + List firstPage = repo.searchActiveUsers("alp", null, 0, 1); + List secondPage = repo.searchActiveUsers("alp", null, 1, 1); assertThat(firstPage) .extracting(User::getNickname) @@ -183,11 +183,11 @@ void findActiveNicknameUsers_queryAndPagination() { assertThat(secondPage) .extracting(User::getNickname) .containsExactly("alpine"); - assertThat(repo.countActiveNicknameUsers("alp")).isEqualTo(2); + assertThat(repo.countSearchActiveUsers("alp", null)).isEqualTo(2); } - @Test @DisplayName("findActiveNicknameUsers - 닉네임 중간 문자열 검색") - void findActiveNicknameUsers_containsQuery() { + @Test @DisplayName("searchActiveUsers - 닉네임 중간 문자열 검색") + void searchActiveUsers_containsQuery() { User duli = user("duli@example.com", "둘리"); duli.setSignupStatus(SignupStatusType.COMPLETED); User pigeon = user("pigeon@example.com", "비둘기"); @@ -196,12 +196,62 @@ void findActiveNicknameUsers_containsQuery() { magpie.setSignupStatus(SignupStatusType.COMPLETED); em.flush(); em.clear(); - List users = repo.findActiveNicknameUsers("둘", 0, 20); + List users = repo.searchActiveUsers("둘", null, 0, 20); assertThat(users) .extracting(User::getNickname) .containsExactly("둘리", "비둘기"); - assertThat(repo.countActiveNicknameUsers("둘")).isEqualTo(2); + assertThat(repo.countSearchActiveUsers("둘", null)).isEqualTo(2); + } + + @Test @DisplayName("searchActiveUsers - ID 단독 검색") + void searchActiveUsers_byIdOnly() { + User alpha = user("id-alpha@example.com", "id-alpha"); + alpha.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("id-bravo@example.com", "id-bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers(null, alpha.getId(), 0, 20); + + assertThat(users) + .extracting(User::getId) + .containsExactly(alpha.getId()); + assertThat(repo.countSearchActiveUsers(null, alpha.getId())).isEqualTo(1); + } + + @Test @DisplayName("searchActiveUsers - ID와 닉네임 OR 검색") + void searchActiveUsers_byIdOrNickname() { + User alpha = user("or-alpha@example.com", "or-alpha"); + alpha.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("or-bravo@example.com", "or-bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + User charlie = user("or-charlie@example.com", "charlie-other"); + charlie.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers("or-", charlie.getId(), 0, 20); + + assertThat(users) + .extracting(User::getId) + .containsExactlyInAnyOrder(alpha.getId(), bravo.getId(), charlie.getId()); + assertThat(repo.countSearchActiveUsers("or-", charlie.getId())).isEqualTo(3); + } + + @Test @DisplayName("searchActiveUsers - 탈퇴/삭제 사용자는 ID 검색에서도 제외") + void searchActiveUsers_byIdExcludesInactive() { + User withdrawn = user("inactive-withdrawn@example.com", "withdrawn-user"); + withdrawn.setSignupStatus(SignupStatusType.WITHDRAWN); + User deleted = user("inactive-deleted@example.com", "deleted-user"); + deleted.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); + deleted.softDelete(); + em.flush(); em.clear(); + + assertThat(repo.searchActiveUsers(null, withdrawn.getId(), 0, 20)).isEmpty(); + assertThat(repo.countSearchActiveUsers(null, withdrawn.getId())).isZero(); + assertThat(repo.searchActiveUsers(null, deleted.getId(), 0, 20)).isEmpty(); + assertThat(repo.countSearchActiveUsers(null, deleted.getId())).isZero(); } @Test @DisplayName("save - 중복 닉네임은 제약조건 위반") From 5be6544125916cbfc29efb8d7210d52802895dbd Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Fri, 1 May 2026 15:20:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EB=8B=89=EB=84=A4=EC=9E=84?= =?UTF-8?q?=EC=9D=B4=20null=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=8F=84=20=EA=B2=80=EC=83=89=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/core/repository/UserRepository.java | 11 ++---- .../core/repository/UserRepositoryTest.java | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java index c0a33346..f90bd073 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java @@ -68,8 +68,6 @@ public List searchActiveUsers(String nicknameQuery, Long idQuery, int offs SELECT u FROM User u WHERE u.deletedAt IS NULL AND u.signupStatus <> :withdrawn - AND u.nickname IS NOT NULL - AND TRIM(u.nickname) <> '' """; jpql += buildSearchPredicate(nicknameQuery, idQuery); jpql += " ORDER BY u.nickname ASC, u.id ASC"; @@ -89,8 +87,6 @@ public long countSearchActiveUsers(String nicknameQuery, Long idQuery) { SELECT COUNT(u) FROM User u WHERE u.deletedAt IS NULL AND u.signupStatus <> :withdrawn - AND u.nickname IS NOT NULL - AND TRIM(u.nickname) <> '' """; jpql += buildSearchPredicate(nicknameQuery, idQuery); @@ -105,16 +101,17 @@ AND TRIM(u.nickname) <> '' private String buildSearchPredicate(String nicknameQuery, Long idQuery) { boolean hasNickname = nicknameQuery != null; boolean hasId = idQuery != null; + String nicknamePresent = "(u.nickname IS NOT NULL AND TRIM(u.nickname) <> '')"; if (hasNickname && hasId) { - return " AND (u.nickname LIKE :nicknameQuery OR u.id = :idQuery)"; + return " AND ((" + nicknamePresent + " AND u.nickname LIKE :nicknameQuery) OR u.id = :idQuery)"; } if (hasNickname) { - return " AND u.nickname LIKE :nicknameQuery"; + return " AND " + nicknamePresent + " AND u.nickname LIKE :nicknameQuery"; } if (hasId) { return " AND u.id = :idQuery"; } - return ""; + return " AND " + nicknamePresent; } private void bindSearchParameters(Query query, String nicknameQuery, Long idQuery) { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java index 55859865..6301d63c 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java @@ -238,6 +238,44 @@ void searchActiveUsers_byIdOrNickname() { assertThat(repo.countSearchActiveUsers("or-", charlie.getId())).isEqualTo(3); } + @Test @DisplayName("searchActiveUsers - 닉네임 null인 활성 사용자도 ID로 검색됨") + void searchActiveUsers_byIdAllowsNullNickname() { + User profileRequired = user("profile-required@example.com", null); + profileRequired.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers(null, profileRequired.getId(), 0, 20); + + assertThat(users).extracting(User::getId).containsExactly(profileRequired.getId()); + assertThat(repo.countSearchActiveUsers(null, profileRequired.getId())).isEqualTo(1); + } + + @Test @DisplayName("searchActiveUsers - 닉네임 검색에는 null 닉네임 사용자 제외") + void searchActiveUsers_nicknameQueryExcludesNullNickname() { + User withNickname = user("with-nick@example.com", "with-nick-target"); + withNickname.setSignupStatus(SignupStatusType.COMPLETED); + User noNickname = user("no-nick@example.com", null); + noNickname.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers("nick-target", null, 0, 20); + + assertThat(users).extracting(User::getNickname).containsExactly("with-nick-target"); + } + + @Test @DisplayName("searchActiveUsers - 쿼리 없을 때 닉네임 null 사용자 제외") + void searchActiveUsers_noQueryExcludesNullNickname() { + User withNickname = user("default-nick@example.com", "default-nick"); + withNickname.setSignupStatus(SignupStatusType.COMPLETED); + User noNickname = user("default-no-nick@example.com", null); + noNickname.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers(null, null, 0, 20); + + assertThat(users).extracting(User::getNickname).containsExactly("default-nick"); + } + @Test @DisplayName("searchActiveUsers - 탈퇴/삭제 사용자는 ID 검색에서도 제외") void searchActiveUsers_byIdExcludesInactive() { User withdrawn = user("inactive-withdrawn@example.com", "withdrawn-user");