Show complete user credit data in admin
Load users by registration order, expose consumed and current credits, and accept short internal IDs so support can reliably locate accounts.
This commit is contained in:
@@ -165,7 +165,7 @@ export const adminApi = {
|
|||||||
referrals: (range: string) =>
|
referrals: (range: string) =>
|
||||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||||
|
|
||||||
users: (search: string, cursor?: string) =>
|
users: (search = "", cursor?: string) =>
|
||||||
request<PageResult<UserSummary>>(
|
request<PageResult<UserSummary>>(
|
||||||
`/users${query({ q: search.trim(), cursor })}`,
|
`/users${query({ q: search.trim(), cursor })}`,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface UserSummary {
|
|||||||
maskedEmail?: string;
|
maskedEmail?: string;
|
||||||
status: "active" | "suspended" | "closed";
|
status: "active" | "suspended" | "closed";
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
|
consumedCredits: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
|
|||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<div class="eyebrow">账户管理</div>
|
<div class="eyebrow">账户管理</div>
|
||||||
<h1>用户查询</h1>
|
<h1>用户列表</h1>
|
||||||
<p>按内部用户 ID 精确查询,不展示 Apple 身份标识。</p>
|
<p>按注册时间查看全部用户,支持完整或后 8 位内部用户 ID 查询。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -37,11 +37,11 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
|
|||||||
<label class="search-box">
|
<label class="search-box">
|
||||||
<span class="sr-only">内部用户 ID</span>
|
<span class="sr-only">内部用户 ID</span>
|
||||||
<span aria-hidden="true">⌕</span>
|
<span aria-hidden="true">⌕</span>
|
||||||
<input name="query" type="search" placeholder="输入完整内部用户 ID" autocomplete="off" maxlength="36" required />
|
<input name="query" type="search" placeholder="输入完整或后 8 位用户 ID" autocomplete="off" maxlength="36" />
|
||||||
</label>
|
</label>
|
||||||
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
|
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
|
||||||
</form>
|
</form>
|
||||||
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
|
<div data-results>${renderEmpty("正在加载用户列表")}</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -49,11 +49,14 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
|
|||||||
form?.addEventListener("submit", (event) => {
|
form?.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const search = new FormData(form).get("query")?.toString().trim() ?? "";
|
const search = new FormData(form).get("query")?.toString().trim() ?? "";
|
||||||
if (search) void searchUsers(container, search, role);
|
void loadUsers(container, search, role);
|
||||||
});
|
});
|
||||||
|
const results = container.querySelector<HTMLElement>("[data-results]");
|
||||||
|
if (results) bindUserRows(results, container, role);
|
||||||
|
void loadUsers(container, "", role);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchUsers(
|
async function loadUsers(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
search: string,
|
search: string,
|
||||||
role: AdminRole,
|
role: AdminRole,
|
||||||
@@ -71,40 +74,90 @@ async function searchUsers(
|
|||||||
results.innerHTML = `
|
results.innerHTML = `
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<caption class="sr-only">用户查询结果</caption>
|
<caption class="sr-only">${search ? "用户查询结果" : "全部用户"}</caption>
|
||||||
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">积分余额</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
|
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">累计使用积分</th><th scope="col">当前积分</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
|
||||||
<tbody>
|
<tbody data-user-body>${userRows(page.items)}</tbody>
|
||||||
${page.items.map(userRow).join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
${
|
||||||
|
page.nextCursor
|
||||||
|
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-user-more>加载更多用户</wa-button></div>'
|
||||||
|
: ""
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
results.querySelectorAll<HTMLButtonElement>("[data-user-id]").forEach(
|
if (page.nextCursor) {
|
||||||
(button) => {
|
bindUserPagination(container, search, page.nextCursor);
|
||||||
button.addEventListener("click", () => {
|
}
|
||||||
const userId = button.dataset.userId;
|
|
||||||
if (userId) void renderUserDetail(container, userId, role);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
renderError(results, error, () => void searchUsers(container, search, role));
|
renderError(results, error, () => void loadUsers(container, search, role));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function userRow(user: UserSummary): string {
|
function userRows(users: UserSummary[]): string {
|
||||||
return `
|
return users
|
||||||
|
.map(
|
||||||
|
(user) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
|
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
|
||||||
<div class="subtle mono">${escapeHtml(user.userId)}</div>
|
<div class="subtle mono">${escapeHtml(user.userId)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
|
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
|
||||||
|
<td>${formatNumber(user.consumedCredits)}</td>
|
||||||
<td>${formatNumber(user.creditBalance)}</td>
|
<td>${formatNumber(user.creditBalance)}</td>
|
||||||
<td>${formatDateTime(user.createdAt)}</td>
|
<td>${formatDateTime(user.createdAt)}</td>
|
||||||
<td><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-button></td>
|
<td><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-button></td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUserRows(
|
||||||
|
scope: HTMLElement,
|
||||||
|
container: HTMLElement,
|
||||||
|
role: AdminRole,
|
||||||
|
): void {
|
||||||
|
scope.addEventListener("click", (event) => {
|
||||||
|
const button = (event.target as Element).closest<HTMLButtonElement>(
|
||||||
|
"[data-user-id]",
|
||||||
|
);
|
||||||
|
const userId = button?.dataset.userId;
|
||||||
|
if (userId) void renderUserDetail(container, userId, role);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUserPagination(
|
||||||
|
container: HTMLElement,
|
||||||
|
search: string,
|
||||||
|
initialCursor: string,
|
||||||
|
): void {
|
||||||
|
const button = container.querySelector<HTMLButtonElement>("[data-user-more]");
|
||||||
|
const body = container.querySelector<HTMLTableSectionElement>("[data-user-body]");
|
||||||
|
if (!button || !body) return;
|
||||||
|
let cursor: string | undefined = initialCursor;
|
||||||
|
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (!cursor) return;
|
||||||
|
setButtonBusy(button, true, "加载中…");
|
||||||
|
try {
|
||||||
|
const page = await adminApi.users(search, cursor);
|
||||||
|
body.insertAdjacentHTML("beforeend", userRows(page.items));
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
if (!cursor) {
|
||||||
|
button.closest(".pagination-actions")?.remove();
|
||||||
|
} else {
|
||||||
|
setButtonBusy(button, false);
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(
|
||||||
|
error instanceof ApiError ? error.message : "加载用户失败",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
setButtonBusy(button, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderUserDetail(
|
async function renderUserDetail(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const userSummary: UserSummary = {
|
|||||||
displayName: "测试用户",
|
displayName: "测试用户",
|
||||||
status: "active",
|
status: "active",
|
||||||
creditBalance: 120,
|
creditBalance: 120,
|
||||||
|
consumedCredits: 18,
|
||||||
createdAt: "2026-08-01T08:00:00Z",
|
createdAt: "2026-08-01T08:00:00Z",
|
||||||
};
|
};
|
||||||
const userDetail: UserDetail = {
|
const userDetail: UserDetail = {
|
||||||
@@ -45,6 +46,37 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("用户页", () => {
|
describe("用户页", () => {
|
||||||
|
it("进入页面自动显示积分、注册时间并按游标加载更多用户", async () => {
|
||||||
|
const secondUser: UserSummary = {
|
||||||
|
...userSummary,
|
||||||
|
userId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
displayName: "第二位用户",
|
||||||
|
creditBalance: 80,
|
||||||
|
consumedCredits: 40,
|
||||||
|
};
|
||||||
|
vi.spyOn(adminApi, "users")
|
||||||
|
.mockResolvedValueOnce({ items: [userSummary], nextCursor: "user-next" })
|
||||||
|
.mockResolvedValueOnce({ items: [secondUser] });
|
||||||
|
const container = document.createElement("main");
|
||||||
|
document.body.append(container);
|
||||||
|
|
||||||
|
renderUsers(container, "SUPPORT");
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("累计使用积分");
|
||||||
|
expect(container.textContent).toContain("当前积分");
|
||||||
|
expect(container.textContent).toContain("18");
|
||||||
|
expect(container.textContent).toContain("120");
|
||||||
|
|
||||||
|
container.querySelector<HTMLButtonElement>("[data-user-more]")?.click();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
|
||||||
|
expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(container.querySelector("[data-user-more]")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("支持人员可查看详情但不显示人工赠送", async () => {
|
it("支持人员可查看详情但不显示人工赠送", async () => {
|
||||||
mockUserRequests();
|
mockUserRequests();
|
||||||
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
|
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
|
||||||
|
|||||||
+5
-2
@@ -549,7 +549,7 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- adminMtls: []
|
- adminMtls: []
|
||||||
adminSession: []
|
adminSession: []
|
||||||
summary: List users or search by exact internal user ID
|
summary: List users or search by full or 8-character internal user ID suffix
|
||||||
parameters:
|
parameters:
|
||||||
- name: q
|
- name: q
|
||||||
in: query
|
in: query
|
||||||
@@ -959,12 +959,13 @@ components:
|
|||||||
AdminUserSummary:
|
AdminUserSummary:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [userId, displayName, status, creditBalance, createdAt]
|
required: [userId, displayName, status, creditBalance, consumedCredits, createdAt]
|
||||||
properties:
|
properties:
|
||||||
userId: { type: string, format: uuid }
|
userId: { type: string, format: uuid }
|
||||||
displayName: { type: string }
|
displayName: { type: string }
|
||||||
status: { type: string, enum: [active, suspended, closed] }
|
status: { type: string, enum: [active, suspended, closed] }
|
||||||
creditBalance: { type: integer, format: int64, minimum: 0 }
|
creditBalance: { type: integer, format: int64, minimum: 0 }
|
||||||
|
consumedCredits: { type: integer, format: int64, minimum: 0 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
AdminUserPage:
|
AdminUserPage:
|
||||||
type: object
|
type: object
|
||||||
@@ -991,6 +992,7 @@ components:
|
|||||||
- displayName
|
- displayName
|
||||||
- status
|
- status
|
||||||
- creditBalance
|
- creditBalance
|
||||||
|
- consumedCredits
|
||||||
- createdAt
|
- createdAt
|
||||||
- qualifiedUsage
|
- qualifiedUsage
|
||||||
- usage
|
- usage
|
||||||
@@ -1000,6 +1002,7 @@ components:
|
|||||||
displayName: { type: string }
|
displayName: { type: string }
|
||||||
status: { type: string, enum: [active, suspended, closed] }
|
status: { type: string, enum: [active, suspended, closed] }
|
||||||
creditBalance: { type: integer, format: int64, minimum: 0 }
|
creditBalance: { type: integer, format: int64, minimum: 0 }
|
||||||
|
consumedCredits: { type: integer, format: int64, minimum: 0 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
lastActiveAt: { type: ["string", "null"], format: date-time }
|
lastActiveAt: { type: ["string", "null"], format: date-time }
|
||||||
qualifiedUsage: { type: boolean }
|
qualifiedUsage: { type: boolean }
|
||||||
|
|||||||
@@ -646,9 +646,10 @@ private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
|||||||
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
||||||
AdminUserSummaryResponse(
|
AdminUserSummaryResponse(
|
||||||
userId = id,
|
userId = id,
|
||||||
displayName = "用户 ${id.take(8)}",
|
displayName = "用户 ${id.takeLast(8).uppercase()}",
|
||||||
status = if (antiAbuseRestricted) "suspended" else "active",
|
status = if (antiAbuseRestricted) "suspended" else "active",
|
||||||
creditBalance = creditBalance,
|
creditBalance = creditBalance,
|
||||||
|
consumedCredits = consumedCredits,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -817,6 +818,7 @@ private data class AdminUserSummaryResponse(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val creditBalance: Long,
|
val creditBalance: Long,
|
||||||
|
val consumedCredits: Long,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -826,6 +828,7 @@ private data class AdminUserDetailResponse(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val creditBalance: Long,
|
val creditBalance: Long,
|
||||||
|
val consumedCredits: Long,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val lastActiveAt: String?,
|
val lastActiveAt: String?,
|
||||||
val qualifiedUsage: Boolean,
|
val qualifiedUsage: Boolean,
|
||||||
@@ -847,6 +850,7 @@ private data class AdminUserDetailResponse(
|
|||||||
displayName = summary.displayName,
|
displayName = summary.displayName,
|
||||||
status = summary.status,
|
status = summary.status,
|
||||||
creditBalance = summary.creditBalance,
|
creditBalance = summary.creditBalance,
|
||||||
|
consumedCredits = summary.consumedCredits,
|
||||||
createdAt = summary.createdAt,
|
createdAt = summary.createdAt,
|
||||||
lastActiveAt = lastActiveAt,
|
lastActiveAt = lastActiveAt,
|
||||||
qualifiedUsage = qualifiedUsage,
|
qualifiedUsage = qualifiedUsage,
|
||||||
|
|||||||
+19
@@ -16,6 +16,7 @@ import org.jetbrains.exposed.v1.core.and
|
|||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.core.inList
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
import org.jetbrains.exposed.v1.core.less
|
import org.jetbrains.exposed.v1.core.less
|
||||||
|
import org.jetbrains.exposed.v1.core.like
|
||||||
import org.jetbrains.exposed.v1.core.or
|
import org.jetbrains.exposed.v1.core.or
|
||||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
@@ -35,6 +36,8 @@ data class AdminUserLedgerCursor(
|
|||||||
interface AdminUsersRepository {
|
interface AdminUsersRepository {
|
||||||
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
|
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
|
||||||
|
|
||||||
|
suspend fun findByIdSuffix(suffix: String, limit: Int): List<AdminUserSummaryDto>
|
||||||
|
|
||||||
suspend fun exists(userId: UUID): Boolean
|
suspend fun exists(userId: UUID): Boolean
|
||||||
|
|
||||||
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
|
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
|
||||||
@@ -74,6 +77,22 @@ class ExposedAdminUsersRepository(
|
|||||||
accountRows.map { it.toSummary(support) }
|
accountRows.map { it.toSummary(support) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun findByIdSuffix(
|
||||||
|
suffix: String,
|
||||||
|
limit: Int,
|
||||||
|
): List<AdminUserSummaryDto> = databaseFactory.query {
|
||||||
|
val accountRows = AdminUsersAccountsTable.selectAll()
|
||||||
|
.where { AdminUsersAccountsTable.id like "%$suffix" }
|
||||||
|
.orderBy(
|
||||||
|
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
|
||||||
|
AdminUsersAccountsTable.id to SortOrder.DESC,
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.toList()
|
||||||
|
val support = loadSupport(accountRows.map { it.userId() }.toSet())
|
||||||
|
accountRows.map { it.toSummary(support) }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
|
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
|
||||||
AdminUsersAccountsTable.selectAll()
|
AdminUsersAccountsTable.selectAll()
|
||||||
.where { AdminUsersAccountsTable.id eq userId.toString() }
|
.where { AdminUsersAccountsTable.id eq userId.toString() }
|
||||||
|
|||||||
+16
-4
@@ -17,10 +17,19 @@ class AdminUsersService(
|
|||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun searchByInternalId(query: String): AdminUserPageDto {
|
suspend fun searchByInternalId(query: String): AdminUserPageDto {
|
||||||
val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull()
|
val normalized = query.trim()
|
||||||
?: return AdminUserPageDto(emptyList(), null)
|
val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
|
||||||
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
|
if (userId != null) {
|
||||||
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
|
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
|
||||||
|
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
|
||||||
|
}
|
||||||
|
if (!SHORT_INTERNAL_ID.matches(normalized)) {
|
||||||
|
return AdminUserPageDto(emptyList(), null)
|
||||||
|
}
|
||||||
|
return AdminUserPageDto(
|
||||||
|
items = repository.findByIdSuffix(normalized.lowercase(), limit = MAX_SHORT_ID_MATCHES),
|
||||||
|
nextCursor = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun list(
|
suspend fun list(
|
||||||
@@ -80,6 +89,9 @@ class AdminUsersService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$")
|
||||||
|
private const val MAX_SHORT_ID_MATCHES = 100
|
||||||
|
|
||||||
internal object AdminUserCursorCodec {
|
internal object AdminUserCursorCodec {
|
||||||
fun encode(cursor: AdminUserCursor): String {
|
fun encode(cursor: AdminUserCursor): String {
|
||||||
val value = "${cursor.createdAt}|${cursor.userId}"
|
val value = "${cursor.createdAt}|${cursor.userId}"
|
||||||
|
|||||||
@@ -143,6 +143,40 @@ class AdminUsersServiceTest : FunSpec({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("search accepts an uppercase 8-character internal user ID suffix") {
|
||||||
|
val matchingId = UUID.fromString("11111111-1111-4111-8111-aaaae3c7c475")
|
||||||
|
val otherId = UUID.fromString("22222222-2222-4222-8222-bbbb12345678")
|
||||||
|
val service = AdminUsersService(
|
||||||
|
PagingUsersRepository(
|
||||||
|
listOf(
|
||||||
|
summary(matchingId, Instant.parse("2026-08-15T02:00:00Z")),
|
||||||
|
summary(otherId, Instant.parse("2026-08-15T01:00:00Z")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val page = service.searchByInternalId("E3C7C475")
|
||||||
|
|
||||||
|
page.items.map { it.id } shouldBe listOf(matchingId.toString())
|
||||||
|
page.nextCursor shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
test("short ID search returns all collisions for disambiguation") {
|
||||||
|
val firstId = UUID.fromString("11111111-1111-4111-8111-aaaae3c7c475")
|
||||||
|
val secondId = UUID.fromString("22222222-2222-4222-8222-bbbbe3c7c475")
|
||||||
|
val service = AdminUsersService(
|
||||||
|
PagingUsersRepository(
|
||||||
|
listOf(
|
||||||
|
summary(firstId, Instant.parse("2026-08-15T02:00:00Z")),
|
||||||
|
summary(secondId, Instant.parse("2026-08-15T01:00:00Z")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
service.searchByInternalId("e3c7c475").items.map { it.id } shouldBe
|
||||||
|
listOf(firstId.toString(), secondId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
test("invalid user cursor and missing detail fail without returning user data") {
|
test("invalid user cursor and missing detail fail without returning user data") {
|
||||||
val service = AdminUsersService(PagingUsersRepository(emptyList()))
|
val service = AdminUsersService(PagingUsersRepository(emptyList()))
|
||||||
|
|
||||||
@@ -178,6 +212,12 @@ private class PagingUsersRepository(
|
|||||||
ledgerLimit: Int,
|
ledgerLimit: Int,
|
||||||
): AdminUserDetailDto? = details[userId]
|
): AdminUserDetailDto? = details[userId]
|
||||||
|
|
||||||
|
override suspend fun findByIdSuffix(
|
||||||
|
suffix: String,
|
||||||
|
limit: Int,
|
||||||
|
): List<AdminUserSummaryDto> =
|
||||||
|
users.filter { it.id.endsWith(suffix, ignoreCase = true) }.take(limit)
|
||||||
|
|
||||||
override suspend fun exists(userId: UUID): Boolean =
|
override suspend fun exists(userId: UUID): Boolean =
|
||||||
userId in details || userId in ledger || users.any { it.id == userId.toString() }
|
userId in details || userId in ledger || users.any { it.id == userId.toString() }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user