599 lines
22 KiB
TypeScript
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" });
|
|
});
|