328ac31844
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
501 lines
15 KiB
TypeScript
501 lines
15 KiB
TypeScript
import { afterEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
createAdminAccount,
|
|
executeAdminRechargeRefund,
|
|
getAdminFeatureGateConfig,
|
|
getAdminUserDetail,
|
|
listAdminGameDistributionReviews,
|
|
listAdminRechargeOrders,
|
|
reconcileAdminUserConsumption,
|
|
resolveAdminRechargeRefundManualReview,
|
|
reviewAdminGameDistributionVersion,
|
|
suspendAdminGameDistributionGame,
|
|
updateAdminAccount,
|
|
uploadAdminEditorShowcaseCampaignImage,
|
|
upsertAdminFeatureGateConfig,
|
|
upsertProfileWalletConfig,
|
|
} from './adminApiClient';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ account: { accountId: 'member-1' } }), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await createAdminAccount('owner-token', {
|
|
username: 'operator',
|
|
displayName: '运营',
|
|
password: 'secret123',
|
|
tabPermissions: ['dashboard', 'tracking'],
|
|
actionPermissions: ['profile-wallet-consumption-reconcile'],
|
|
enabled: true,
|
|
});
|
|
await updateAdminAccount('owner-token', 'member/1', {
|
|
displayName: '运营二组',
|
|
tabPermissions: ['tracking'],
|
|
actionPermissions: [],
|
|
enabled: false,
|
|
});
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/accounts');
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
|
|
body: JSON.stringify({
|
|
username: 'operator',
|
|
displayName: '运营',
|
|
password: 'secret123',
|
|
tabPermissions: ['dashboard', 'tracking'],
|
|
actionPermissions: ['profile-wallet-consumption-reconcile'],
|
|
enabled: true,
|
|
}),
|
|
}),
|
|
);
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
|
|
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
displayName: '运营二组',
|
|
tabPermissions: ['tracking'],
|
|
actionPermissions: [],
|
|
enabled: false,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('账号配置一次提交初始和每日免费泥点', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ configId: 'profile_wallet' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await upsertProfileWalletConfig('owner-token', {
|
|
initialMudPoints: 100,
|
|
dailyFreePointsPerDay: 35,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/admin/api/profile/wallet-config',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
|
|
body: JSON.stringify({
|
|
initialMudPoints: 100,
|
|
dailyFreePointsPerDay: 35,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('灰度配置读写只使用通用 feature-gates 管理接口', async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ gates: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await getAdminFeatureGateConfig('gray-token');
|
|
await upsertAdminFeatureGateConfig('gray-token', {
|
|
gateKey: 'image-editor:agent-sidebar',
|
|
enabled: true,
|
|
rolloutPercent: 25,
|
|
allowUserIds: ['user-1'],
|
|
allowUserTags: ['beta'],
|
|
denyUserIds: ['blocked-1'],
|
|
description: '画布 Agent 入口灰度',
|
|
});
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/feature-gates');
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }),
|
|
}),
|
|
);
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/feature-gates');
|
|
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }),
|
|
body: JSON.stringify({
|
|
gateKey: 'image-editor:agent-sidebar',
|
|
enabled: true,
|
|
rolloutPercent: 25,
|
|
allowUserIds: ['user-1'],
|
|
allowUserTags: ['beta'],
|
|
denyUserIds: ['blocked-1'],
|
|
description: '画布 Agent 入口灰度',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => {
|
|
const closeBitmap = vi.fn();
|
|
vi.stubGlobal(
|
|
'createImageBitmap',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValue({ width: 1024, height: 1536, close: closeBitmap }),
|
|
);
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
upload: {
|
|
bucket: 'genarrative-release',
|
|
host: 'https://genarrative-release.oss.example.com',
|
|
objectKey:
|
|
'generated-character-drafts/editor/showcase-campaign/current/card.png',
|
|
legacyPublicPath:
|
|
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
|
|
contentType: 'image/png',
|
|
formFields: { key: 'campaign-key', policy: 'signed-policy' },
|
|
},
|
|
}),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(new Response('', { status: 200 }))
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({ assetObject: { assetObjectId: 'assetobj-1' } }),
|
|
{
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
},
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const file = new File(['image-bytes'], 'card.png', { type: 'image/png' });
|
|
|
|
const uploaded = await uploadAdminEditorShowcaseCampaignImage(
|
|
'admin-token',
|
|
file,
|
|
);
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'/admin/api/editor-showcase/campaign/image-upload-ticket',
|
|
);
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
'https://genarrative-release.oss.example.com',
|
|
);
|
|
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
|
'/admin/api/editor-showcase/campaign/image-upload-confirm',
|
|
);
|
|
expect(fetchMock.mock.calls[2]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
body: JSON.stringify({
|
|
bucket: 'genarrative-release',
|
|
objectKey:
|
|
'generated-character-drafts/editor/showcase-campaign/current/card.png',
|
|
contentType: 'image/png',
|
|
contentLength: file.size,
|
|
}),
|
|
}),
|
|
);
|
|
expect(uploaded).toEqual({
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
|
|
imageObjectKey:
|
|
'generated-character-drafts/editor/showcase-campaign/current/card.png',
|
|
imageWidth: 1024,
|
|
imageHeight: 1536,
|
|
legacyPublicPath:
|
|
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
|
|
});
|
|
expect(closeBitmap).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test('充值订单查询按后台契约序列化筛选参数', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ entries: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await listAdminRechargeOrders('token-1', {
|
|
orderId: 'order 1',
|
|
userId: 'user-1',
|
|
providerTransactionId: 'wx-1',
|
|
paymentChannel: 'wechat_native',
|
|
status: 'paid',
|
|
createdAfter: '2026-07-01T00:00:00.000Z',
|
|
createdBefore: '2026-07-13T23:59:00.000Z',
|
|
limit: 50,
|
|
});
|
|
|
|
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
|
|
const parsed = new URL(requestUrl, 'http://admin.local');
|
|
expect(parsed.pathname).toBe('/admin/api/profile/recharge-orders');
|
|
expect(Object.fromEntries(parsed.searchParams)).toEqual({
|
|
orderId: 'order 1',
|
|
providerTransactionId: 'wx-1',
|
|
userId: 'user-1',
|
|
paymentChannel: 'wechat_native',
|
|
status: 'paid',
|
|
createdAfter: '2026-07-01T00:00:00.000Z',
|
|
createdBefore: '2026-07-13T23:59:00.000Z',
|
|
limit: '50',
|
|
});
|
|
});
|
|
|
|
test('用户详情只发送实际提供的用户定位字段', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' });
|
|
|
|
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
|
|
const parsed = new URL(requestUrl, 'http://admin.local');
|
|
expect(parsed.pathname).toBe('/admin/api/profile/users/detail');
|
|
expect(Object.fromEntries(parsed.searchParams)).toEqual({
|
|
publicUserCode: 'TN1001',
|
|
});
|
|
});
|
|
|
|
test('历史花费手动对账使用独立管理员写接口', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
userId: 'user-1',
|
|
historicalConsumedPoints: 1300,
|
|
changed: true,
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await reconcileAdminUserConsumption('token-1', { userId: 'user-1' });
|
|
|
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
|
'/admin/api/profile/users/reconcile-consumption',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({ Authorization: 'Bearer token-1' }),
|
|
body: JSON.stringify({ userId: 'user-1' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('退款执行使用独立 execute 管理员路由', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
|
status: 200,
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await executeAdminRechargeRefund('token-1', {
|
|
orderId: 'order-1',
|
|
refundAmountCents: 300,
|
|
requestId: 'request-1',
|
|
reason: '用户申请',
|
|
});
|
|
|
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
|
'/admin/api/profile/recharge-refunds/execute',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
orderId: 'order-1',
|
|
refundAmountCents: 300,
|
|
requestId: 'request-1',
|
|
reason: '用户申请',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('退款人工复核使用独立 resolve 管理员路由', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
|
status: 200,
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await resolveAdminRechargeRefundManualReview('token-1', {
|
|
outRefundNo: 'refund-1',
|
|
reason: '已核对微信商户平台原始账单',
|
|
expectedErrorCode: 'provider_transaction_id_mismatch',
|
|
});
|
|
|
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
|
'/admin/api/profile/recharge-refunds/manual-review/resolve',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
outRefundNo: 'refund-1',
|
|
reason: '已核对微信商户平台原始账单',
|
|
expectedErrorCode: 'provider_transaction_id_mismatch',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await listAdminGameDistributionReviews('admin-token');
|
|
await reviewAdminGameDistributionVersion(
|
|
'admin-token',
|
|
'gamever/1',
|
|
'game-review-key-1',
|
|
{
|
|
decision: 'approve',
|
|
expectedPublicationRevision: 3,
|
|
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
|
},
|
|
);
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'/admin/api/game-distribution/reviews?limit=48',
|
|
);
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
'/admin/api/game-distribution/versions/gamever%2F1/review',
|
|
);
|
|
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer admin-token',
|
|
'Idempotency-Key': 'game-review-key-1',
|
|
}),
|
|
body: JSON.stringify({
|
|
decision: 'approve',
|
|
expectedPublicationRevision: 3,
|
|
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ game: {}, replayed: false }), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await suspendAdminGameDistributionGame(
|
|
'admin-token',
|
|
'game/1',
|
|
'game-suspend-key-1',
|
|
{ expectedPublicationRevision: 7, reason: '版权投诉' },
|
|
);
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
'/admin/api/game-distribution/games/game%2F1/suspend',
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer admin-token',
|
|
'Idempotency-Key': 'game-suspend-key-1',
|
|
}),
|
|
body: JSON.stringify({
|
|
expectedPublicationRevision: 7,
|
|
reason: '版权投诉',
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(() =>
|
|
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
|
|
expectedPublicationRevision: 1,
|
|
}),
|
|
).toThrow('缺少游戏 ID');
|
|
expect(() =>
|
|
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
|
|
expectedPublicationRevision: 1,
|
|
}),
|
|
).toThrow('下架幂等键必须是 1 到 128 个字符');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ version: {}, replayed: false }), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await reviewAdminGameDistributionVersion(
|
|
'admin-token',
|
|
'version-1',
|
|
'game-review-key-2',
|
|
{
|
|
decision: 'reject',
|
|
expectedPublicationRevision: 0,
|
|
reviewReason: '运行时报错',
|
|
},
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
decision: 'reject',
|
|
expectedPublicationRevision: 0,
|
|
reviewReason: '运行时报错',
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(() =>
|
|
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
|
|
decision: 'reject',
|
|
expectedPublicationRevision: 0,
|
|
reviewReason: 'x',
|
|
}),
|
|
).toThrow('审核幂等键必须是 1 到 128 个字符');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|