2026-07-12 14:17:50 +08:00

599 lines
22 KiB
TypeScript

import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import {
adjustAppUserLevels,
banAppUser,
createAppUserExport,
getAppUser,
issueAppUserAccessToken,
listAppUsers,
listPrettyDisplayIDs,
recyclePrettyDisplayID,
unbanAppUser,
} from "./api";
afterEach(() => {
setAccessToken("");
vi.unstubAllGlobals();
});
test("listPrettyDisplayIDs uses admin endpoint and normalizes snake case fields", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
assigned_user_id: "10001",
display_user_id: "VIP2026",
pool: {
level_track: "wealth",
max_level: 10,
min_level: 1,
name: "财富池",
pool_id: "pool-1",
rule_type: "aabbcc",
status: "active",
},
pool_id: "pool-1",
pretty_id: "pretty-1",
source: "pool",
status: "assigned",
},
],
page: 1,
page_size: 20,
total: 1,
},
}),
),
),
);
const result = await listPrettyDisplayIDs({ keyword: "VIP", page: 1, page_size: 20, status: "assigned" });
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/users/pretty-ids?");
expect(String(url)).toContain("keyword=VIP");
expect(String(url)).toContain("status=assigned");
expect(init?.method).toBe("GET");
expect(result.pageSize).toBe(20);
expect(result.items[0]).toMatchObject({
assignedUserId: "10001",
displayUserId: "VIP2026",
pool: { levelTrack: "wealth", poolId: "pool-1" },
prettyId: "pretty-1",
});
});
test("listPrettyDisplayIDs normalizes assigned user and operator snapshots", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
assigned_user: {
avatar: "https://cdn.example/u.png",
default_display_user_id: "10001",
display_user_id: "777777",
user_id: "9001",
username: "pretty owner",
},
assigned_user_id: "9001",
created_by_admin_id: "7",
display_user_id: "777777",
operator: {
account: "ops",
admin_id: "8",
name: "运营",
type: "admin",
},
pretty_id: "pretty-2",
source: "admin_grant",
status: "assigned",
updated_by_admin_id: "8",
},
],
page: 1,
page_size: 20,
total: 1,
},
}),
),
),
);
const result = await listPrettyDisplayIDs({ page: 1, page_size: 20 });
expect(result.items[0]).toMatchObject({
assignedUser: {
avatar: "https://cdn.example/u.png",
defaultDisplayUserId: "10001",
displayUserId: "777777",
userId: "9001",
username: "pretty owner",
},
operator: {
account: "ops",
adminId: "8",
name: "运营",
type: "admin",
},
updatedByAdminId: "8",
});
});
test("recyclePrettyDisplayID uses generated recycle endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
assigned_user_id: "",
display_user_id: "777777",
pretty_id: "pretty-2",
released_at_ms: 1782493200000,
source: "admin_grant",
status: "released",
},
}),
),
),
);
const result = await recyclePrettyDisplayID("pretty-2");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/users/pretty-ids/pretty-2/recycle");
expect(init?.method).toBe("POST");
expect(result).toMatchObject({
assignedUserId: "",
displayUserId: "777777",
prettyId: "pretty-2",
status: "released",
});
});
test("listPrettyDisplayIDs normalizes zero assigned user as empty", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
assigned_user_id: "0",
display_user_id: "00002",
pretty_id: "pretty-0",
source: "pool",
status: "available",
},
],
page: 1,
page_size: 20,
total: 1,
},
}),
),
),
);
const result = await listPrettyDisplayIDs({ page: 1, page_size: 20 });
expect(result.items[0]).toMatchObject({
assignedUser: undefined,
assignedUserId: "",
displayUserId: "00002",
});
});
test("listAppUsers sends country region and time filters", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } })),
),
);
const result = await listAppUsers({
country: "PH",
end_ms: 2000,
page: 1,
page_size: 50,
region_id: 3,
start_ms: 1000,
});
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(result.items).toEqual([]);
expect(String(url)).toContain("/api/v1/app/users?");
expect(String(url)).toContain("country=PH");
expect(String(url)).toContain("region_id=3");
expect(String(url)).toContain("start_ms=1000");
expect(String(url)).toContain("end_ms=2000");
expect(init?.method).toBe("GET");
});
test("listAppUsers normalizes pretty display id fields from snake case", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
avatar: "https://cdn.example/avatar.png",
coin: 99,
default_display_user_id: "123456",
display_user_id: "123456",
pretty_display_user_id: "VIP2026",
pretty_id: "pretty-2026",
user_id: "10001",
username: "tester",
vip: {
active: true,
expires_at_ms: 1790000000000,
level: 3,
name: "黄金会员",
},
},
],
page: 1,
page_size: 50,
total: 1,
},
}),
),
),
);
const result = await listAppUsers({ page: 1, page_size: 50 });
expect(result.pageSize).toBe(50);
expect(result.items[0]).toMatchObject({
defaultDisplayUserId: "123456",
displayUserId: "123456",
prettyDisplayUserId: "VIP2026",
prettyId: "pretty-2026",
userId: "10001",
username: "tester",
vip: expect.objectContaining({
active: true,
level: 3,
name: "黄金会员",
}),
});
});
test("getAppUser uses detail endpoint and normalizes assets", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
display_user_id: "123456",
default_display_user_id: "123456",
pretty_display_user_id: "VIP888",
pretty_id: "pretty-888",
user_id: "10001",
balances: [
{
asset_type: "COIN",
available_amount: 1200,
frozen_amount: 50,
total_amount: 1250,
version: 7,
},
],
equipped_resources: [
{
entitlement_id: "ent-1",
equipped: true,
name: "金色头像框",
quantity: 1,
remaining_quantity: 1,
resource_code: "frame_gold",
resource_id: 9,
resource_type: "avatar_frame",
},
],
resources: [
{
entitlement_id: "ent-1",
equipped: true,
name: "金色头像框",
quantity: 1,
remaining_quantity: 1,
resource_code: "frame_gold",
resource_id: 9,
resource_type: "avatar_frame",
},
],
},
}),
),
),
);
const result = await getAppUser("10001");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001");
expect(init?.method).toBe("GET");
expect(result.defaultDisplayUserId).toBe("123456");
expect(result.prettyDisplayUserId).toBe("VIP888");
expect(result.prettyId).toBe("pretty-888");
expect(result.balances?.[0]).toMatchObject({
assetType: "COIN",
availableAmount: 1200,
frozenAmount: 50,
totalAmount: 1250,
version: 7,
});
expect(result.equippedResources?.[0]).toMatchObject({
equipped: true,
name: "金色头像框",
resourceType: "avatar_frame",
});
expect(result.resources?.[0]).toMatchObject({
entitlementId: "ent-1",
resourceCode: "frame_gold",
});
});
test("app user projection normalizes levels roles ban and successful login fields", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
age: 26,
app_version: "3.2.1",
app_version_at_ms: 1779990000000,
ban: {
active: true,
expires_at_ms: 1790000000000,
id: "ban-1",
permanent: false,
reason: "risk",
source: "admin",
},
birth: "2000-02-29",
build_number: "345",
cumulative_recharge_usd_minor: 12345,
devices: [
{
app_version: "3.2.1",
build_number: "345",
device_id: "android_abcdef",
device_model: "Redmi Note 8 Pro",
first_seen_at_ms: 1770000000000,
imei: "",
last_seen_at_ms: 1781500000000,
manufacturer: "Xiaomi",
os_version: "13",
platform: "android",
},
],
last_login_at_ms: 1780000000000,
last_login_ip: "203.0.113.7",
last_login_ip_at_ms: 1781600000000,
last_login_ip_country_code: "AE",
last_operated_at_ms: 1781000000000,
last_operator: { action: "ban", admin_id: "7", name: "运营" },
levels: {
wealth: {
display_level: 20,
expires_at_ms: 1790000000000,
real_level: 8,
temporary_level_id: "temporary-1",
temporary_target_level: 20,
track: "wealth",
},
},
register_device: "iPhone17,2",
register_device_id: "ios_1F2E3D",
register_os_version: "17.5",
roles: ["主播", "BD"],
third_party_identities: [
{
created_at_ms: 1760000000000,
openid: "g-openid-1",
provider: "google",
union_id: "g-union-1",
},
],
user_id: "10001",
},
}),
),
),
);
const result = await getAppUser("10001");
expect(result).toMatchObject({
age: 26,
appVersion: "3.2.1",
appVersionAtMs: 1779990000000,
ban: { active: true, expiresAtMs: 1790000000000, id: "ban-1", permanent: false },
birth: "2000-02-29",
buildNumber: "345",
cumulativeRechargeUsdMinor: 12345,
devices: [
{
appVersion: "3.2.1",
buildNumber: "345",
deviceId: "android_abcdef",
deviceModel: "Redmi Note 8 Pro",
firstSeenAtMs: 1770000000000,
imei: "",
lastSeenAtMs: 1781500000000,
manufacturer: "Xiaomi",
osVersion: "13",
platform: "android",
},
],
lastLoginAtMs: 1780000000000,
lastLoginIp: "203.0.113.7",
lastLoginIpAtMs: 1781600000000,
lastLoginIpCountryCode: "AE",
lastOperatedAtMs: 1781000000000,
lastOperator: { action: "ban", adminId: "7", name: "运营" },
levels: {
wealth: expect.objectContaining({
displayLevel: 20,
realLevel: 8,
temporaryLevelId: "temporary-1",
temporaryTargetLevel: 20,
}),
},
registerDevice: "iPhone17,2",
registerDeviceId: "ios_1F2E3D",
registerOsVersion: "17.5",
roles: ["主播", "BD"],
thirdPartyIdentities: [
{ createdAtMs: 1760000000000, openid: "g-openid-1", provider: "google", unionId: "g-union-1" },
],
});
});
test("issueAppUserAccessToken posts to access-token endpoint and normalizes the token payload", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
access_token: "eyJhbGciOiJIUzI1NiJ9.payload.signature",
device_id: "android_abcdef",
expires_in_sec: 7200,
session_id: "session-1",
session_last_heartbeat_at_ms: 1781700000000,
token_type: "Bearer",
},
}),
),
),
);
const result = await issueAppUserAccessToken("10001");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001/access-token");
expect(init?.method).toBe("POST");
expect(result).toEqual({
accessToken: "eyJhbGciOiJIUzI1NiJ9.payload.signature",
deviceId: "android_abcdef",
expiresInSec: 7200,
sessionId: "session-1",
sessionLastHeartbeatAtMs: 1781700000000,
tokenType: "Bearer",
});
});
test("temporary level adjustment sends wealth and charm in one request", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { user_id: "10001" } }))),
);
const payload = {
adjustments: [
{ duration_days: 7, level: 20, track: "wealth" as const },
{ duration_days: 3, level: 18, track: "charm" as const },
],
reason: "campaign",
};
const result = await adjustAppUserLevels("10001", payload);
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001/levels");
expect(init?.method).toBe("PUT");
expect(JSON.parse(String(init?.body))).toEqual(payload);
expect(result.userId).toBe("10001");
});
test("ban and unban normalize the status mutation user wrapper", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: 0,
data: {
user: {
ban: { active: true, expires_at_ms: 1790000000000, permanent: false },
status: "banned",
user_id: "10001",
},
},
}),
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({ code: 0, data: { user: { ban: { active: false }, status: "active", user_id: "10001" } } }),
),
);
vi.stubGlobal("fetch", fetchMock);
const banned = await banAppUser("10001", { expires_at_ms: 1790000000000, reason: "risk" });
const unbanned = await unbanAppUser("10001", { reason: "cleared" });
expect(banned).toMatchObject({ ban: { active: true, expiresAtMs: 1790000000000 }, status: "banned" });
expect(unbanned).toMatchObject({ ban: { active: false }, status: "active" });
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
expires_at_ms: 1790000000000,
reason: "risk",
});
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ reason: "cleared" });
});
test("app user export creates a task from the current filter snapshot", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({ code: 0, data: { jobId: 77, snapshotAtMs: 1781000000000, status: "pending" } }),
),
),
);
const result = await createAppUserExport({ country: "AE", status: "banned" });
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/exports/app-users?");
expect(String(url)).toContain("country=AE");
expect(String(url)).toContain("status=banned");
expect(init?.method).toBe("POST");
expect(result).toEqual({ jobId: 77, snapshotAtMs: 1781000000000, status: "pending" });
});