From 89859bae1622116994bd343daf28c326b292f75b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 04:43:46 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20[testing=20improvement]=20Add=20?= =?UTF-8?q?tests=20for=20auth=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: sunnylqm <615282+sunnylqm@users.noreply.github.com> --- bun-test-setup.ts | 24 ++++- bun.lock | 1 - src/globals.d.ts | 7 ++ src/services/auth.test.ts | 178 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 src/services/auth.test.ts diff --git a/bun-test-setup.ts b/bun-test-setup.ts index b755ae3..290abe5 100644 --- a/bun-test-setup.ts +++ b/bun-test-setup.ts @@ -1,7 +1,20 @@ import { mock } from 'bun:test'; +import { plugin } from 'bun'; import { GlobalWindow } from 'happy-dom'; -const win = new GlobalWindow(); +plugin({ + name: 'svg-loader', + setup(builder) { + builder.onLoad({ filter: /\.svg$/ }, () => { + return { + exports: { ReactComponent: () => null, default: '' }, + loader: 'object', + }; + }); + }, +}); + +const win = new GlobalWindow({ url: 'http://localhost' }); global.window = win as any; global.document = win.document as any; global.navigator = win.navigator as any; @@ -10,6 +23,7 @@ global.addEventListener = win.addEventListener.bind(win) as any; global.removeEventListener = win.removeEventListener.bind(win) as any; global.dispatchEvent = win.dispatchEvent.bind(win) as any; global.StorageEvent = win.StorageEvent as any; +global.location = win.location as any; // Mock dependencies that cause side effects or fail on static assets imports mock.module('@/services/api', () => ({ @@ -17,5 +31,13 @@ mock.module('@/services/api', () => ({ })); mock.module('@/services/request', () => ({ getToken: () => '', + setToken: () => {}, + RequestError: class RequestError extends Error { + status: number; + constructor(msg: string, status: number) { + super(msg); + this.status = status; + } + }, default: {}, })); diff --git a/bun.lock b/bun.lock index 8f70800..86915b2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "pushy-admin", diff --git a/src/globals.d.ts b/src/globals.d.ts index d6a174b..aae5bef 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -29,12 +29,19 @@ declare module 'bun:test' { export function expect(actual: T): { toBe(expected: unknown): void; toBeNull(): void; + toBeInstanceOf(expected: unknown): void; + toEqual(expected: unknown): void; + toHaveBeenCalled(): void; + toHaveBeenCalledWith(...args: unknown[]): void; + rejects: any; + resolves: any; }; export function beforeEach(fn: () => void | Promise): void; export function afterEach(fn: () => void | Promise): void; export function setSystemTime(time: Date | number | null): void; export const mock: { module(path: string, factory: () => any): void; + any>(impl?: T): T & { mock: any }; }; } diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts new file mode 100644 index 0000000..f0158a0 --- /dev/null +++ b/src/services/auth.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +const mockMessageSuccess = mock(); +const mockMessageError = mock(); +mock.module('antd', () => ({ + message: { + success: mockMessageSuccess, + error: mockMessageError, + }, +})); + +mock.module('hash-wasm', () => ({ + md5: mock(async (str: string) => `${str}_hashed`), +})); + +const mockNavigate = mock(); +let mockSearch = ''; +let mockPathname = '/'; +const mockRootRouterPath = { + login: '/login', + apps: '/apps', + inactivated: '/inactivated', +}; + +mock.module('@/router', () => ({ + rootRouterPath: mockRootRouterPath, + router: { + get state() { + return { + location: { search: mockSearch, pathname: mockPathname }, + }; + }, + navigate: mockNavigate, + }, +})); + +class MockRequestError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +const mockApiLogin = mock(); +mock.module('@/services/api', () => ({ + api: { + login: mockApiLogin, + }, +})); + +const mockSetToken = mock(); +mock.module('@/services/request', () => ({ + RequestError: MockRequestError, + setToken: mockSetToken, +})); + +const mockReload = mock(); +Object.defineProperty(window.location, 'reload', { + value: mockReload, + configurable: true, + writable: true, +}); + +import { getUserEmail, login, logout, setUserEmail } from './auth'; + +describe('auth service', () => { + beforeEach(() => { + mockMessageSuccess.mockClear(); + mockMessageError.mockClear(); + mockNavigate.mockClear(); + mockApiLogin.mockClear(); + mockSetToken.mockClear(); + mockReload.mockClear(); + mockSearch = ''; + mockPathname = '/'; + }); + + describe('login', () => { + test('successful login sets token, email, shows message, and navigates', async () => { + mockApiLogin.mockResolvedValue({ token: 'valid-token' }); + mockSearch = '?loginFrom=/custom-path'; + + await login('test@example.com', 'mypassword'); + + expect(getUserEmail()).toBe('test@example.com'); + expect(mockApiLogin).toHaveBeenCalledWith({ + email: 'test@example.com', + pwd: 'mypassword_hashed', + }); + expect(mockSetToken).toHaveBeenCalledWith('valid-token'); + expect(mockMessageSuccess).toHaveBeenCalledWith('登录成功'); + expect(mockNavigate).toHaveBeenCalledWith('/custom-path'); + }); + + test('resolveLoginFrom fallback when loginFrom is empty or invalid', async () => { + mockApiLogin.mockResolvedValue({ token: 'valid-token' }); + + // Empty + mockSearch = ''; + await login('test1@example.com', 'pwd'); + expect(mockNavigate).toHaveBeenCalledWith('/apps'); + + // Not starting with / + mockSearch = '?loginFrom=https://evil.com'; + await login('test2@example.com', 'pwd'); + expect(mockNavigate).toHaveBeenCalledWith('/apps'); + + // Starts with // + mockSearch = '?loginFrom=//evil.com'; + await login('test3@example.com', 'pwd'); + expect(mockNavigate).toHaveBeenCalledWith('/apps'); + + // Is /login + mockSearch = '?loginFrom=/login'; + await login('test4@example.com', 'pwd'); + expect(mockNavigate).toHaveBeenCalledWith('/apps'); + + // Starts with /login? + mockSearch = '?loginFrom=/login?redirect=/apps'; + await login('test5@example.com', 'pwd'); + expect(mockNavigate).toHaveBeenCalledWith('/apps'); + }); + + test('handles 423 RequestError by navigating to inactivated page', async () => { + mockApiLogin.mockRejectedValue(new MockRequestError('Inactivated', 423)); + + await login('inactive@example.com', 'pwd'); + + expect(mockNavigate).toHaveBeenCalledWith('/inactivated'); + expect(mockMessageError).not.toHaveBeenCalled(); + }); + + test('handles other errors by showing error message', async () => { + mockApiLogin.mockRejectedValue(new Error('Invalid credentials')); + + await login('wrong@example.com', 'pwd'); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockMessageError).toHaveBeenCalledWith('Invalid credentials'); + }); + + test('handles unknown errors by showing fallback message', async () => { + mockApiLogin.mockRejectedValue('Some string error'); + + await login('unknown@example.com', 'pwd'); + + expect(mockMessageError).toHaveBeenCalledWith('登录失败'); + }); + }); + + describe('logout', () => { + test('clears token, navigates to login if not already there, and reloads', () => { + mockPathname = '/apps'; + logout(); + + expect(mockSetToken).toHaveBeenCalledWith(''); + expect(mockNavigate).toHaveBeenCalledWith('/login'); + expect(mockReload).toHaveBeenCalled(); + }); + + test('clears token and reloads, but does not navigate if already on login page', () => { + mockPathname = '/login'; + logout(); + + expect(mockSetToken).toHaveBeenCalledWith(''); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockReload).toHaveBeenCalled(); + }); + }); + + describe('setUserEmail', () => { + test('updates the email', () => { + setUserEmail('new@example.com'); + expect(getUserEmail()).toBe('new@example.com'); + }); + }); +});