合并主分支
解决合并冲突
This commit is contained in:
@@ -662,6 +662,168 @@ describe('apiClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry an unsafe write with a new token after its account signal aborts', async () => {
|
||||
setStoredAccessToken('account-a-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 503 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 200 }));
|
||||
const abortController = new AbortController();
|
||||
|
||||
const request = requestJson(
|
||||
'/api/profile/redeem-codes/redeem',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'REWARD01' }),
|
||||
signal: abortController.signal,
|
||||
},
|
||||
'兑换失败',
|
||||
{
|
||||
retry: {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 1000,
|
||||
maxDelayMs: 1000,
|
||||
retryUnsafeMethods: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
setStoredAccessToken('account-b-token', { emit: false });
|
||||
abortController.abort();
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(fetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>)
|
||||
.Authorization,
|
||||
).toBe('Bearer account-a-token');
|
||||
});
|
||||
|
||||
it('does not replay a 401 unsafe write after its account signal aborts during refresh', async () => {
|
||||
setStoredAccessToken('account-a-token', { emit: false });
|
||||
const refreshResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockImplementationOnce(() => refreshResponse.promise)
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 200 }));
|
||||
const abortController = new AbortController();
|
||||
|
||||
const request = requestJson(
|
||||
'/api/profile/redeem-codes/redeem',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'REWARD01' }),
|
||||
signal: abortController.signal,
|
||||
},
|
||||
'兑换失败',
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const accountARefresh = refreshStoredAccessToken({
|
||||
clearOnFailure: false,
|
||||
});
|
||||
const accountARefreshResult = expect(accountARefresh).rejects.toMatchObject(
|
||||
{
|
||||
name: 'AuthStateChangedDuringRefreshError',
|
||||
},
|
||||
);
|
||||
setStoredAccessToken('account-b-token', { emit: false });
|
||||
abortController.abort();
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
|
||||
refreshResponse.resolve(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'late-refresh-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await accountARefreshResult;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('account-b-token');
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/api/profile/redeem-codes/redeem',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh');
|
||||
});
|
||||
|
||||
it('starts a separate refresh for a new auth generation', async () => {
|
||||
setStoredAccessToken('account-a-token', { emit: false });
|
||||
const accountAResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
const accountBResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
fetchMock
|
||||
.mockImplementationOnce(() => accountAResponse.promise)
|
||||
.mockImplementationOnce(() => accountBResponse.promise);
|
||||
|
||||
const accountARefresh = refreshStoredAccessToken({ clearOnFailure: false });
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
setStoredAccessToken('account-b-token', { emit: false });
|
||||
const accountBRefresh = refreshStoredAccessToken({ clearOnFailure: false });
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
accountAResponse.resolve(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'late-account-a-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(accountARefresh).rejects.toMatchObject({
|
||||
name: 'AuthStateChangedDuringRefreshError',
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('account-b-token');
|
||||
|
||||
accountBResponse.resolve(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'fresh-account-b-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(accountBRefresh).resolves.toBe('fresh-account-b-token');
|
||||
expect(getStoredAccessToken()).toBe('fresh-account-b-token');
|
||||
});
|
||||
|
||||
it('does not clear a new account token when an old refresh fails late', async () => {
|
||||
setStoredAccessToken('account-a-token', { emit: false });
|
||||
const accountAResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
fetchMock.mockImplementationOnce(() => accountAResponse.promise);
|
||||
|
||||
const accountARefresh = refreshStoredAccessToken();
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
setStoredAccessToken('account-b-token', { emit: false });
|
||||
accountAResponse.resolve(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(accountARefresh).rejects.toMatchObject({ status: 401 });
|
||||
expect(getStoredAccessToken()).toBe('account-b-token');
|
||||
});
|
||||
|
||||
it('aborts requests when timeoutMs is reached', async () => {
|
||||
setStoredAccessToken('timeout-token', { emit: false });
|
||||
fetchMock.mockImplementation(
|
||||
@@ -700,9 +862,8 @@ describe('apiClient', () => {
|
||||
it.each(['missing-token', 'unauthorized'] as const)(
|
||||
'bounds the %s refresh wait with an absolute request deadline',
|
||||
async (refreshMode) => {
|
||||
const refreshResponse = createDeferred<
|
||||
ReturnType<typeof createResponseMock>
|
||||
>();
|
||||
const refreshResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
if (refreshMode === 'unauthorized') {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
@@ -853,9 +1014,13 @@ describe('apiClient', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
|
||||
method: 'POST',
|
||||
}, '执行拼图操作失败。'),
|
||||
requestJson(
|
||||
'/api/runtime/puzzle/agent/sessions/test/actions',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'执行拼图操作失败。',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: '拼图图片生成失败:请求参数不合法',
|
||||
status: 400,
|
||||
@@ -895,9 +1060,13 @@ describe('apiClient', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
|
||||
method: 'POST',
|
||||
}, '执行拼图操作失败。'),
|
||||
requestJson(
|
||||
'/api/runtime/puzzle/agent/sessions/test/actions',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'执行拼图操作失败。',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message:
|
||||
'无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置',
|
||||
|
||||
+105
-15
@@ -114,7 +114,9 @@ function normalizeHeaders(headers?: HeadersInit) {
|
||||
|
||||
function hasHeader(headers: Record<string, string>, name: string) {
|
||||
const normalizedName = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName);
|
||||
return Object.keys(headers).some(
|
||||
(key) => key.toLowerCase() === normalizedName,
|
||||
);
|
||||
}
|
||||
|
||||
function setHeaderIfMissing(
|
||||
@@ -146,7 +148,11 @@ function attachHostRuntimeHeaders(headers: Record<string, string>) {
|
||||
runtime.clientRuntime || 'wechat_mini_program',
|
||||
);
|
||||
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
|
||||
setHeaderIfMissing(headers, MINI_PROGRAM_ENV_HEADER, runtime.miniProgramEnv);
|
||||
setHeaderIfMissing(
|
||||
headers,
|
||||
MINI_PROGRAM_ENV_HEADER,
|
||||
runtime.miniProgramEnv,
|
||||
);
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -171,7 +177,10 @@ function buildClientRequestId() {
|
||||
return `web-${randomId}`;
|
||||
}
|
||||
|
||||
function resolveRequestIdHeader(headers: Record<string, string>, options: ApiRequestOptions) {
|
||||
function resolveRequestIdHeader(
|
||||
headers: Record<string, string>,
|
||||
options: ApiRequestOptions,
|
||||
) {
|
||||
const explicitRequestId = options.requestId?.trim();
|
||||
const existingRequestId = Object.entries(headers).find(
|
||||
([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(),
|
||||
@@ -630,7 +639,9 @@ export function getStoredAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(
|
||||
let authStateGeneration = 0;
|
||||
|
||||
function writeStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
@@ -654,6 +665,20 @@ export function setStoredAccessToken(
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
authStateGeneration += 1;
|
||||
writeStoredAccessToken(token, options);
|
||||
}
|
||||
|
||||
export function clearStoredAccessToken(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
@@ -663,6 +688,7 @@ export function clearStoredAccessToken(
|
||||
return;
|
||||
}
|
||||
|
||||
authStateGeneration += 1;
|
||||
const previousToken = getStoredAccessToken();
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
|
||||
@@ -687,7 +713,50 @@ function withAuthorizationHeaders(
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
type AuthStateSnapshot = {
|
||||
generation: number;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
type RefreshAccessTokenAttempt = AuthStateSnapshot & {
|
||||
promise: Promise<string>;
|
||||
};
|
||||
|
||||
class AuthStateChangedDuringRefreshError extends Error {
|
||||
constructor() {
|
||||
super('刷新期间登录状态已变化');
|
||||
this.name = 'AuthStateChangedDuringRefreshError';
|
||||
}
|
||||
}
|
||||
|
||||
let refreshAccessTokenAttempt: RefreshAccessTokenAttempt | null = null;
|
||||
|
||||
function captureAuthStateSnapshot(): AuthStateSnapshot {
|
||||
return {
|
||||
generation: authStateGeneration,
|
||||
accessToken: getStoredAccessToken(),
|
||||
};
|
||||
}
|
||||
|
||||
function isCurrentAuthState(snapshot: AuthStateSnapshot) {
|
||||
return (
|
||||
authStateGeneration === snapshot.generation &&
|
||||
getStoredAccessToken() === snapshot.accessToken
|
||||
);
|
||||
}
|
||||
|
||||
function publishRefreshedAccessToken(
|
||||
nextToken: string,
|
||||
snapshot: AuthStateSnapshot,
|
||||
) {
|
||||
if (!isCurrentAuthState(snapshot)) {
|
||||
throw new AuthStateChangedDuringRefreshError();
|
||||
}
|
||||
|
||||
// refresh 只轮换同一账号的 access token,不推进账号代际。
|
||||
// 外部登录、切号或退出通过公开 setter 推进代际,使旧 refresh 发布失效。
|
||||
writeStoredAccessToken(nextToken, { emit: false });
|
||||
}
|
||||
|
||||
function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
return (
|
||||
@@ -697,11 +766,16 @@ function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
const authStateSnapshot = captureAuthStateSnapshot();
|
||||
if (
|
||||
refreshAccessTokenAttempt &&
|
||||
refreshAccessTokenAttempt.generation === authStateSnapshot.generation &&
|
||||
refreshAccessTokenAttempt.accessToken === authStateSnapshot.accessToken
|
||||
) {
|
||||
return refreshAccessTokenAttempt.promise;
|
||||
}
|
||||
|
||||
refreshAccessTokenPromise = (async () => {
|
||||
const promise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -726,14 +800,21 @@ async function refreshAccessToken() {
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
publishRefreshedAccessToken(nextToken, authStateSnapshot);
|
||||
return nextToken;
|
||||
})();
|
||||
const attempt: RefreshAccessTokenAttempt = {
|
||||
...authStateSnapshot,
|
||||
promise,
|
||||
};
|
||||
refreshAccessTokenAttempt = attempt;
|
||||
|
||||
try {
|
||||
return await refreshAccessTokenPromise;
|
||||
return await promise;
|
||||
} finally {
|
||||
refreshAccessTokenPromise = null;
|
||||
if (refreshAccessTokenAttempt === attempt) {
|
||||
refreshAccessTokenAttempt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,12 +833,14 @@ export async function refreshStoredAccessToken(
|
||||
clearOnFailure?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const authStateSnapshot = captureAuthStateSnapshot();
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
if (
|
||||
options.clearOnFailure !== false &&
|
||||
shouldClearAuthAfterRefreshFailure(error)
|
||||
shouldClearAuthAfterRefreshFailure(error) &&
|
||||
isCurrentAuthState(authStateSnapshot)
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
@@ -774,7 +857,10 @@ export async function fetchWithApiAuth(
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const authFailurePolicy = resolveAuthFailurePolicy(options);
|
||||
const requestSignal = init.signal ?? undefined;
|
||||
const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options);
|
||||
const requestId = resolveRequestIdHeader(
|
||||
normalizeHeaders(init.headers),
|
||||
options,
|
||||
);
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
@@ -830,6 +916,7 @@ export async function fetchWithApiAuth(
|
||||
!authFailurePolicy.skipRefresh &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
const refreshAuthStateSnapshot = captureAuthStateSnapshot();
|
||||
try {
|
||||
await awaitWithAbortSignal(refreshAccessToken(), requestSignal);
|
||||
refreshAttempted = true;
|
||||
@@ -844,7 +931,8 @@ export async function fetchWithApiAuth(
|
||||
const shouldClearAuth =
|
||||
hasAuthHeader &&
|
||||
authFailurePolicy.clearAuthOnUnauthorized &&
|
||||
shouldClearAuthAfterRefreshFailure(refreshError);
|
||||
shouldClearAuthAfterRefreshFailure(refreshError) &&
|
||||
isCurrentAuthState(refreshAuthStateSnapshot);
|
||||
if (shouldClearAuth) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
@@ -897,7 +985,9 @@ async function buildApiClientError(
|
||||
const baseMessage = parseApiErrorMessage(responseText, fallbackMessage);
|
||||
|
||||
return new ApiClientError({
|
||||
message: requestId ? `${baseMessage}(requestId: ${requestId})` : baseMessage,
|
||||
message: requestId
|
||||
? `${baseMessage}(requestId: ${requestId})`
|
||||
: baseMessage,
|
||||
status: response.status,
|
||||
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
|
||||
details: parsedError?.details ?? null,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
generateEditorCharacterAnimation,
|
||||
generateEditorIconSpritesheet,
|
||||
generateEditorImage,
|
||||
generateEditorScene,
|
||||
generateEditorSoundEffect,
|
||||
generateEditorVideo,
|
||||
listEditorProjects,
|
||||
@@ -966,6 +967,60 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('generates editor scenes through the structured endpoint without automatic retries', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
queueState: { jobId: 'scene-job-1', status: 'pending' },
|
||||
});
|
||||
|
||||
await generateEditorScene({
|
||||
sceneContent: '雨夜中的欧洲小镇街道',
|
||||
stylePreset: 'anime',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: ['editor-resource-reference'],
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/scenes/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sceneContent: '雨夜中的欧洲小镇街道',
|
||||
stylePreset: 'anime',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: ['editor-resource-reference'],
|
||||
}),
|
||||
}),
|
||||
'生成游戏场景失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
retryUnsafeMethods: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects scene reference overflow without sending an HTTP request', async () => {
|
||||
await expect(
|
||||
generateEditorScene({
|
||||
sceneContent: '雨夜中的欧洲小镇街道',
|
||||
stylePreset: 'anime',
|
||||
referenceImageSrcs: Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) =>
|
||||
`/generated-images/editor/scene-reference-${index + 1}.png`,
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow('场景参考图最多允许 5 张');
|
||||
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects inline media before submitting editor generation requests', async () => {
|
||||
await expect(
|
||||
generateEditorImage({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BackgroundMusicPromptAssistRequest,
|
||||
BackgroundMusicPromptAssistResponse,
|
||||
} from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import type { EditorSceneGenerationRequest } from '../../../packages/shared/src/contracts/editorScene';
|
||||
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { requestJson } from '../apiClient';
|
||||
import { EDITOR_GENERATION_REQUEST_RETRY_OPTIONS } from './editorRetryOptions';
|
||||
@@ -12,6 +13,7 @@ const EDITOR_SHOWCASE_RESOURCE_API = '/api/editor/showcase/resources';
|
||||
const EDITOR_SHOWCASE_ASSET_API_BASE = '/api/editor/showcase/assets';
|
||||
const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources';
|
||||
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
|
||||
const EDITOR_SCENE_GENERATION_API = '/api/editor/scenes/generations';
|
||||
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
|
||||
const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps';
|
||||
@@ -1036,6 +1038,45 @@ export async function toggleEditorShowcaseAssetLike(
|
||||
return response.showcaseAsset;
|
||||
}
|
||||
|
||||
export type EditorSceneGenerationInput = EditorSceneGenerationRequest;
|
||||
|
||||
export async function generateEditorScene(input: EditorSceneGenerationInput) {
|
||||
assertStableEditorMediaReferences(input.referenceImageSrcs, '场景参考图');
|
||||
assertEditorReferenceLimit(
|
||||
input.referenceImageSrcs,
|
||||
EDITOR_IMAGE_REFERENCE_LIMIT,
|
||||
'场景参考图',
|
||||
);
|
||||
return requestJson<EditorImageGenerationResponse>(
|
||||
EDITOR_SCENE_GENERATION_API,
|
||||
jsonRequest('POST', {
|
||||
sceneContent: input.sceneContent,
|
||||
stylePreset: input.stylePreset,
|
||||
...(input.customStyle ? { customStyle: input.customStyle } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
|
||||
...(input.imageSize ? { imageSize: input.imageSize } : {}),
|
||||
...(input.referenceImageSrcs?.length
|
||||
? { referenceImageSrcs: input.referenceImageSrcs }
|
||||
: {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.generationInputs
|
||||
? { generationInputs: input.generationInputs }
|
||||
: {}),
|
||||
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
|
||||
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
|
||||
...(input.canvasCompletion
|
||||
? { canvasCompletion: input.canvasCompletion }
|
||||
: {}),
|
||||
}),
|
||||
'生成游戏场景失败',
|
||||
{
|
||||
timeoutMs: 1_200_000,
|
||||
retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
assertStableEditorMediaReferences(input.referenceImageSrcs, '生成参考图');
|
||||
const referenceLimit =
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
fetchWithApiAuth: vi.fn(),
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => apiClientMocks);
|
||||
|
||||
import {
|
||||
createPlatformProfileRechargeOrder,
|
||||
getPlatformProfileRechargeCenter,
|
||||
redeemPlatformProfileReferralInviteCode,
|
||||
redeemPlatformProfileRewardCode,
|
||||
} from './platformProfileClient';
|
||||
|
||||
describe('platformProfileClient', () => {
|
||||
beforeEach(() => {
|
||||
apiClientMocks.requestJson.mockReset();
|
||||
apiClientMocks.requestJson.mockResolvedValue({});
|
||||
});
|
||||
|
||||
test('forwards the recharge-center abort signal through request options', async () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
await getPlatformProfileRechargeCenter({
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/profile/recharge-center',
|
||||
{
|
||||
method: 'GET',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
'读取泥点购买信息失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
'充值下单',
|
||||
(signal: AbortSignal) =>
|
||||
createPlatformProfileRechargeOrder('points-60', 'wechat_native', {
|
||||
signal,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'邀请码兑换',
|
||||
(signal: AbortSignal) =>
|
||||
redeemPlatformProfileReferralInviteCode('INVITE01', { signal }),
|
||||
],
|
||||
[
|
||||
'奖励码兑换',
|
||||
(signal: AbortSignal) =>
|
||||
redeemPlatformProfileRewardCode('REWARD01', { signal }),
|
||||
],
|
||||
])('%s write forwards its account abort signal', async (_label, request) => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
await request(abortController.signal);
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/api\/profile\//u),
|
||||
expect.objectContaining({ signal: abortController.signal }),
|
||||
expect.any(String),
|
||||
expect.objectContaining({ retry: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user