修复泥点消耗刷新不及时 (#138)
refactor: - 主站复用game agent的zustand store fix: - 修复旧请求覆盖问题 - 修复账号切换问题 - 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #138.
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、防火墙或代理配置',
|
||||
|
||||
Reference in New Issue
Block a user