fix: render registration errors as text

This commit is contained in:
boos
2026-06-24 10:52:20 +08:00
parent f9118583ff
commit 86bbfe2f3c
2 changed files with 176 additions and 2 deletions
+129
View File
@@ -470,6 +470,135 @@ describe('Login surface', () => {
} }
}); });
it('renders structured user registration errors as readable text', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
id: 'captcha-test',
image: 'data:image/png;base64,test',
},
}),
})
.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({
error: {
message: '图像验证码错误,请重新输入',
type: 'Bad Request',
},
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
id: 'captcha-next',
image: 'data:image/png;base64,next',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<UserRegister />
</MemoryRouter>,
);
});
await act(async () => {
root.root.findByProps({ id: 'reg-username-input' }).props.onChange({ target: { value: 'demo' } });
root.root.findByProps({ id: 'reg-email-input' }).props.onChange({ target: { value: 'demo@example.com' } });
root.root.findByProps({ id: 'reg-password-input' }).props.onChange({ target: { value: 'secret1' } });
root.root.findByProps({ id: 'reg-confirm-password-input' }).props.onChange({ target: { value: 'secret1' } });
root.root.findByProps({ id: 'reg-captcha-input' }).props.onChange({ target: { value: 'wrong' } });
});
await act(async () => {
await root.root.findByType('form').props.onSubmit({ preventDefault: vi.fn() });
});
const pageText = collectText(root.root);
expect(pageText).toContain('图像验证码错误,请重新输入');
expect(pageText).not.toContain('[object Object]');
expect(pageText).not.toContain('Object');
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/users/register', expect.objectContaining({
method: 'POST',
}));
} finally {
act(() => {
root?.unmount();
});
}
});
it('does not render object-shaped registration exceptions as object text', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
id: 'captcha-test',
image: 'data:image/png;base64,test',
},
}),
})
.mockRejectedValueOnce({
error: {
message: '注册请求过于频繁,请稍后再试。',
},
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
id: 'captcha-next',
image: 'data:image/png;base64,next',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<UserRegister />
</MemoryRouter>,
);
});
await act(async () => {
root.root.findByProps({ id: 'reg-username-input' }).props.onChange({ target: { value: 'demo' } });
root.root.findByProps({ id: 'reg-email-input' }).props.onChange({ target: { value: 'demo@example.com' } });
root.root.findByProps({ id: 'reg-password-input' }).props.onChange({ target: { value: 'secret1' } });
root.root.findByProps({ id: 'reg-confirm-password-input' }).props.onChange({ target: { value: 'secret1' } });
root.root.findByProps({ id: 'reg-captcha-input' }).props.onChange({ target: { value: 'wrong' } });
});
await act(async () => {
await root.root.findByType('form').props.onSubmit({ preventDefault: vi.fn() });
});
const pageText = collectText(root.root);
expect(pageText).toContain('注册请求过于频繁,请稍后再试。');
expect(pageText).not.toContain('[object Object]');
} finally {
act(() => {
root?.unmount();
});
}
});
it('keeps the local WeChat OAuth mark unclipped in CSS', () => { it('keeps the local WeChat OAuth mark unclipped in CSS', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n'); const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
+47 -2
View File
@@ -4,6 +4,51 @@ import { commitSession } from '../authSession.js';
import LoginShell from './auth/LoginShell.js'; import LoginShell from './auth/LoginShell.js';
import OAuthSlot from './auth/OAuthSlot.js'; import OAuthSlot from './auth/OAuthSlot.js';
function isObjectObjectText(value: string): boolean {
return value.trim() === '[object Object]' || value.trim() === 'Object';
}
function readableObjectSummary(value: Record<string, unknown>): string {
try {
const text = JSON.stringify(value);
return text && !isObjectObjectText(text) ? text.slice(0, 160) : '';
} catch {
return '';
}
}
function resolveRegisterErrorCandidate(value: unknown): string {
if (typeof value === 'string') {
const text = value.trim();
return text && !isObjectObjectText(text) ? text : '';
}
if (!value || typeof value !== 'object') {
return '';
}
const record = value as Record<string, unknown>;
for (const key of ['message', 'error', 'detail', 'reason', 'msg']) {
const nested = resolveRegisterErrorCandidate(record[key]);
if (nested) return nested;
}
return readableObjectSummary(record);
}
function resolveRegisterErrorMessage(payload: any, fallback: string): string {
const candidates = [
payload?.message,
payload?.error?.message,
payload?.error,
payload?.detail?.message,
payload?.detail,
];
for (const candidate of candidates) {
const message = resolveRegisterErrorCandidate(candidate);
if (message) return message;
}
return fallback;
}
export default function UserRegister({ onLogin }: { onLogin?: () => void }) { export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -87,14 +132,14 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `注册失败 (${res.status})`); throw new Error(resolveRegisterErrorMessage(data, `注册失败 (${res.status})`));
} }
const result = await res.json(); const result = await res.json();
commitSession('user', result.token, undefined, localStorage); commitSession('user', result.token, undefined, localStorage);
onLogin?.(); onLogin?.();
navigate('/user/dashboard'); navigate('/user/dashboard');
} catch (err: any) { } catch (err: any) {
setError(err?.message || '注册失败'); setError(resolveRegisterErrorMessage(err, '注册失败'));
void loadCaptcha(); void loadCaptcha();
} finally { } finally {
setLoading(false); setLoading(false);