Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb

# Conflicts:
#	docs/technical/README.md
#	server-node/src/modules/assets/qwenSpriteRoutes.ts
#	src/components/CustomWorldResultView.test.tsx
#	src/components/CustomWorldResultView.tsx
#	src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
#	src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
#	src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
#	src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
#	src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
#	src/services/apiClient.ts
#	src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
2026-04-21 20:16:01 +08:00
477 changed files with 38047 additions and 26570 deletions

View File

@@ -1,17 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
const apiClientMocks = vi.hoisted(() => ({
emitAuthStateChange: vi.fn(),
requestJson: vi.fn(),
}));
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
getStoredAccessToken,
getStoredAutoAuthCredentials,
setStoredAccessToken,
} from './apiClient';
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
requestJson: apiClientMocks.requestJson,
};
});
import { ApiClientError } from './apiClient';
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
import {
authEntryWithStoredCredentials,
bindWechatPhone,
@@ -24,50 +28,55 @@ import {
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
revokeAuthSession,
sendPhoneLoginCode,
startWechatLogin,
} from './authService';
function createMemoryStorage() {
const values = new Map<string, string>();
function createLocalStorageMock() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return values.has(key) ? values.get(key)! : null;
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
values.set(key, value);
store.set(key, String(value));
},
removeItem(key: string) {
values.delete(key);
store.delete(key);
},
clear() {
values.clear();
store.clear();
},
};
}
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
function createWindowMock(overrides: Record<string, unknown> = {}) {
return {
...actual,
requestJson: requestJsonMock,
dispatchEvent: vi.fn(),
localStorage: createLocalStorageMock(),
location: {
pathname: '/',
hash: '',
search: '',
assign: vi.fn(),
},
history: {
replaceState: vi.fn(),
},
...overrides,
};
});
}
describe('authService auto auth', () => {
describe('authService', () => {
beforeEach(() => {
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
});
requestJsonMock.mockReset();
clearStoredAccessToken();
clearStoredAutoAuthCredentials();
vi.clearAllMocks();
vi.stubGlobal('window', createWindowMock());
clearStoredAccessToken({ emit: false });
});
it('creates credentials that match current username/password constraints', () => {
@@ -78,9 +87,9 @@ describe('authService auto auth', () => {
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
});
it('stores jwt and auto credentials after auth entry', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-token-value',
it('auth entry trims guest credentials and写入 access token', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-entry-token',
user: {
id: 'user_1',
username: 'guest_abc123abc123',
@@ -98,12 +107,7 @@ describe('authService auto auth', () => {
});
expect(user.username).toBe('guest_abc123abc123');
expect(getStoredAccessToken()).toBe('jwt-token-value');
expect(getStoredAutoAuthCredentials()).toEqual({
username: 'guest_abc123abc123',
password: 'auto_secret_password',
});
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
@@ -113,13 +117,13 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(getStoredAccessToken()).toBe('jwt-entry-token');
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
});
it('reuses stored auto credentials before generating a new account', async () => {
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
requestJsonMock.mockResolvedValue({
token: 'jwt-restored',
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-auto-token',
user: {
id: 'user_saved',
username: 'guest_saveduser01',
@@ -132,24 +136,25 @@ describe('authService auto auth', () => {
});
const result = await ensureAutoAuthUser();
const authEntryBody = JSON.parse(
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
};
expect(result.user.username).toBe('guest_saveduser01');
expect(result.credentials).toEqual({
username: 'guest_saveduser01',
password: 'auto_saved_password',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
method: 'POST',
}),
'登录失败',
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
expect(result.credentials.password).toMatch(
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
);
expect(authEntryBody).toEqual(result.credentials);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent auto auth requests', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-auto',
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-auto-shared-token',
user: {
id: 'user_auto',
username: 'guest_auto',
@@ -166,13 +171,12 @@ describe('authService auto auth', () => {
ensureAutoAuthUser(),
]);
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
});
it('sends phone login code through the new auth endpoint', async () => {
requestJsonMock.mockResolvedValue({
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
@@ -182,7 +186,7 @@ describe('authService auto auth', () => {
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
expect(result.cooldownSeconds).toBe(60);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
@@ -194,28 +198,6 @@ describe('authService auto auth', () => {
);
});
it('sends phone change code with the correct scene', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
providerRequestId: 'mock-request-id',
});
await sendPhoneLoginCode('13900139000', 'change_phone');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
scene: 'change_phone',
}),
}),
'发送验证码失败',
);
});
it('extracts captcha challenge details from api errors', () => {
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
@@ -241,9 +223,9 @@ describe('authService auto auth', () => {
});
});
it('stores jwt after phone login', async () => {
requestJsonMock.mockResolvedValue({
token: 'phone-jwt-token',
it('stores renewed access token after phone login', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-phone-token',
user: {
id: 'user_phone',
username: '138****8000',
@@ -258,8 +240,7 @@ describe('authService auto auth', () => {
const user = await loginWithPhoneCode('13800138000', '123456');
expect(user.username).toBe('138****8000');
expect(getStoredAccessToken()).toBe('phone-jwt-token');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
@@ -269,11 +250,13 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(getStoredAccessToken()).toBe('jwt-phone-token');
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
});
it('binds wechat phone and stores jwt after activation', async () => {
requestJsonMock.mockResolvedValue({
token: 'wechat-bind-token',
it('stores renewed access token after wechat bind activation', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-wechat-bind-token',
user: {
id: 'user_wechat',
username: '138****8000',
@@ -288,22 +271,12 @@ describe('authService auto auth', () => {
const user = await bindWechatPhone('13800138000', '123456');
expect(user.wechatBound).toBe(true);
expect(getStoredAccessToken()).toBe('wechat-bind-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/wechat/bind-phone',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
code: '123456',
}),
}),
'绑定手机号失败',
);
expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token');
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
});
it('changes phone number without replacing the stored access token', async () => {
setStoredAccessToken('active-token');
requestJsonMock.mockResolvedValue({
it('changes phone number without emitting a global auth state refresh', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
username: '139****9000',
@@ -318,41 +291,29 @@ describe('authService auto auth', () => {
const user = await changePhoneNumber('13900139000', '123456');
expect(user.phoneNumberMasked).toBe('139****9000');
expect(getStoredAccessToken()).toBe('active-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/change',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
code: '123456',
}),
}),
'更换手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
});
it('starts wechat login by navigating to backend authorization url', async () => {
const assignMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
history: {
replaceState: vi.fn(),
},
});
requestJsonMock.mockResolvedValue({
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
await startWechatLogin();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/start?redirectPath=%2F',
expect.objectContaining({
method: 'GET',
@@ -365,14 +326,14 @@ describe('authService auto auth', () => {
});
it('loads available login methods for the unauthenticated login screen', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
const result = await getAuthLoginOptions();
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/login-options',
expect.objectContaining({
method: 'GET',
@@ -381,20 +342,22 @@ describe('authService auto auth', () => {
);
});
it('consumes auth callback hash and stores token', () => {
it('consumes auth callback hash and persists the returned access token', () => {
const replaceStateMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
},
history: {
replaceState: replaceStateMock,
},
});
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
assign: vi.fn(),
},
history: {
replaceState: replaceStateMock,
},
}),
);
const result = consumeAuthCallbackResult();
@@ -403,12 +366,37 @@ describe('authService auto auth', () => {
bindingStatus: 'pending_bind_phone',
error: null,
});
expect(getStoredAccessToken()).toBe('wx-token');
expect(getStoredAccessToken()).toBe('jwt-callback-token');
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
});
it('gets current auth user with silent auth-state notification settings', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
const result = await getCurrentAuthUser();
expect(result).toEqual({
user: null,
availableLoginMethods: ['phone'],
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/me',
expect.objectContaining({
method: 'GET',
}),
'读取当前用户失败',
{
notifyAuthStateChange: false,
},
);
});
it('loads auth sessions from account center endpoint', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
sessions: [
{
sessionId: 'usess_1',
@@ -427,17 +415,10 @@ describe('authService auto auth', () => {
const sessions = await getAuthSessions();
expect(sessions).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions',
expect.objectContaining({
method: 'GET',
}),
'读取登录设备失败',
);
});
it('loads recent auth audit logs', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
logs: [
{
id: 'audit_1',
@@ -454,17 +435,10 @@ describe('authService auto auth', () => {
const logs = await getAuthAuditLogs();
expect(logs).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/audit-logs',
expect.objectContaining({
method: 'GET',
}),
'读取账号操作记录失败',
);
});
it('loads current risk blocks', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
blocks: [
{
scopeType: 'phone',
@@ -479,23 +453,16 @@ describe('authService auto auth', () => {
const blocks = await getAuthRiskBlocks();
expect(blocks).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/risk-blocks',
expect.objectContaining({
method: 'GET',
}),
'读取安全状态失败',
);
});
it('lifts a risk block by scope type', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await liftAuthRiskBlock('phone');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/risk-blocks/phone/lift',
expect.objectContaining({
method: 'POST',
@@ -504,37 +471,20 @@ describe('authService auto auth', () => {
);
});
it('revokes a remote auth session by id', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
});
await revokeAuthSession('usess_123');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions/usess_123/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('clears local auth state after logout all sessions', async () => {
setStoredAccessToken('stale-token');
requestJsonMock.mockResolvedValue({
it('emits auth change after logout all sessions', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await logoutAllAuthSessions();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
}),
'退出全部设备失败',
);
expect(getStoredAccessToken()).toBe('');
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
});