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); });