mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge remote-tracking branch 'origin/main' into feature/notification-system
This commit is contained in:
commit
3f7145a843
116 changed files with 5151 additions and 3 deletions
157
web/src/api/client.test.ts
Normal file
157
web/src/api/client.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
function setMockWindow(runtimeConfig?: Window['__SKILLHUB_RUNTIME_CONFIG__']) {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
__SKILLHUB_RUNTIME_CONFIG__: runtimeConfig,
|
||||
} satisfies Pick<Window, '__SKILLHUB_RUNTIME_CONFIG__'>,
|
||||
})
|
||||
}
|
||||
|
||||
// Mock i18n before importing client
|
||||
vi.mock('@/i18n/config', () => ({
|
||||
default: { resolvedLanguage: 'en' },
|
||||
}))
|
||||
|
||||
// Mock api-error before importing client
|
||||
vi.mock('@/shared/lib/api-error', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
serverMessage?: string
|
||||
serverMessageKey?: string
|
||||
constructor(message: string, status: number, serverMessage?: string, serverMessageKey?: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.serverMessage = serverMessage
|
||||
this.serverMessageKey = serverMessageKey
|
||||
}
|
||||
},
|
||||
handleApiError: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
WEB_API_PREFIX,
|
||||
buildApiUrl,
|
||||
getDirectAuthRuntimeConfig,
|
||||
getSessionBootstrapRuntimeConfig,
|
||||
} from './client'
|
||||
|
||||
beforeEach(() => {
|
||||
setMockWindow()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalWindow,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('WEB_API_PREFIX', () => {
|
||||
it('uses the /api/web prefix for web-facing endpoints', () => {
|
||||
expect(WEB_API_PREFIX).toBe('/api/web')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildApiUrl', () => {
|
||||
it('returns the path as-is when no runtime base URL is configured', () => {
|
||||
expect(buildApiUrl('/api/v1/auth/me')).toBe('/api/v1/auth/me')
|
||||
})
|
||||
|
||||
it('prepends the runtime base URL when one is set', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' }
|
||||
const url = buildApiUrl('/api/v1/auth/me')
|
||||
expect(url).toBe('https://api.example.com/api/v1/auth/me')
|
||||
})
|
||||
|
||||
it('handles a trailing slash on the base URL', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/' }
|
||||
const url = buildApiUrl('/api/v1/auth/me')
|
||||
expect(url).toBe('https://api.example.com/api/v1/auth/me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDirectAuthRuntimeConfig', () => {
|
||||
it('returns disabled when no runtime config is present', () => {
|
||||
const config = getDirectAuthRuntimeConfig()
|
||||
expect(config.enabled).toBe(false)
|
||||
expect(config.provider).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns enabled with provider when both flag and provider are set', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
authDirectEnabled: 'true',
|
||||
authDirectProvider: 'ldap',
|
||||
}
|
||||
const config = getDirectAuthRuntimeConfig()
|
||||
expect(config.enabled).toBe(true)
|
||||
expect(config.provider).toBe('ldap')
|
||||
})
|
||||
|
||||
it('returns disabled when the flag is true but the provider is missing', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
authDirectEnabled: 'true',
|
||||
}
|
||||
const config = getDirectAuthRuntimeConfig()
|
||||
expect(config.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns disabled when the flag is false', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
authDirectEnabled: 'false',
|
||||
authDirectProvider: 'ldap',
|
||||
}
|
||||
const config = getDirectAuthRuntimeConfig()
|
||||
expect(config.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('treats various truthy flag values correctly', () => {
|
||||
for (const flag of ['1', 'yes', 'on', 'TRUE', ' True ']) {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
authDirectEnabled: flag,
|
||||
authDirectProvider: 'ldap',
|
||||
}
|
||||
expect(getDirectAuthRuntimeConfig().enabled).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSessionBootstrapRuntimeConfig', () => {
|
||||
it('returns disabled when no runtime config is present', () => {
|
||||
const config = getSessionBootstrapRuntimeConfig()
|
||||
expect(config.enabled).toBe(false)
|
||||
expect(config.auto).toBe(false)
|
||||
expect(config.provider).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns fully enabled config when all flags and provider are set', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
authSessionBootstrapEnabled: '1',
|
||||
authSessionBootstrapProvider: 'sso',
|
||||
authSessionBootstrapAuto: 'true',
|
||||
}
|
||||
const config = getSessionBootstrapRuntimeConfig()
|
||||
expect(config.enabled).toBe(true)
|
||||
expect(config.provider).toBe('sso')
|
||||
expect(config.auto).toBe(true)
|
||||
})
|
||||
|
||||
it('returns disabled when the provider is blank', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
authSessionBootstrapEnabled: 'true',
|
||||
authSessionBootstrapProvider: ' ',
|
||||
}
|
||||
const config = getSessionBootstrapRuntimeConfig()
|
||||
expect(config.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_HEADER_ELEVATED_CLASS_NAME, getAppHeaderClassName } from '../../src/app/layout-header-style'
|
||||
import { APP_HEADER_ELEVATED_CLASS_NAME, getAppHeaderClassName } from './layout-header-style'
|
||||
|
||||
describe('getAppHeaderClassName', () => {
|
||||
it('keeps the header flat before the page starts scrolling', () => {
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
DEFAULT_MAIN_CLASS_NAME,
|
||||
getAppMainContentLayout,
|
||||
resolveAppMainContentPathname,
|
||||
} from '../../src/app/layout-main-content'
|
||||
} from './layout-main-content'
|
||||
|
||||
describe('getAppMainContentLayout', () => {
|
||||
it('keeps the landing page full width without the app-shell padding wrapper', () => {
|
||||
57
web/src/app/layout.test.ts
Normal file
57
web/src/app/layout.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Layout is a component-only file with no exported pure functions or constants.
|
||||
// We verify that the named export exists for the router to consume.
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Outlet: () => null,
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
useRouterState: () => ({ pathname: '/', resolvedPathname: '/' }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/language-switcher', () => ({
|
||||
LanguageSwitcher: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/user-menu', () => ({
|
||||
UserMenu: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./layout-header-style', () => ({
|
||||
getAppHeaderClassName: () => 'header-class',
|
||||
}))
|
||||
|
||||
vi.mock('./layout-main-content', () => ({
|
||||
resolveAppMainContentPathname: (p: string) => p,
|
||||
getAppMainContentLayout: () => ({
|
||||
mainClassName: 'main-class',
|
||||
contentClassName: 'content-class',
|
||||
}),
|
||||
}))
|
||||
|
||||
import { Layout } from './layout'
|
||||
|
||||
describe('Layout', () => {
|
||||
it('exports a named Layout component function', () => {
|
||||
expect(typeof Layout).toBe('function')
|
||||
expect(Layout.name).toBe('Layout')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SHELL_PAGE_CLASS_NAME } from '../../src/app/page-shell-style'
|
||||
import { APP_SHELL_PAGE_CLASS_NAME } from './page-shell-style'
|
||||
|
||||
describe('APP_SHELL_PAGE_CLASS_NAME', () => {
|
||||
it('keeps the upward float-in animation on stable app-shell pages', () => {
|
||||
36
web/src/app/providers.test.ts
Normal file
36
web/src/app/providers.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// App/providers is a wiring module that sets up QueryClient, RouterProvider,
|
||||
// and Toaster. It exports only the App component. We verify the export exists.
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
QueryClient: vi.fn().mockImplementation(() => ({})),
|
||||
QueryClientProvider: ({ children }: { children: unknown }) => children,
|
||||
QueryCache: vi.fn().mockImplementation(() => ({})),
|
||||
MutationCache: vi.fn().mockImplementation(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
RouterProvider: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/toaster', () => ({
|
||||
Toaster: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/api-error', () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./router', () => ({
|
||||
router: {},
|
||||
}))
|
||||
|
||||
import { App } from './providers'
|
||||
|
||||
describe('App', () => {
|
||||
it('exports a named App component function', () => {
|
||||
expect(typeof App).toBe('function')
|
||||
expect(App.name).toBe('App')
|
||||
})
|
||||
})
|
||||
42
web/src/app/router.test.ts
Normal file
42
web/src/app/router.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The router module captures window.location.search at module load time.
|
||||
// We test the exported ORIGINAL_URL_SEARCH constant and the buildReturnTo
|
||||
// helper (tested indirectly via the route tree structure).
|
||||
|
||||
vi.mock('./layout', () => ({
|
||||
Layout: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getCurrentUser: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/role-guard', () => ({
|
||||
RoleGuard: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/search-query', () => ({
|
||||
normalizeSearchQuery: (q: string) => q.trim(),
|
||||
}))
|
||||
|
||||
import { ORIGINAL_URL_SEARCH, router } from './router'
|
||||
|
||||
describe('ORIGINAL_URL_SEARCH', () => {
|
||||
it('is a string (captured from window.location.search at load time)', () => {
|
||||
expect(typeof ORIGINAL_URL_SEARCH).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('router', () => {
|
||||
it('exports a TanStack Router instance with a route tree', () => {
|
||||
expect(router).toBeDefined()
|
||||
expect(router.routeTree).toBeDefined()
|
||||
})
|
||||
|
||||
it('has a routeTree structure', () => {
|
||||
// The router instance exists and has the expected structure
|
||||
// In test environment, flatRoutes may not be populated until router is used
|
||||
expect(router.routeTree).toBeDefined()
|
||||
})
|
||||
})
|
||||
34
web/src/features/admin/use-admin-labels.test.ts
Normal file
34
web/src/features/admin/use-admin-labels.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as adminLabels from './use-admin-labels'
|
||||
|
||||
/**
|
||||
* use-admin-labels exports four thin useMutation hooks (useCreateAdminLabel,
|
||||
* useUpdateAdminLabel, useDeleteAdminLabel, useUpdateAdminLabelSortOrder) that
|
||||
* delegate to labelApi. Each invalidates ['labels'] and ['skills'] query caches
|
||||
* on success. It also re-exports useAdminLabelDefinitions from the shared hooks
|
||||
* module.
|
||||
*
|
||||
* There are no exported pure functions, constants, or data transformations to
|
||||
* unit-test. This file verifies the public API surface.
|
||||
*/
|
||||
describe('use-admin-labels module exports', () => {
|
||||
it('exports useAdminLabelDefinitions re-exported from shared hooks', () => {
|
||||
expect(adminLabels.useAdminLabelDefinitions).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useCreateAdminLabel hook', () => {
|
||||
expect(adminLabels.useCreateAdminLabel).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useUpdateAdminLabel hook', () => {
|
||||
expect(adminLabels.useUpdateAdminLabel).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useDeleteAdminLabel hook', () => {
|
||||
expect(adminLabels.useDeleteAdminLabel).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useUpdateAdminLabelSortOrder hook', () => {
|
||||
expect(adminLabels.useUpdateAdminLabelSortOrder).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
37
web/src/features/admin/use-admin-users.test.ts
Normal file
37
web/src/features/admin/use-admin-users.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as adminUsers from './use-admin-users'
|
||||
|
||||
/**
|
||||
* use-admin-users exports interfaces (AdminUsersParams, PagedAdminUsers) for typing,
|
||||
* a type re-export (AdminUser), and several thin hooks: useAdminUsers (query),
|
||||
* useUpdateUserRole, useUpdateUserStatus, useApproveUser, useDisableUser, useEnableUser
|
||||
* (mutations). All mutations invalidate ['admin', 'users'] and ['auth', 'me'] caches.
|
||||
*
|
||||
* There are no exported pure functions or data transformations to unit-test.
|
||||
* This file verifies the public API surface so that accidental export removals are caught.
|
||||
*/
|
||||
describe('use-admin-users module exports', () => {
|
||||
it('exports useAdminUsers query hook', () => {
|
||||
expect(adminUsers.useAdminUsers).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useUpdateUserRole mutation hook', () => {
|
||||
expect(adminUsers.useUpdateUserRole).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useUpdateUserStatus mutation hook', () => {
|
||||
expect(adminUsers.useUpdateUserStatus).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useApproveUser mutation hook', () => {
|
||||
expect(adminUsers.useApproveUser).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useDisableUser mutation hook', () => {
|
||||
expect(adminUsers.useDisableUser).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useEnableUser mutation hook', () => {
|
||||
expect(adminUsers.useEnableUser).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/features/admin/use-audit-log.test.ts
Normal file
16
web/src/features/admin/use-audit-log.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as auditLog from './use-audit-log'
|
||||
|
||||
/**
|
||||
* use-audit-log exports interfaces (AuditLogParams, PagedAuditLogs) for typing
|
||||
* and a single useQuery hook (useAuditLog) that delegates to adminApi.getAuditLogs.
|
||||
* The query key includes the full params object for cache isolation.
|
||||
*
|
||||
* There are no exported pure functions, constants, or data transformations to
|
||||
* unit-test. This file verifies the public API surface.
|
||||
*/
|
||||
describe('use-audit-log module exports', () => {
|
||||
it('exports useAuditLog query hook', () => {
|
||||
expect(auditLog.useAuditLog).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/features/auth/login-button.test.ts
Normal file
16
web/src/features/auth/login-button.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as loginButton from './login-button'
|
||||
|
||||
/**
|
||||
* LoginButton is a React component that renders OAuth login buttons from backend-provided
|
||||
* auth methods. It filters for OAUTH_REDIRECT method types and shows a loading state.
|
||||
* There are no exported pure functions, constants, or data transformations to unit-test.
|
||||
*
|
||||
* Full rendering tests would require a React test renderer, QueryClient provider,
|
||||
* and i18next setup. This file verifies the export surface.
|
||||
*/
|
||||
describe('login-button module exports', () => {
|
||||
it('exports LoginButton component', () => {
|
||||
expect(loginButton.LoginButton).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/auth/session-bootstrap-entry.test.ts
Normal file
17
web/src/features/auth/session-bootstrap-entry.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as sessionBootstrapEntry from './session-bootstrap-entry'
|
||||
|
||||
/**
|
||||
* SessionBootstrapEntry is a React component that attempts to bootstrap a browser
|
||||
* session from an upstream enterprise identity. It reads runtime config, manages
|
||||
* auto-bootstrap via useEffect/useRef, and renders a manual trigger button.
|
||||
* There are no exported pure functions, constants, or data transformations to unit-test.
|
||||
*
|
||||
* Full rendering tests would require a React test renderer, QueryClient provider,
|
||||
* and i18next setup. This file verifies the export surface.
|
||||
*/
|
||||
describe('session-bootstrap-entry module exports', () => {
|
||||
it('exports SessionBootstrapEntry component', () => {
|
||||
expect(sessionBootstrapEntry.SessionBootstrapEntry).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
23
web/src/features/auth/use-account-merge.test.ts
Normal file
23
web/src/features/auth/use-account-merge.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as accountMerge from './use-account-merge'
|
||||
|
||||
/**
|
||||
* use-account-merge exports three thin useMutation hooks (useInitiateAccountMerge,
|
||||
* useVerifyAccountMerge, useConfirmAccountMerge) that delegate directly to accountApi.
|
||||
* There are no exported pure functions, constants, or data transformations to unit-test.
|
||||
*
|
||||
* This file verifies the public API surface so that accidental export removals are caught.
|
||||
*/
|
||||
describe('use-account-merge module exports', () => {
|
||||
it('exports useInitiateAccountMerge hook', () => {
|
||||
expect(accountMerge.useInitiateAccountMerge).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useVerifyAccountMerge hook', () => {
|
||||
expect(accountMerge.useVerifyAccountMerge).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useConfirmAccountMerge hook', () => {
|
||||
expect(accountMerge.useConfirmAccountMerge).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
15
web/src/features/auth/use-auth-methods.test.ts
Normal file
15
web/src/features/auth/use-auth-methods.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as authMethods from './use-auth-methods'
|
||||
|
||||
/**
|
||||
* use-auth-methods is a thin useQuery wrapper around authApi.getMethods.
|
||||
* The query key includes the returnTo parameter for proper cache isolation.
|
||||
* There are no exported pure functions or data transformations to unit-test.
|
||||
*
|
||||
* This file verifies the public API surface so that accidental export removals are caught.
|
||||
*/
|
||||
describe('use-auth-methods module exports', () => {
|
||||
it('exports useAuthMethods hook', () => {
|
||||
expect(authMethods.useAuthMethods).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
20
web/src/features/auth/use-local-auth.test.ts
Normal file
20
web/src/features/auth/use-local-auth.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as localAuth from './use-local-auth'
|
||||
|
||||
/**
|
||||
* use-local-auth exports two thin useMutation hooks (useLocalLogin, useLocalRegister)
|
||||
* that delegate directly to authApi. Both update the ['auth', 'me'] query cache on
|
||||
* success. There are no exported pure functions, constants, or data transformations
|
||||
* to unit-test.
|
||||
*
|
||||
* This file verifies the public API surface so that accidental export removals are caught.
|
||||
*/
|
||||
describe('use-local-auth module exports', () => {
|
||||
it('exports useLocalLogin hook', () => {
|
||||
expect(localAuth.useLocalLogin).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports useLocalRegister hook', () => {
|
||||
expect(localAuth.useLocalRegister).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/features/auth/use-password-login.test.ts
Normal file
16
web/src/features/auth/use-password-login.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as passwordLogin from './use-password-login'
|
||||
|
||||
/**
|
||||
* use-password-login exports a single useMutation hook that conditionally routes to
|
||||
* authApi.directLogin or authApi.localLogin based on runtime configuration.
|
||||
* The branching logic lives inside the hook's mutationFn and cannot be unit-tested
|
||||
* without rendering the hook in a React/QueryClient context.
|
||||
*
|
||||
* This file verifies the public API surface so that accidental export removals are caught.
|
||||
*/
|
||||
describe('use-password-login module exports', () => {
|
||||
it('exports usePasswordLogin hook', () => {
|
||||
expect(passwordLogin.usePasswordLogin).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
15
web/src/features/auth/use-session-bootstrap.test.ts
Normal file
15
web/src/features/auth/use-session-bootstrap.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as sessionBootstrap from './use-session-bootstrap'
|
||||
|
||||
/**
|
||||
* use-session-bootstrap exports a single useMutation hook that delegates to
|
||||
* authApi.bootstrapSession and updates the ['auth', 'me'] query cache on success.
|
||||
* There are no exported pure functions, constants, or data transformations to unit-test.
|
||||
*
|
||||
* This file verifies the public API surface so that accidental export removals are caught.
|
||||
*/
|
||||
describe('use-session-bootstrap module exports', () => {
|
||||
it('exports useSessionBootstrap hook', () => {
|
||||
expect(sessionBootstrap.useSessionBootstrap).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
92
web/src/features/governance/governance-activity.test.ts
Normal file
92
web/src/features/governance/governance-activity.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { GovernanceActivityItem } from '@/api/types'
|
||||
|
||||
const { formatLocalDateTimeMock } = vi.hoisted(() => ({
|
||||
formatLocalDateTimeMock: vi.fn(() => '2026-03-23 12:00'),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: formatLocalDateTimeMock,
|
||||
}))
|
||||
|
||||
import { GovernanceActivity } from './governance-activity'
|
||||
|
||||
function createItem(overrides: Partial<GovernanceActivityItem> = {}): GovernanceActivityItem {
|
||||
return {
|
||||
id: 1,
|
||||
action: 'Skill approved',
|
||||
actorUserId: 'user-1',
|
||||
actorDisplayName: 'Alice',
|
||||
targetType: 'SKILL',
|
||||
targetId: 'skill-1',
|
||||
details: 'Approved by governance team',
|
||||
timestamp: '2026-03-23T04:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('governance-activity module', () => {
|
||||
beforeEach(() => {
|
||||
formatLocalDateTimeMock.mockClear()
|
||||
})
|
||||
|
||||
it('renders the loading shimmer', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernanceActivity, { isLoading: true }))
|
||||
|
||||
expect(html).toContain('animate-shimmer')
|
||||
})
|
||||
|
||||
it('renders the empty state', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernanceActivity, { isLoading: false, items: [] }))
|
||||
|
||||
expect(html).toContain('governance.emptyActivity')
|
||||
})
|
||||
|
||||
it('renders the activity item with formatted time, actor display name, and details', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceActivity, { isLoading: false, items: [createItem()] })
|
||||
)
|
||||
|
||||
expect(html).toContain('Skill approved')
|
||||
expect(html).toContain('Alice')
|
||||
expect(html).toContain('Approved by governance team')
|
||||
expect(html).toContain('2026-03-23 12:00')
|
||||
expect(formatLocalDateTimeMock).toHaveBeenCalledWith('2026-03-23T04:00:00Z', 'en')
|
||||
})
|
||||
|
||||
it('falls back to actor user id and unknown actor text when needed', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceActivity, {
|
||||
isLoading: false,
|
||||
items: [
|
||||
createItem({ id: 2, actorDisplayName: undefined, actorUserId: 'user-2', details: undefined }),
|
||||
createItem({ id: 3, actorDisplayName: undefined, actorUserId: undefined, details: undefined }),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
expect(html).toContain('user-2')
|
||||
expect(html).toContain('governance.unknownActor')
|
||||
})
|
||||
|
||||
it('omits the details block when details are missing', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceActivity, { isLoading: false, items: [createItem({ details: undefined })] })
|
||||
)
|
||||
|
||||
expect(html).not.toContain('Approved by governance team')
|
||||
})
|
||||
})
|
||||
117
web/src/features/governance/governance-inbox.test.ts
Normal file
117
web/src/features/governance/governance-inbox.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { GovernanceInboxItem } from '@/api/types'
|
||||
|
||||
const { navigateMock, buttonProps, formatLocalDateTimeMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
buttonProps: [] as Array<{ onClick?: () => void }>,
|
||||
formatLocalDateTimeMock: vi.fn(() => '2026-03-23 10:00'),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: formatLocalDateTimeMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: (props: { children?: ReactNode; onClick?: () => void }) => {
|
||||
buttonProps.push(props)
|
||||
return createElement('button', { onClick: props.onClick }, props.children)
|
||||
},
|
||||
}))
|
||||
|
||||
import { GovernanceInbox } from './governance-inbox'
|
||||
|
||||
function createItem(overrides: Partial<GovernanceInboxItem> = {}): GovernanceInboxItem {
|
||||
return {
|
||||
type: 'REVIEW',
|
||||
id: 42,
|
||||
title: 'Review pending skill',
|
||||
subtitle: 'Needs approval',
|
||||
timestamp: '2026-03-23T02:00:00Z',
|
||||
namespace: 'team-a',
|
||||
skillSlug: 'demo-skill',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('governance-inbox module', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset()
|
||||
buttonProps.length = 0
|
||||
formatLocalDateTimeMock.mockClear()
|
||||
})
|
||||
|
||||
it('renders the loading shimmer', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernanceInbox, { isLoading: true }))
|
||||
|
||||
expect(html).toContain('animate-shimmer')
|
||||
})
|
||||
|
||||
it('renders the empty state', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernanceInbox, { isLoading: false, items: [] }))
|
||||
|
||||
expect(html).toContain('governance.emptyInbox')
|
||||
expect(html).toContain('text-muted-foreground')
|
||||
})
|
||||
|
||||
it('renders inbox items with formatted timestamps and subtitles', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceInbox, { isLoading: false, items: [createItem()] })
|
||||
)
|
||||
|
||||
expect(html).toContain('Review pending skill')
|
||||
expect(html).toContain('Needs approval')
|
||||
expect(html).toContain('REVIEW')
|
||||
expect(html).toContain('2026-03-23 10:00')
|
||||
expect(formatLocalDateTimeMock).toHaveBeenCalledWith('2026-03-23T02:00:00Z', 'en')
|
||||
})
|
||||
|
||||
it('navigates to the review route when the open button is activated', () => {
|
||||
renderToStaticMarkup(
|
||||
createElement(GovernanceInbox, { isLoading: false, items: [createItem({ type: 'REVIEW' })] })
|
||||
)
|
||||
|
||||
expect(buttonProps).toHaveLength(1)
|
||||
buttonProps[0].onClick?.()
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({ to: '/dashboard/reviews/42' })
|
||||
})
|
||||
|
||||
it('routes promotion, report, and namespace items to the expected destinations', () => {
|
||||
renderToStaticMarkup(
|
||||
createElement(GovernanceInbox, {
|
||||
isLoading: false,
|
||||
items: [
|
||||
createItem({ type: 'PROMOTION', id: 7, title: 'Promotion ready' }),
|
||||
createItem({ type: 'REPORT', id: 8, title: 'Report ready' }),
|
||||
createItem({ type: 'OTHER', id: 9, title: 'Space item', namespace: 'team-b', skillSlug: 'skill-x' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
expect(buttonProps).toHaveLength(3)
|
||||
buttonProps[0].onClick?.()
|
||||
buttonProps[1].onClick?.()
|
||||
buttonProps[2].onClick?.()
|
||||
|
||||
expect(navigateMock).toHaveBeenNthCalledWith(1, { to: '/dashboard/promotions' })
|
||||
expect(navigateMock).toHaveBeenNthCalledWith(2, { to: '/dashboard/reports' })
|
||||
expect(navigateMock).toHaveBeenNthCalledWith(3, { to: '/space/team-b/skill-x' })
|
||||
})
|
||||
})
|
||||
131
web/src/features/governance/governance-notifications.test.ts
Normal file
131
web/src/features/governance/governance-notifications.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { GovernanceNotification } from '@/api/types'
|
||||
|
||||
const { buttonProps, formatLocalDateTimeMock } = vi.hoisted(() => ({
|
||||
buttonProps: [] as Array<{ disabled?: boolean; onClick?: () => void }>,
|
||||
formatLocalDateTimeMock: vi.fn(() => '2026-03-23 11:00'),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: formatLocalDateTimeMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: (props: { children?: ReactNode; disabled?: boolean; onClick?: () => void }) => {
|
||||
buttonProps.push(props)
|
||||
return createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.children)
|
||||
},
|
||||
}))
|
||||
|
||||
import { GovernanceNotifications } from './governance-notifications'
|
||||
|
||||
function createItem(overrides: Partial<GovernanceNotification> = {}): GovernanceNotification {
|
||||
return {
|
||||
category: 'SYSTEM',
|
||||
entityType: 'SKILL',
|
||||
entityId: 99,
|
||||
title: 'Governance notification',
|
||||
status: 'UNREAD',
|
||||
id: 12,
|
||||
createdAt: '2026-03-23T03:00:00Z',
|
||||
bodyJson: '{"message":"Needs attention"}',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('governance-notifications module', () => {
|
||||
beforeEach(() => {
|
||||
buttonProps.length = 0
|
||||
formatLocalDateTimeMock.mockClear()
|
||||
})
|
||||
|
||||
it('renders the loading shimmer', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceNotifications, {
|
||||
isLoading: true,
|
||||
items: undefined,
|
||||
onMarkRead: vi.fn(),
|
||||
isMarkingRead: false,
|
||||
})
|
||||
)
|
||||
|
||||
expect(html).toContain('animate-shimmer')
|
||||
})
|
||||
|
||||
it('renders the empty state', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceNotifications, {
|
||||
isLoading: false,
|
||||
items: [],
|
||||
onMarkRead: vi.fn(),
|
||||
isMarkingRead: false,
|
||||
})
|
||||
)
|
||||
|
||||
expect(html).toContain('governance.emptyNotifications')
|
||||
})
|
||||
|
||||
it('renders unread notifications with metadata and a mark-read action', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceNotifications, {
|
||||
isLoading: false,
|
||||
items: [createItem()],
|
||||
onMarkRead: vi.fn(),
|
||||
isMarkingRead: false,
|
||||
})
|
||||
)
|
||||
|
||||
expect(html).toContain('Governance notification')
|
||||
expect(html).toContain('UNREAD')
|
||||
expect(html).toContain('Needs attention')
|
||||
expect(html).toContain('2026-03-23 11:00')
|
||||
expect(formatLocalDateTimeMock).toHaveBeenCalledWith('2026-03-23T03:00:00Z', 'en')
|
||||
expect(buttonProps).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables the mark-read button while marking and invokes the callback with the notification id', () => {
|
||||
const onMarkRead = vi.fn()
|
||||
|
||||
renderToStaticMarkup(
|
||||
createElement(GovernanceNotifications, {
|
||||
isLoading: false,
|
||||
items: [createItem()],
|
||||
onMarkRead,
|
||||
isMarkingRead: true,
|
||||
})
|
||||
)
|
||||
|
||||
expect(buttonProps).toHaveLength(1)
|
||||
expect(buttonProps[0].disabled).toBe(true)
|
||||
buttonProps[0].onClick?.()
|
||||
|
||||
expect(onMarkRead).toHaveBeenCalledWith(12)
|
||||
})
|
||||
|
||||
it('omits the mark-read action for read notifications or when the id is missing', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(GovernanceNotifications, {
|
||||
isLoading: false,
|
||||
items: [createItem({ status: 'READ', id: undefined }), createItem({ status: 'READ', id: 13, entityId: 13 })],
|
||||
onMarkRead: vi.fn(),
|
||||
isMarkingRead: false,
|
||||
})
|
||||
)
|
||||
|
||||
expect(html).toContain('READ')
|
||||
expect(buttonProps).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
19
web/src/features/governance/use-governance.test.ts
Normal file
19
web/src/features/governance/use-governance.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { GOVERNANCE_PAGE_SIZE } from './use-governance'
|
||||
|
||||
describe('GOVERNANCE_PAGE_SIZE', () => {
|
||||
it('defaults to 10 items per page', () => {
|
||||
expect(GOVERNANCE_PAGE_SIZE).toBe(10)
|
||||
})
|
||||
|
||||
it('is a positive integer', () => {
|
||||
expect(Number.isInteger(GOVERNANCE_PAGE_SIZE)).toBe(true)
|
||||
expect(GOVERNANCE_PAGE_SIZE).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// The hook exports (useGovernanceSummary, useGovernanceInbox, useGovernanceActivity,
|
||||
// useGovernanceNotifications, useMarkGovernanceNotificationRead, useRebuildSearchIndex)
|
||||
// are thin useQuery / useMutation wrappers with no custom data transformation logic.
|
||||
// Testing them would only verify TanStack Query internals, so they are intentionally
|
||||
// skipped in favour of integration or E2E coverage.
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './add-namespace-member-dialog'
|
||||
|
||||
/**
|
||||
* add-namespace-member-dialog.tsx exports a single React component
|
||||
* (AddNamespaceMemberDialog). The ROLE_OPTIONS constant and validation
|
||||
* helpers are module-private, so we verify the export contract and
|
||||
* component function shape to catch accidental breakage.
|
||||
*/
|
||||
describe('add-namespace-member-dialog module exports', () => {
|
||||
it('exports the AddNamespaceMemberDialog component', () => {
|
||||
expect(mod.AddNamespaceMemberDialog).toBeDefined()
|
||||
expect(typeof mod.AddNamespaceMemberDialog).toBe('function')
|
||||
})
|
||||
})
|
||||
15
web/src/features/namespace/create-namespace-dialog.test.ts
Normal file
15
web/src/features/namespace/create-namespace-dialog.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './create-namespace-dialog'
|
||||
|
||||
/**
|
||||
* create-namespace-dialog.tsx exports the CreateNamespaceDialog component.
|
||||
* The validation helper (buildFieldErrors), slug constants (SLUG_PATTERN,
|
||||
* RESERVED_SLUGS, length limits), and FieldErrors type are all
|
||||
* module-private. We verify the public export contract here.
|
||||
*/
|
||||
describe('create-namespace-dialog module exports', () => {
|
||||
it('exports the CreateNamespaceDialog component', () => {
|
||||
expect(mod.CreateNamespaceDialog).toBeDefined()
|
||||
expect(typeof mod.CreateNamespaceDialog).toBe('function')
|
||||
})
|
||||
})
|
||||
102
web/src/features/namespace/namespace-header.test.ts
Normal file
102
web/src/features/namespace/namespace-header.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Namespace } from '@/api/types'
|
||||
import { NamespaceHeader } from './namespace-header'
|
||||
import * as mod from './namespace-header'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('namespace-header module exports', () => {
|
||||
it('exports the NamespaceHeader component', () => {
|
||||
expect(mod.NamespaceHeader).toBeDefined()
|
||||
expect(typeof mod.NamespaceHeader).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NamespaceHeader', () => {
|
||||
const baseNamespace: Namespace = {
|
||||
id: 1,
|
||||
slug: 'skillhub',
|
||||
displayName: 'SkillHub',
|
||||
type: 'GLOBAL',
|
||||
status: 'ACTIVE',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
description: 'Shared namespace for all skills',
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
}
|
||||
|
||||
const renderHeader = (namespace: Namespace) =>
|
||||
renderToStaticMarkup(createElement(NamespaceHeader, { namespace }))
|
||||
|
||||
it('renders the GLOBAL namespace header with avatar, description, slug and immutable hint', () => {
|
||||
const html = renderHeader(baseNamespace)
|
||||
|
||||
expect(html).toContain('SkillHub')
|
||||
expect(html).toContain('img')
|
||||
expect(html).toContain('src="https://example.com/avatar.png"')
|
||||
expect(html).toContain('alt="SkillHub"')
|
||||
expect(html).toContain('Shared namespace for all skills')
|
||||
expect(html).toContain('@skillhub')
|
||||
expect(html).toContain('myNamespaces.typeGlobal')
|
||||
expect(html).toContain('namespaceStatus.active')
|
||||
expect(html).toContain('namespaceStatus.immutableHint')
|
||||
expect(html).toContain('bg-emerald-500/10 text-emerald-500 border-emerald-500/20')
|
||||
expect(html).not.toContain('namespaceStatus.frozenHint')
|
||||
expect(html).not.toContain('namespaceStatus.archivedHint')
|
||||
})
|
||||
|
||||
it('renders the TEAM namespace header with frozen status and hint', () => {
|
||||
const html = renderHeader({
|
||||
...baseNamespace,
|
||||
type: 'TEAM',
|
||||
status: 'FROZEN',
|
||||
avatarUrl: undefined,
|
||||
description: undefined,
|
||||
slug: 'team-space',
|
||||
displayName: 'Team Space',
|
||||
})
|
||||
|
||||
expect(html).toContain('Team Space')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('Shared namespace for all skills')
|
||||
expect(html).toContain('@team-space')
|
||||
expect(html).toContain('myNamespaces.typeTeam')
|
||||
expect(html).toContain('namespaceStatus.frozen')
|
||||
expect(html).toContain('namespaceStatus.frozenHint')
|
||||
expect(html).toContain('bg-amber-500/10 text-amber-500 border-amber-500/20')
|
||||
expect(html).not.toContain('namespaceStatus.immutableHint')
|
||||
expect(html).not.toContain('namespaceStatus.archivedHint')
|
||||
})
|
||||
|
||||
it('renders the TEAM namespace header with archived status and hint', () => {
|
||||
const html = renderHeader({
|
||||
...baseNamespace,
|
||||
type: 'TEAM',
|
||||
status: 'ARCHIVED',
|
||||
slug: 'team-archive',
|
||||
displayName: 'Team Archive',
|
||||
avatarUrl: undefined,
|
||||
description: 'Archived workspace for old projects',
|
||||
})
|
||||
|
||||
expect(html).toContain('Team Archive')
|
||||
expect(html).toContain('Archived workspace for old projects')
|
||||
expect(html).toContain('@team-archive')
|
||||
expect(html).toContain('myNamespaces.typeTeam')
|
||||
expect(html).toContain('namespaceStatus.archived')
|
||||
expect(html).toContain('namespaceStatus.archivedHint')
|
||||
expect(html).toContain('bg-slate-500/10 text-slate-500 border-slate-500/20')
|
||||
expect(html).not.toContain('namespaceStatus.immutableHint')
|
||||
expect(html).not.toContain('namespaceStatus.frozenHint')
|
||||
})
|
||||
})
|
||||
17
web/src/features/namespace/use-my-namespaces.test.ts
Normal file
17
web/src/features/namespace/use-my-namespaces.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './use-my-namespaces'
|
||||
|
||||
/**
|
||||
* use-my-namespaces.ts is a feature-local re-export of the
|
||||
* useMyNamespaces hook from the shared query layer. There is no custom
|
||||
* logic, transformation, or query-key function to test.
|
||||
*
|
||||
* We verify the re-export contract so import paths used by namespace
|
||||
* dashboard screens break fast if the module shape changes.
|
||||
*/
|
||||
describe('use-my-namespaces re-export', () => {
|
||||
it('re-exports useMyNamespaces as a function', () => {
|
||||
expect(mod.useMyNamespaces).toBeDefined()
|
||||
expect(typeof mod.useMyNamespaces).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/namespace/use-namespace-detail.test.ts
Normal file
17
web/src/features/namespace/use-namespace-detail.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './use-namespace-detail'
|
||||
|
||||
/**
|
||||
* use-namespace-detail.ts is a feature-local re-export of the
|
||||
* useNamespaceDetail hook from the shared query layer. There is no
|
||||
* custom logic or transformation to test.
|
||||
*
|
||||
* We verify the re-export contract so import paths used by namespace
|
||||
* detail screens break fast if the module shape changes.
|
||||
*/
|
||||
describe('use-namespace-detail re-export', () => {
|
||||
it('re-exports useNamespaceDetail as a function', () => {
|
||||
expect(mod.useNamespaceDetail).toBeDefined()
|
||||
expect(typeof mod.useNamespaceDetail).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/namespace/use-namespace-members.test.ts
Normal file
17
web/src/features/namespace/use-namespace-members.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './use-namespace-members'
|
||||
|
||||
/**
|
||||
* use-namespace-members.ts is a feature-local re-export of the
|
||||
* useNamespaceMembers hook from the shared query layer. There is no
|
||||
* custom logic or transformation to test.
|
||||
*
|
||||
* We verify the re-export contract so import paths used by namespace
|
||||
* member screens break fast if the module shape changes.
|
||||
*/
|
||||
describe('use-namespace-members re-export', () => {
|
||||
it('re-exports useNamespaceMembers as a function', () => {
|
||||
expect(mod.useNamespaceMembers).toBeDefined()
|
||||
expect(typeof mod.useNamespaceMembers).toBe('function')
|
||||
})
|
||||
})
|
||||
35
web/src/features/promotion/use-promotion-list.test.ts
Normal file
35
web/src/features/promotion/use-promotion-list.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './use-promotion-list'
|
||||
|
||||
/**
|
||||
* use-promotion-list.ts exports four hooks (usePromotionList,
|
||||
* usePromotionDetail, useApprovePromotion, useRejectPromotion) and
|
||||
* re-exports the PromotionTask type. All hooks are thin wrappers around
|
||||
* useQuery/useMutation with no exported pure helpers, query-key functions,
|
||||
* or data transformations beyond unwrapping the backend page object
|
||||
* (which cannot be tested without an API client mock).
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('use-promotion-list module exports', () => {
|
||||
it('exports usePromotionList as a function', () => {
|
||||
expect(mod.usePromotionList).toBeDefined()
|
||||
expect(typeof mod.usePromotionList).toBe('function')
|
||||
})
|
||||
|
||||
it('exports usePromotionDetail as a function', () => {
|
||||
expect(mod.usePromotionDetail).toBeDefined()
|
||||
expect(typeof mod.usePromotionDetail).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useApprovePromotion as a function', () => {
|
||||
expect(mod.useApprovePromotion).toBeDefined()
|
||||
expect(typeof mod.useApprovePromotion).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useRejectPromotion as a function', () => {
|
||||
expect(mod.useRejectPromotion).toBeDefined()
|
||||
expect(typeof mod.useRejectPromotion).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/publish/upload-zone.test.ts
Normal file
17
web/src/features/publish/upload-zone.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './upload-zone'
|
||||
|
||||
/**
|
||||
* upload-zone.tsx exports the UploadZone component. It is a stateless
|
||||
* dropzone wrapper with no exported constants, validation logic, or
|
||||
* helper functions.
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('upload-zone module exports', () => {
|
||||
it('exports the UploadZone component', () => {
|
||||
expect(mod.UploadZone).toBeDefined()
|
||||
expect(typeof mod.UploadZone).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/publish/use-publish-skill.test.ts
Normal file
17
web/src/features/publish/use-publish-skill.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './use-publish-skill'
|
||||
|
||||
/**
|
||||
* use-publish-skill.ts is a feature-local re-export of the usePublishSkill
|
||||
* hook from the shared query layer. There is no custom logic or
|
||||
* transformation to test.
|
||||
*
|
||||
* We verify the re-export contract so import paths used by publish screens
|
||||
* break fast if the module shape changes.
|
||||
*/
|
||||
describe('use-publish-skill re-export', () => {
|
||||
it('re-exports usePublishSkill as a function', () => {
|
||||
expect(mod.usePublishSkill).toBeDefined()
|
||||
expect(typeof mod.usePublishSkill).toBe('function')
|
||||
})
|
||||
})
|
||||
63
web/src/features/report/use-skill-reports.test.ts
Normal file
63
web/src/features/report/use-skill-reports.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// The use-skill-reports module exports thin useQuery / useMutation wrappers
|
||||
// (useSkillReports, useSubmitSkillReport, useResolveSkillReport, useDismissSkillReport).
|
||||
// These are wrappers around reportApi methods with standard cache invalidation.
|
||||
//
|
||||
// The tests below verify exports and the underlying reportApi surface.
|
||||
|
||||
describe('use-skill-reports exports', () => {
|
||||
it('exports useSkillReports', async () => {
|
||||
const mod = await import('./use-skill-reports')
|
||||
expect(mod.useSkillReports).toBeDefined()
|
||||
expect(typeof mod.useSkillReports).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useSubmitSkillReport', async () => {
|
||||
const mod = await import('./use-skill-reports')
|
||||
expect(mod.useSubmitSkillReport).toBeDefined()
|
||||
expect(typeof mod.useSubmitSkillReport).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useResolveSkillReport', async () => {
|
||||
const mod = await import('./use-skill-reports')
|
||||
expect(mod.useResolveSkillReport).toBeDefined()
|
||||
expect(typeof mod.useResolveSkillReport).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useDismissSkillReport', async () => {
|
||||
const mod = await import('./use-skill-reports')
|
||||
expect(mod.useDismissSkillReport).toBeDefined()
|
||||
expect(typeof mod.useDismissSkillReport).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportApi methods', () => {
|
||||
it('reportApi exports listSkillReports', async () => {
|
||||
const { reportApi } = await import('@/api/client')
|
||||
expect(reportApi.listSkillReports).toBeDefined()
|
||||
expect(typeof reportApi.listSkillReports).toBe('function')
|
||||
})
|
||||
|
||||
it('reportApi exports submitSkillReport', async () => {
|
||||
const { reportApi } = await import('@/api/client')
|
||||
expect(reportApi.submitSkillReport).toBeDefined()
|
||||
expect(typeof reportApi.submitSkillReport).toBe('function')
|
||||
})
|
||||
|
||||
it('reportApi exports resolveSkillReport', async () => {
|
||||
const { reportApi } = await import('@/api/client')
|
||||
expect(reportApi.resolveSkillReport).toBeDefined()
|
||||
expect(typeof reportApi.resolveSkillReport).toBe('function')
|
||||
})
|
||||
|
||||
it('reportApi exports dismissSkillReport', async () => {
|
||||
const { reportApi } = await import('@/api/client')
|
||||
expect(reportApi.dismissSkillReport).toBeDefined()
|
||||
expect(typeof reportApi.dismissSkillReport).toBe('function')
|
||||
})
|
||||
})
|
||||
35
web/src/features/review/use-review-detail.test.ts
Normal file
35
web/src/features/review/use-review-detail.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// The use-review-detail module exports thin useQuery / useMutation wrappers
|
||||
// (useReviewDetail, useReviewSkillDetail, useApproveReview, useRejectReview).
|
||||
// Internal helper functions (getReviewDetail, getReviewSkillDetail, approveReview,
|
||||
// rejectReview) are not exported and cannot be tested directly.
|
||||
//
|
||||
// Verifying that each public hook is exported and is a callable function serves as
|
||||
// a smoke check that the module and its dependency graph resolve correctly.
|
||||
|
||||
describe('use-review-detail exports', () => {
|
||||
it('exports useReviewDetail', async () => {
|
||||
const mod = await import('./use-review-detail')
|
||||
expect(mod.useReviewDetail).toBeDefined()
|
||||
expect(typeof mod.useReviewDetail).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useReviewSkillDetail', async () => {
|
||||
const mod = await import('./use-review-detail')
|
||||
expect(mod.useReviewSkillDetail).toBeDefined()
|
||||
expect(typeof mod.useReviewSkillDetail).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useApproveReview', async () => {
|
||||
const mod = await import('./use-review-detail')
|
||||
expect(mod.useApproveReview).toBeDefined()
|
||||
expect(typeof mod.useApproveReview).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useRejectReview', async () => {
|
||||
const mod = await import('./use-review-detail')
|
||||
expect(mod.useRejectReview).toBeDefined()
|
||||
expect(typeof mod.useRejectReview).toBe('function')
|
||||
})
|
||||
})
|
||||
15
web/src/features/review/use-review-file.test.ts
Normal file
15
web/src/features/review/use-review-file.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// useReviewFile is a thin useQuery wrapper that fetches a single file's content
|
||||
// from a review-bound skill version. It exports no pure helpers, constants, or
|
||||
// data transformation logic beyond what TanStack Query provides.
|
||||
//
|
||||
// The smoke check below verifies that the module resolves correctly.
|
||||
|
||||
describe('use-review-file exports', () => {
|
||||
it('exports useReviewFile', async () => {
|
||||
const mod = await import('./use-review-file')
|
||||
expect(mod.useReviewFile).toBeDefined()
|
||||
expect(typeof mod.useReviewFile).toBe('function')
|
||||
})
|
||||
})
|
||||
51
web/src/features/review/use-review-list.test.ts
Normal file
51
web/src/features/review/use-review-list.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// useReviewList is a useQuery wrapper. The internal getReviewList function computes
|
||||
// totalElements and totalPages from the API response. While getReviewList itself is
|
||||
// not exported, its behaviour can be validated indirectly through the reviewApi
|
||||
// integration surface, similar to the pattern used in profile-review.test.ts.
|
||||
|
||||
describe('use-review-list exports', () => {
|
||||
it('exports useReviewList', async () => {
|
||||
const mod = await import('./use-review-list')
|
||||
expect(mod.useReviewList).toBeDefined()
|
||||
expect(typeof mod.useReviewList).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reviewApi.list response mapping', () => {
|
||||
it('reviewApi exports the list method', async () => {
|
||||
const { reviewApi } = await import('@/api/client')
|
||||
expect(reviewApi.list).toBeDefined()
|
||||
expect(typeof reviewApi.list).toBe('function')
|
||||
})
|
||||
|
||||
it('returns paginated skill review list from the API', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'response.success',
|
||||
data: {
|
||||
items: [],
|
||||
total: 15,
|
||||
page: 0,
|
||||
size: 20,
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
))
|
||||
vi.stubGlobal('document', { cookie: '' })
|
||||
|
||||
const { reviewApi } = await import('@/api/client')
|
||||
const response = await reviewApi.list({ status: 'PENDING', page: 0, size: 20 })
|
||||
|
||||
expect(response.total).toBe(15)
|
||||
expect(response.items).toEqual([])
|
||||
})
|
||||
})
|
||||
18
web/src/features/search/search-bar.test.ts
Normal file
18
web/src/features/search/search-bar.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './search-bar'
|
||||
|
||||
/**
|
||||
* search-bar.tsx exports the SearchBar component. The component delegates
|
||||
* its max-length constraint to the shared MAX_SEARCH_QUERY_LENGTH constant
|
||||
* (tested in search-query.test.ts). Controlled/uncontrolled mode logic and
|
||||
* submit/clear handlers are component-internal with no exported helpers.
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('search-bar module exports', () => {
|
||||
it('exports the SearchBar component', () => {
|
||||
expect(mod.SearchBar).toBeDefined()
|
||||
expect(typeof mod.SearchBar).toBe('function')
|
||||
})
|
||||
})
|
||||
122
web/src/features/security-audit/finding-item.test.tsx
Normal file
122
web/src/features/security-audit/finding-item.test.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SecurityFinding } from './types'
|
||||
import { FindingItem } from './finding-item'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function createFinding(overrides: Partial<SecurityFinding> = {}): SecurityFinding {
|
||||
return {
|
||||
ruleId: 'SEC-001',
|
||||
severity: 'HIGH',
|
||||
category: 'injection',
|
||||
title: 'SQL Injection detected',
|
||||
message: null,
|
||||
filePath: null,
|
||||
lineNumber: null,
|
||||
codeSnippet: null,
|
||||
remediation: null,
|
||||
analyzer: null,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('FindingItem', () => {
|
||||
it('renders the rule ID and title', () => {
|
||||
const html = renderToStaticMarkup(<FindingItem finding={createFinding()} />)
|
||||
|
||||
expect(html).toContain('SEC-001')
|
||||
expect(html).toContain('SQL Injection detected')
|
||||
})
|
||||
|
||||
it('renders the severity badge', () => {
|
||||
const html = renderToStaticMarkup(<FindingItem finding={createFinding()} />)
|
||||
|
||||
expect(html).toContain('securityAudit.severity.HIGH')
|
||||
})
|
||||
|
||||
it('renders the location when both filePath and lineNumber are present', () => {
|
||||
const finding = createFinding({ filePath: 'src/main.py', lineNumber: 42 })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
expect(html).toContain('src/main.py:42')
|
||||
})
|
||||
|
||||
it('renders only the filePath when lineNumber is null', () => {
|
||||
const finding = createFinding({ filePath: 'src/main.py', lineNumber: null })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
expect(html).toContain('src/main.py')
|
||||
expect(html).not.toContain('src/main.py:')
|
||||
})
|
||||
|
||||
it('omits the location span when both filePath and lineNumber are null', () => {
|
||||
const finding = createFinding({ filePath: null, lineNumber: null })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
// The location span should not be rendered at all.
|
||||
// Only the severity badge and ruleId code element should appear in the header row.
|
||||
const locationMatches = html.match(/text-muted-foreground/g) ?? []
|
||||
// Without location, there should be fewer muted-foreground elements
|
||||
expect(locationMatches.length).toBeLessThan(
|
||||
(renderToStaticMarkup(
|
||||
<FindingItem finding={createFinding({ filePath: 'a.py', lineNumber: 1 })} />
|
||||
).match(/text-muted-foreground/g) ?? []).length
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the message when present', () => {
|
||||
const finding = createFinding({ message: 'Use parameterized queries' })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
expect(html).toContain('Use parameterized queries')
|
||||
})
|
||||
|
||||
it('omits the message paragraph when message is null', () => {
|
||||
const finding = createFinding({ message: null })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
// Should still render the title but not an extra paragraph
|
||||
expect(html).toContain('SQL Injection detected')
|
||||
})
|
||||
|
||||
it('renders the code snippet in a pre element when present', () => {
|
||||
const finding = createFinding({ codeSnippet: 'SELECT * FROM users WHERE id = ${input}' })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
expect(html).toContain('<pre')
|
||||
expect(html).toContain('SELECT * FROM users WHERE id = ${input}')
|
||||
})
|
||||
|
||||
it('omits the code snippet when codeSnippet is null', () => {
|
||||
const finding = createFinding({ codeSnippet: null })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
expect(html).not.toContain('<pre')
|
||||
})
|
||||
|
||||
it('renders the remediation section when present', () => {
|
||||
const finding = createFinding({ remediation: 'Use prepared statements' })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
expect(html).toContain('securityAudit.remediation')
|
||||
expect(html).toContain('Use prepared statements')
|
||||
})
|
||||
|
||||
it('omits the remediation section when remediation is null', () => {
|
||||
const finding = createFinding({ remediation: null })
|
||||
const html = renderToStaticMarkup(<FindingItem finding={finding} />)
|
||||
|
||||
expect(html).not.toContain('securityAudit.remediation')
|
||||
})
|
||||
})
|
||||
244
web/src/features/security-audit/security-audit-section.test.tsx
Normal file
244
web/src/features/security-audit/security-audit-section.test.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SecurityAuditRecord } from './types'
|
||||
import { SecurityAuditSection } from './security-audit-section'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) =>
|
||||
values?.count !== undefined ? `${key}:${values.count}` : key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function createAudit(overrides: Partial<SecurityAuditRecord> = {}): SecurityAuditRecord {
|
||||
return {
|
||||
id: 1,
|
||||
scanId: 'scan-abc',
|
||||
scannerType: 'semgrep',
|
||||
verdict: 'SAFE',
|
||||
isSafe: true,
|
||||
maxSeverity: null,
|
||||
findingsCount: 0,
|
||||
findings: [],
|
||||
scanDurationSeconds: null,
|
||||
scannedAt: '2026-03-20T10:00:00Z',
|
||||
createdAt: '2026-03-20T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let mockAudits: SecurityAuditRecord[] | undefined = undefined
|
||||
let mockIsLoading = false
|
||||
|
||||
vi.mock('./use-security-audit', () => ({
|
||||
useSecurityAudits: () => ({ data: mockAudits, isLoading: mockIsLoading }),
|
||||
}))
|
||||
|
||||
describe('SecurityAuditSection', () => {
|
||||
it('returns null when loading', () => {
|
||||
mockAudits = undefined
|
||||
mockIsLoading = true
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('returns null when audits is undefined', () => {
|
||||
mockAudits = undefined
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('returns null when audits is an empty array', () => {
|
||||
mockAudits = []
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('renders the section title when audits are present', () => {
|
||||
mockAudits = [createAudit()]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.title')
|
||||
})
|
||||
|
||||
it('renders the scanner type for each audit', () => {
|
||||
mockAudits = [
|
||||
createAudit({ id: 1, scannerType: 'semgrep' }),
|
||||
createAudit({ id: 2, scannerType: 'trivy' }),
|
||||
]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('semgrep')
|
||||
expect(html).toContain('trivy')
|
||||
})
|
||||
|
||||
it('renders the verdict badge for each audit', () => {
|
||||
mockAudits = [createAudit({ verdict: 'SUSPICIOUS' })]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.verdict.SUSPICIOUS')
|
||||
})
|
||||
|
||||
it('renders the findings count', () => {
|
||||
mockAudits = [createAudit({ findingsCount: 3 })]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.findingsCount:3')
|
||||
})
|
||||
|
||||
it('renders the scan duration when available', () => {
|
||||
mockAudits = [createAudit({ scanDurationSeconds: 12 })]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.scanDuration')
|
||||
})
|
||||
|
||||
it('omits the scan duration when null', () => {
|
||||
mockAudits = [createAudit({ scanDurationSeconds: null })]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).not.toContain('securityAudit.scanDuration')
|
||||
})
|
||||
|
||||
it('wraps content in a Card when bare is not set', () => {
|
||||
mockAudits = [createAudit()]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
// The Card renders with p-8 class
|
||||
expect(html).toContain('p-8')
|
||||
})
|
||||
|
||||
it('renders a plain div wrapper when bare is true', () => {
|
||||
mockAudits = [createAudit()]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} bare />)
|
||||
|
||||
// bare mode should not include p-8 (from Card)
|
||||
expect(html).not.toContain('p-8')
|
||||
expect(html).toContain('securityAudit.title')
|
||||
})
|
||||
|
||||
it('renders a findings toggle button when findings exist', () => {
|
||||
mockAudits = [
|
||||
createAudit({
|
||||
findingsCount: 1,
|
||||
findings: [
|
||||
{
|
||||
ruleId: 'SEC-001',
|
||||
severity: 'HIGH',
|
||||
category: 'injection',
|
||||
title: 'Test finding',
|
||||
message: null,
|
||||
filePath: null,
|
||||
lineNumber: null,
|
||||
codeSnippet: null,
|
||||
remediation: null,
|
||||
analyzer: null,
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.findings')
|
||||
})
|
||||
|
||||
it('does not render a findings toggle button when findings array is empty', () => {
|
||||
mockAudits = [createAudit({ findings: [], findingsCount: 0 })]
|
||||
mockIsLoading = false
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
// The "Findings" toggle button label should not appear
|
||||
// (only the findingsCount text appears)
|
||||
expect(html).toContain('securityAudit.findingsCount')
|
||||
})
|
||||
|
||||
it('sorts findings by severity when rendering (CRITICAL before INFO)', () => {
|
||||
mockAudits = [
|
||||
createAudit({
|
||||
findingsCount: 3,
|
||||
findings: [
|
||||
{
|
||||
ruleId: 'LOW-1',
|
||||
severity: 'LOW',
|
||||
category: 'misc',
|
||||
title: 'Low finding',
|
||||
message: null,
|
||||
filePath: null,
|
||||
lineNumber: null,
|
||||
codeSnippet: null,
|
||||
remediation: null,
|
||||
analyzer: null,
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
ruleId: 'CRIT-1',
|
||||
severity: 'CRITICAL',
|
||||
category: 'injection',
|
||||
title: 'Critical finding',
|
||||
message: null,
|
||||
filePath: null,
|
||||
lineNumber: null,
|
||||
codeSnippet: null,
|
||||
remediation: null,
|
||||
analyzer: null,
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
ruleId: 'INFO-1',
|
||||
severity: 'INFO',
|
||||
category: 'info',
|
||||
title: 'Info finding',
|
||||
message: null,
|
||||
filePath: null,
|
||||
lineNumber: null,
|
||||
codeSnippet: null,
|
||||
remediation: null,
|
||||
analyzer: null,
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
mockIsLoading = false
|
||||
|
||||
// The component renders findings in sorted order, but they are hidden
|
||||
// behind a toggle (expanded state defaults to false in static render).
|
||||
// We verify the toggle button is present which means sortFindings ran.
|
||||
const html = renderToStaticMarkup(<SecurityAuditSection skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.findings')
|
||||
})
|
||||
})
|
||||
137
web/src/features/security-audit/security-audit-summary.test.tsx
Normal file
137
web/src/features/security-audit/security-audit-summary.test.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SecurityAuditRecord } from './types'
|
||||
import { SecurityAuditSummary } from './security-audit-summary'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) =>
|
||||
values?.count !== undefined ? `${key}:${values.count}` : key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function createAudit(overrides: Partial<SecurityAuditRecord> = {}): SecurityAuditRecord {
|
||||
return {
|
||||
id: 1,
|
||||
scanId: 'scan-abc',
|
||||
scannerType: 'semgrep',
|
||||
verdict: 'SAFE',
|
||||
isSafe: true,
|
||||
maxSeverity: null,
|
||||
findingsCount: 0,
|
||||
findings: [],
|
||||
scanDurationSeconds: null,
|
||||
scannedAt: '2026-03-20T10:00:00Z',
|
||||
createdAt: '2026-03-20T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let mockAudits: SecurityAuditRecord[] | undefined = undefined
|
||||
|
||||
vi.mock('./use-security-audit', () => ({
|
||||
useSecurityAudits: () => ({ data: mockAudits }),
|
||||
}))
|
||||
|
||||
// Mock the Dialog components to avoid Radix UI portal / context issues in static render
|
||||
vi.mock('@/shared/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
|
||||
}))
|
||||
|
||||
// Mock the SecurityAuditSection to avoid nested hook dependencies
|
||||
vi.mock('./security-audit-section', () => ({
|
||||
SecurityAuditSection: () => <div data-testid="audit-section" />,
|
||||
}))
|
||||
|
||||
describe('SecurityAuditSummary', () => {
|
||||
it('returns null when audits is undefined', () => {
|
||||
mockAudits = undefined
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('returns null when audits is an empty array', () => {
|
||||
mockAudits = []
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('renders the security audit title when audits exist', () => {
|
||||
mockAudits = [createAudit()]
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.title')
|
||||
})
|
||||
|
||||
it('renders the scanner type for each audit', () => {
|
||||
mockAudits = [
|
||||
createAudit({ id: 1, scannerType: 'semgrep' }),
|
||||
createAudit({ id: 2, scannerType: 'trivy' }),
|
||||
]
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('semgrep')
|
||||
expect(html).toContain('trivy')
|
||||
})
|
||||
|
||||
it('renders the verdict badge for each audit', () => {
|
||||
mockAudits = [createAudit({ verdict: 'BLOCKED' })]
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.verdict.BLOCKED')
|
||||
})
|
||||
|
||||
it('renders the total findings count across all audits', () => {
|
||||
mockAudits = [
|
||||
createAudit({ id: 1, findingsCount: 3 }),
|
||||
createAudit({ id: 2, findingsCount: 5 }),
|
||||
]
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.totalFindings:8')
|
||||
})
|
||||
|
||||
it('renders zero total findings when all audits have zero findings', () => {
|
||||
mockAudits = [
|
||||
createAudit({ id: 1, findingsCount: 0 }),
|
||||
createAudit({ id: 2, findingsCount: 0 }),
|
||||
]
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.totalFindings:0')
|
||||
})
|
||||
|
||||
it('renders the view details button', () => {
|
||||
mockAudits = [createAudit()]
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.viewDetails')
|
||||
})
|
||||
|
||||
it('renders the dialog with title and description', () => {
|
||||
mockAudits = [createAudit()]
|
||||
|
||||
const html = renderToStaticMarkup(<SecurityAuditSummary skillId={1} versionId={10} />)
|
||||
|
||||
expect(html).toContain('securityAudit.dialogDescription')
|
||||
})
|
||||
})
|
||||
63
web/src/features/security-audit/severity-badge.test.tsx
Normal file
63
web/src/features/security-audit/severity-badge.test.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SeverityBadge } from './severity-badge'
|
||||
import type { FindingSeverity } from './types'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('SeverityBadge', () => {
|
||||
const severities: FindingSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
|
||||
|
||||
it.each(severities)('renders the translated label for %s severity', (severity) => {
|
||||
const html = renderToStaticMarkup(<SeverityBadge severity={severity} />)
|
||||
|
||||
expect(html).toContain(`securityAudit.severity.${severity}`)
|
||||
})
|
||||
|
||||
it('applies the red color classes for CRITICAL severity', () => {
|
||||
const html = renderToStaticMarkup(<SeverityBadge severity="CRITICAL" />)
|
||||
|
||||
expect(html).toContain('text-red-700')
|
||||
})
|
||||
|
||||
it('applies the orange color classes for HIGH severity', () => {
|
||||
const html = renderToStaticMarkup(<SeverityBadge severity="HIGH" />)
|
||||
|
||||
expect(html).toContain('text-orange-700')
|
||||
})
|
||||
|
||||
it('applies the amber color classes for MEDIUM severity', () => {
|
||||
const html = renderToStaticMarkup(<SeverityBadge severity="MEDIUM" />)
|
||||
|
||||
expect(html).toContain('text-amber-700')
|
||||
})
|
||||
|
||||
it('applies the blue color classes for LOW severity', () => {
|
||||
const html = renderToStaticMarkup(<SeverityBadge severity="LOW" />)
|
||||
|
||||
expect(html).toContain('text-blue-700')
|
||||
})
|
||||
|
||||
it('applies the gray color classes for INFO severity', () => {
|
||||
const html = renderToStaticMarkup(<SeverityBadge severity="INFO" />)
|
||||
|
||||
expect(html).toContain('text-gray-700')
|
||||
})
|
||||
|
||||
it('renders as a span with rounded-full pill styling', () => {
|
||||
const html = renderToStaticMarkup(<SeverityBadge severity="LOW" />)
|
||||
|
||||
expect(html).toContain('rounded-full')
|
||||
expect(html).toContain('text-xs')
|
||||
expect(html).toContain('font-medium')
|
||||
})
|
||||
})
|
||||
24
web/src/features/security-audit/types.test.ts
Normal file
24
web/src/features/security-audit/types.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* types.ts only exports TypeScript type aliases and interfaces:
|
||||
*
|
||||
* - SecurityVerdict (type alias)
|
||||
* - FindingSeverity (type alias)
|
||||
* - SecurityFinding (interface)
|
||||
* - SecurityAuditRecord (interface)
|
||||
*
|
||||
* These are erased at compile time and produce no runtime code.
|
||||
* There are no runtime-exported constants, functions, or classes to test.
|
||||
*
|
||||
* This file exists to document the deliberate decision to skip runtime
|
||||
* tests for types.ts. The type correctness is validated by TypeScript
|
||||
* compilation and by the tests of modules that consume these types.
|
||||
*/
|
||||
|
||||
describe('types.ts', () => {
|
||||
it('exports only TypeScript types with no runtime code to test', () => {
|
||||
// Intentionally empty — the types are compile-time only.
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
81
web/src/features/security-audit/use-security-audit.test.ts
Normal file
81
web/src/features/security-audit/use-security-audit.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* use-security-audit.ts exports a single hook `useSecurityAudits` that wraps
|
||||
* `useQuery` from TanStack Query. The module-private `fetchSecurityAudits`
|
||||
* function handles the API call and 404-to-empty-array fallback.
|
||||
*
|
||||
* Since the hook tightly couples to `useQuery` and `fetchJson`, we test
|
||||
* the observable configuration: the query key structure and the `enabled`
|
||||
* guard logic.
|
||||
*/
|
||||
|
||||
// Capture the options passed to useQuery so we can assert on them.
|
||||
let capturedOptions: Record<string, unknown> | undefined
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: (options: Record<string, unknown>) => {
|
||||
capturedOptions = options
|
||||
return { data: undefined, isLoading: false }
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock fetchJson to avoid actual network calls. The hook's queryFn
|
||||
// calls the private fetchSecurityAudits which uses fetchJson internally.
|
||||
vi.mock('@/api/client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
},
|
||||
fetchJson: vi.fn(),
|
||||
}))
|
||||
|
||||
// Dynamic import to ensure mocks are established first.
|
||||
const { useSecurityAudits } = await import('./use-security-audit')
|
||||
|
||||
describe('useSecurityAudits', () => {
|
||||
it('uses the correct query key structure', () => {
|
||||
useSecurityAudits(42, 100)
|
||||
|
||||
expect(capturedOptions?.queryKey).toEqual(['security-audits', 42, 100])
|
||||
})
|
||||
|
||||
it('is enabled when both skillId and versionId are provided', () => {
|
||||
useSecurityAudits(1, 2)
|
||||
|
||||
expect(capturedOptions?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when skillId is undefined', () => {
|
||||
useSecurityAudits(undefined, 2)
|
||||
|
||||
expect(capturedOptions?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('is disabled when versionId is undefined', () => {
|
||||
useSecurityAudits(1, undefined)
|
||||
|
||||
expect(capturedOptions?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('is disabled when both skillId and versionId are undefined', () => {
|
||||
useSecurityAudits(undefined, undefined)
|
||||
|
||||
expect(capturedOptions?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('configures a 30-second stale time', () => {
|
||||
useSecurityAudits(1, 2)
|
||||
|
||||
expect(capturedOptions?.staleTime).toBe(30_000)
|
||||
})
|
||||
|
||||
it('disables retry to avoid retrying on expected 404 responses', () => {
|
||||
useSecurityAudits(1, 2)
|
||||
|
||||
expect(capturedOptions?.retry).toBe(false)
|
||||
})
|
||||
})
|
||||
57
web/src/features/security-audit/verdict-badge.test.tsx
Normal file
57
web/src/features/security-audit/verdict-badge.test.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { VerdictBadge } from './verdict-badge'
|
||||
import type { SecurityVerdict } from './types'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('VerdictBadge', () => {
|
||||
const verdicts: SecurityVerdict[] = ['SAFE', 'SUSPICIOUS', 'DANGEROUS', 'BLOCKED']
|
||||
|
||||
it.each(verdicts)('renders the translated label for %s verdict', (verdict) => {
|
||||
const html = renderToStaticMarkup(<VerdictBadge verdict={verdict} />)
|
||||
|
||||
expect(html).toContain(`securityAudit.verdict.${verdict}`)
|
||||
})
|
||||
|
||||
it('applies emerald color classes for SAFE verdict', () => {
|
||||
const html = renderToStaticMarkup(<VerdictBadge verdict="SAFE" />)
|
||||
|
||||
expect(html).toContain('text-emerald-700')
|
||||
})
|
||||
|
||||
it('applies amber color classes for SUSPICIOUS verdict', () => {
|
||||
const html = renderToStaticMarkup(<VerdictBadge verdict="SUSPICIOUS" />)
|
||||
|
||||
expect(html).toContain('text-amber-700')
|
||||
})
|
||||
|
||||
it('applies orange color classes for DANGEROUS verdict', () => {
|
||||
const html = renderToStaticMarkup(<VerdictBadge verdict="DANGEROUS" />)
|
||||
|
||||
expect(html).toContain('text-orange-700')
|
||||
})
|
||||
|
||||
it('applies red color classes for BLOCKED verdict', () => {
|
||||
const html = renderToStaticMarkup(<VerdictBadge verdict="BLOCKED" />)
|
||||
|
||||
expect(html).toContain('text-red-700')
|
||||
})
|
||||
|
||||
it('renders as a span with rounded-full pill styling', () => {
|
||||
const html = renderToStaticMarkup(<VerdictBadge verdict="SAFE" />)
|
||||
|
||||
expect(html).toContain('rounded-full')
|
||||
expect(html).toContain('text-sm')
|
||||
expect(html).toContain('font-medium')
|
||||
})
|
||||
})
|
||||
17
web/src/features/skill/code-renderer.test.ts
Normal file
17
web/src/features/skill/code-renderer.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './code-renderer'
|
||||
|
||||
/**
|
||||
* code-renderer.tsx exports a single React component (CodeRenderer).
|
||||
* The file contains two useful pure helpers (escapeHtml and treeToHtml),
|
||||
* but they are module-private and cannot be imported directly.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('code-renderer module exports', () => {
|
||||
it('exports the CodeRenderer component', () => {
|
||||
expect(mod.CodeRenderer).toBeDefined()
|
||||
expect(typeof mod.CodeRenderer).toBe('function')
|
||||
})
|
||||
})
|
||||
161
web/src/features/skill/file-preview-dialog.test.ts
Normal file
161
web/src/features/skill/file-preview-dialog.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement, Fragment } from 'react'
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FilePreviewDialog } from './file-preview-dialog'
|
||||
import type { FileTreeNode } from './file-tree-builder'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => createElement(Fragment, null, children),
|
||||
DialogContent: ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => createElement('div', props, children),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/markdown-renderer', () => ({
|
||||
MarkdownRenderer: ({ content }: { content: string }) => createElement('div', { 'data-testid': 'markdown-renderer' }, content),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/code-renderer', () => ({
|
||||
CodeRenderer: ({ code, language }: { code: string, language: string | null }) =>
|
||||
createElement('div', { 'data-testid': 'code-renderer' }, `${language ?? 'plain'}:${code}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function createNode(overrides: Partial<FileTreeNode> = {}): FileTreeNode {
|
||||
return {
|
||||
id: 'root/demo.md',
|
||||
name: 'demo.md',
|
||||
path: 'root/demo.md',
|
||||
type: 'file',
|
||||
depth: 1,
|
||||
file: { fileSize: 128 } as FileTreeNode['file'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderDialog(overrides: Partial<ComponentProps<typeof FilePreviewDialog>> = {}) {
|
||||
const props: ComponentProps<typeof FilePreviewDialog> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
node: createNode(),
|
||||
content: '# Demo\n\ncontent',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
onDownload: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(createElement(FilePreviewDialog, props))
|
||||
}
|
||||
|
||||
describe('FilePreviewDialog', () => {
|
||||
it('renders nothing when the node is missing', () => {
|
||||
const html = renderDialog({ node: null })
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('renders the loading state and hides the copy button when there is no content', () => {
|
||||
const html = renderDialog({
|
||||
content: null,
|
||||
isLoading: true,
|
||||
node: createNode({ name: 'loading.txt', path: 'files/loading.txt', file: { fileSize: 12 } as FileTreeNode['file'] }),
|
||||
})
|
||||
|
||||
expect(html).toContain('loading.txt')
|
||||
expect(html).toContain('filePreview.downloadHint')
|
||||
expect(html).toContain('filePreview.close')
|
||||
expect(html).not.toContain('filePreview.copy')
|
||||
expect(html).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('renders the error state with the server message', () => {
|
||||
const html = renderDialog({
|
||||
content: null,
|
||||
error: new Error('boom'),
|
||||
node: createNode({ name: 'error.txt', path: 'files/error.txt', file: { fileSize: 12 } as FileTreeNode['file'] }),
|
||||
})
|
||||
|
||||
expect(html).toContain('filePreview.loadError')
|
||||
expect(html).toContain('boom')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['too-large', 'oversize.md', 1024 * 1024 + 1, 'filePreview.tooLarge'],
|
||||
['binary', 'image.png', 128, 'filePreview.binaryFile'],
|
||||
['unsupported', 'archive.foo', 128, 'filePreview.unsupported'],
|
||||
] as const)('renders the %s fallback message for non-previewable files', (_, name, fileSize, messageKey) => {
|
||||
const html = renderDialog({
|
||||
content: null,
|
||||
node: createNode({
|
||||
name,
|
||||
path: `files/${name}`,
|
||||
file: { fileSize } as FileTreeNode['file'],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(html).toContain(messageKey)
|
||||
expect(html).toContain('filePreview.downloadHint')
|
||||
})
|
||||
|
||||
it('renders markdown content and shows the copy button when content is available', () => {
|
||||
const html = renderDialog({
|
||||
node: createNode({
|
||||
name: 'README.md',
|
||||
path: 'docs/README.md',
|
||||
file: { fileSize: 256 } as FileTreeNode['file'],
|
||||
}),
|
||||
content: '# Heading\n\nMarkdown body',
|
||||
})
|
||||
|
||||
expect(html).toContain('data-testid="markdown-renderer"')
|
||||
expect(html).toContain('# Heading')
|
||||
expect(html).toContain('Markdown body')
|
||||
expect(html).toContain('filePreview.copy')
|
||||
expect(html).toContain('filePreview.downloadHint')
|
||||
})
|
||||
|
||||
it('renders syntax-highlighted code for supported source files', () => {
|
||||
const html = renderDialog({
|
||||
node: createNode({
|
||||
name: 'script.ts',
|
||||
path: 'src/script.ts',
|
||||
file: { fileSize: 128 } as FileTreeNode['file'],
|
||||
}),
|
||||
content: 'const answer = 42',
|
||||
})
|
||||
|
||||
expect(html).toContain('data-testid="code-renderer"')
|
||||
expect(html).toContain('typescript:const answer = 42')
|
||||
})
|
||||
|
||||
it('renders plain text when the file has no highlight language', () => {
|
||||
const html = renderDialog({
|
||||
node: createNode({
|
||||
name: 'notes',
|
||||
path: 'docs/notes',
|
||||
file: { fileSize: 128 } as FileTreeNode['file'],
|
||||
}),
|
||||
content: 'plain text content',
|
||||
})
|
||||
|
||||
expect(html).not.toContain('data-testid="code-renderer"')
|
||||
expect(html).not.toContain('data-testid="markdown-renderer"')
|
||||
expect(html).toContain('<pre class="text-sm font-mono whitespace-pre-wrap break-words"><code>plain text content</code></pre>')
|
||||
})
|
||||
})
|
||||
17
web/src/features/skill/file-tree-node.test.ts
Normal file
17
web/src/features/skill/file-tree-node.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './file-tree-node'
|
||||
|
||||
/**
|
||||
* file-tree-node.tsx exports the FileTreeNodeComponent React component.
|
||||
* It contains two module-private helpers (formatFileSize and getIconComponent)
|
||||
* that are pure functions but cannot be imported for direct testing.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('file-tree-node module exports', () => {
|
||||
it('exports the FileTreeNodeComponent component', () => {
|
||||
expect(mod.FileTreeNodeComponent).toBeDefined()
|
||||
expect(typeof mod.FileTreeNodeComponent).toBe('function')
|
||||
})
|
||||
})
|
||||
18
web/src/features/skill/file-tree.test.ts
Normal file
18
web/src/features/skill/file-tree.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './file-tree'
|
||||
|
||||
/**
|
||||
* file-tree.tsx exports a single React component (FileTree).
|
||||
* The component delegates tree construction to buildFileTree (tested separately)
|
||||
* and rendering to FileTreeNodeComponent. There are no exported pure helpers
|
||||
* or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('file-tree module exports', () => {
|
||||
it('exports the FileTree component', () => {
|
||||
expect(mod.FileTree).toBeDefined()
|
||||
expect(typeof mod.FileTree).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/skill/skill-card.test.ts
Normal file
17
web/src/features/skill/skill-card.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './skill-card'
|
||||
|
||||
/**
|
||||
* skill-card.tsx exports a single React component (SkillCard).
|
||||
* All visual logic is in JSX and depends on hooks (useAuth, useStar).
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('skill-card module exports', () => {
|
||||
it('exports the SkillCard component', () => {
|
||||
expect(mod.SkillCard).toBeDefined()
|
||||
expect(typeof mod.SkillCard).toBe('function')
|
||||
})
|
||||
})
|
||||
18
web/src/features/skill/skill-label-panel.test.ts
Normal file
18
web/src/features/skill/skill-label-panel.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './skill-label-panel'
|
||||
|
||||
/**
|
||||
* skill-label-panel.tsx exports the SkillLabelPanel React component.
|
||||
* It contains several module-private pure helpers (canManageLabelType,
|
||||
* resolveDisplayName, toCandidateLabel, sortByPresentation) that encode
|
||||
* real business logic but cannot be imported for direct testing.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('skill-label-panel module exports', () => {
|
||||
it('exports the SkillLabelPanel component', () => {
|
||||
expect(mod.SkillLabelPanel).toBeDefined()
|
||||
expect(typeof mod.SkillLabelPanel).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/skill/use-search-skills.test.ts
Normal file
17
web/src/features/skill/use-search-skills.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { useSearchSkills } from './use-search-skills'
|
||||
|
||||
/**
|
||||
* use-search-skills.ts is a thin re-export barrel:
|
||||
* export { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
*
|
||||
* There is no custom logic, query-key factory, or data transformation
|
||||
* to unit-test. We verify the re-export resolves so import-path
|
||||
* changes are caught early.
|
||||
*/
|
||||
describe('use-search-skills re-export', () => {
|
||||
it('re-exports useSearchSkills from shared hooks', () => {
|
||||
expect(useSearchSkills).toBeDefined()
|
||||
expect(typeof useSearchSkills).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/skill/use-skill-detail.test.ts
Normal file
17
web/src/features/skill/use-skill-detail.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { useSkillDetail } from './use-skill-detail'
|
||||
|
||||
/**
|
||||
* use-skill-detail.ts is a thin re-export barrel:
|
||||
* export { useSkillDetail } from '@/shared/hooks/use-skill-queries'
|
||||
*
|
||||
* There is no custom logic, query-key factory, or data transformation
|
||||
* to unit-test. We verify the re-export resolves so import-path
|
||||
* changes are caught early.
|
||||
*/
|
||||
describe('use-skill-detail re-export', () => {
|
||||
it('re-exports useSkillDetail from shared hooks', () => {
|
||||
expect(useSkillDetail).toBeDefined()
|
||||
expect(typeof useSkillDetail).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/skill/use-skill-files.test.ts
Normal file
17
web/src/features/skill/use-skill-files.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { useSkillFiles } from './use-skill-files'
|
||||
|
||||
/**
|
||||
* use-skill-files.ts is a thin re-export barrel:
|
||||
* export { useSkillFiles } from '@/shared/hooks/use-skill-queries'
|
||||
*
|
||||
* There is no custom logic, query-key factory, or data transformation
|
||||
* to unit-test. We verify the re-export resolves so import-path
|
||||
* changes are caught early.
|
||||
*/
|
||||
describe('use-skill-files re-export', () => {
|
||||
it('re-exports useSkillFiles from shared hooks', () => {
|
||||
expect(useSkillFiles).toBeDefined()
|
||||
expect(typeof useSkillFiles).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/skill/use-skill-versions.test.ts
Normal file
17
web/src/features/skill/use-skill-versions.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { useSkillVersions } from './use-skill-versions'
|
||||
|
||||
/**
|
||||
* use-skill-versions.ts is a thin re-export barrel:
|
||||
* export { useSkillVersions } from '@/shared/hooks/use-skill-queries'
|
||||
*
|
||||
* There is no custom logic, query-key factory, or data transformation
|
||||
* to unit-test. We verify the re-export resolves so import-path
|
||||
* changes are caught early.
|
||||
*/
|
||||
describe('use-skill-versions re-export', () => {
|
||||
it('re-exports useSkillVersions from shared hooks', () => {
|
||||
expect(useSkillVersions).toBeDefined()
|
||||
expect(typeof useSkillVersions).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/social/rating-input.test.ts
Normal file
17
web/src/features/social/rating-input.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './rating-input'
|
||||
|
||||
/**
|
||||
* rating-input.tsx exports the RatingInput component. All rating logic
|
||||
* (hover state, star fill calculation, authentication guard) lives inside
|
||||
* the component with no exported pure helpers or constants.
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('rating-input module exports', () => {
|
||||
it('exports the RatingInput component', () => {
|
||||
expect(mod.RatingInput).toBeDefined()
|
||||
expect(typeof mod.RatingInput).toBe('function')
|
||||
})
|
||||
})
|
||||
17
web/src/features/social/star-button.test.ts
Normal file
17
web/src/features/social/star-button.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './star-button'
|
||||
|
||||
/**
|
||||
* star-button.tsx exports the StarButton component. The toggle logic and
|
||||
* authentication guard are internal to the component with no exported pure
|
||||
* helpers or constants.
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('star-button module exports', () => {
|
||||
it('exports the StarButton component', () => {
|
||||
expect(mod.StarButton).toBeDefined()
|
||||
expect(typeof mod.StarButton).toBe('function')
|
||||
})
|
||||
})
|
||||
22
web/src/features/social/use-rating.test.ts
Normal file
22
web/src/features/social/use-rating.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './use-rating'
|
||||
|
||||
/**
|
||||
* use-rating.ts exports useUserRating and useRate hooks. Both are thin
|
||||
* wrappers around useQuery/useMutation with no exported pure helpers,
|
||||
* query-key functions, or data transformations.
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('use-rating module exports', () => {
|
||||
it('exports useUserRating as a function', () => {
|
||||
expect(mod.useUserRating).toBeDefined()
|
||||
expect(typeof mod.useUserRating).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useRate as a function', () => {
|
||||
expect(mod.useRate).toBeDefined()
|
||||
expect(typeof mod.useRate).toBe('function')
|
||||
})
|
||||
})
|
||||
22
web/src/features/social/use-star.test.ts
Normal file
22
web/src/features/social/use-star.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './use-star'
|
||||
|
||||
/**
|
||||
* use-star.ts exports useStar and useToggleStar hooks. Both are thin
|
||||
* wrappers around useQuery/useMutation with no exported pure helpers,
|
||||
* query-key functions, or data transformations.
|
||||
*
|
||||
* We verify the export contract so downstream consumers break fast if
|
||||
* the module shape changes.
|
||||
*/
|
||||
describe('use-star module exports', () => {
|
||||
it('exports useStar as a function', () => {
|
||||
expect(mod.useStar).toBeDefined()
|
||||
expect(typeof mod.useStar).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useToggleStar as a function', () => {
|
||||
expect(mod.useToggleStar).toBeDefined()
|
||||
expect(typeof mod.useToggleStar).toBe('function')
|
||||
})
|
||||
})
|
||||
14
web/src/features/token/create-token-dialog.test.ts
Normal file
14
web/src/features/token/create-token-dialog.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { CreateTokenDialog } from './create-token-dialog'
|
||||
|
||||
// CreateTokenDialog is a stateful React component that handles API token creation
|
||||
// with duplicate-name checks, expiration selection, and a one-time token reveal.
|
||||
// Its internal MAX_TOKEN_NAME_LENGTH constant and validation logic are not exported
|
||||
// and can only be exercised through component rendering.
|
||||
|
||||
describe('create-token-dialog module', () => {
|
||||
it('exports the CreateTokenDialog component', () => {
|
||||
expect(CreateTokenDialog).toBeDefined()
|
||||
expect(typeof CreateTokenDialog).toBe('function')
|
||||
})
|
||||
})
|
||||
14
web/src/features/token/token-list.test.ts
Normal file
14
web/src/features/token/token-list.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { TokenList } from './token-list'
|
||||
|
||||
// TokenList is a complex stateful React component that owns token pagination,
|
||||
// optimistic deletion, expiration editing, and creation entry points. Its internal
|
||||
// PAGE_SIZE constant and formatting helpers are not exported and can only be
|
||||
// exercised through component rendering.
|
||||
|
||||
describe('token-list module', () => {
|
||||
it('exports the TokenList component', () => {
|
||||
expect(TokenList).toBeDefined()
|
||||
expect(typeof TokenList).toBe('function')
|
||||
})
|
||||
})
|
||||
64
web/src/i18n/config.test.ts
Normal file
64
web/src/i18n/config.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The i18n config module performs side-effect-only initialization.
|
||||
// We mock i18next to verify that init is called with expected config.
|
||||
|
||||
const initMock = vi.fn().mockReturnThis()
|
||||
const useMock = vi.fn().mockReturnThis()
|
||||
|
||||
vi.mock('i18next', () => ({
|
||||
default: {
|
||||
use: useMock,
|
||||
init: initMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
initReactI18next: { type: '3rdParty', init: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('i18next-browser-languagedetector', () => ({
|
||||
default: class MockDetector {},
|
||||
}))
|
||||
|
||||
vi.mock('./locales/en.json', () => ({
|
||||
default: { greeting: 'Hello' },
|
||||
}))
|
||||
|
||||
vi.mock('./locales/zh.json', () => ({
|
||||
default: { greeting: '你好' },
|
||||
}))
|
||||
|
||||
// Import triggers the side-effect initialization
|
||||
await import('./config')
|
||||
|
||||
describe('i18n config', () => {
|
||||
it('chains the language detector and react-i18next plugins', () => {
|
||||
expect(useMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('calls init with the english fallback language', () => {
|
||||
expect(initMock).toHaveBeenCalledTimes(1)
|
||||
const initOptions = initMock.mock.calls[0][0]
|
||||
expect(initOptions.fallbackLng).toBe('en')
|
||||
})
|
||||
|
||||
it('disables HTML escaping for React interpolation', () => {
|
||||
const initOptions = initMock.mock.calls[0][0]
|
||||
expect(initOptions.interpolation.escapeValue).toBe(false)
|
||||
})
|
||||
|
||||
it('configures localStorage-first detection order', () => {
|
||||
const initOptions = initMock.mock.calls[0][0]
|
||||
expect(initOptions.detection.order).toEqual(['localStorage', 'navigator'])
|
||||
expect(initOptions.detection.caches).toEqual(['localStorage'])
|
||||
})
|
||||
|
||||
it('registers both english and chinese resource bundles', () => {
|
||||
const initOptions = initMock.mock.calls[0][0]
|
||||
expect(initOptions.resources).toHaveProperty('en')
|
||||
expect(initOptions.resources).toHaveProperty('zh')
|
||||
expect(initOptions.resources.en).toHaveProperty('translation')
|
||||
expect(initOptions.resources.zh).toHaveProperty('translation')
|
||||
})
|
||||
})
|
||||
86
web/src/pages/admin/audit-log.test.tsx
Normal file
86
web/src/pages/admin/audit-log.test.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// AuditLogPage is a JSX-heavy component with local state and hooks.
|
||||
// The ACTION_OPTIONS constant is not exported, but we can verify the component
|
||||
// exists and renders the expected audit log action filter list by checking
|
||||
// its behavior in a static render.
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (value: string) => value,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
normalizeSelectValue: (v: string) => v || null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/table', () => ({
|
||||
Table: ({ children }: { children: unknown }) => children,
|
||||
TableBody: ({ children }: { children: unknown }) => children,
|
||||
TableCell: ({ children }: { children: unknown }) => children,
|
||||
TableHead: ({ children }: { children: unknown }) => children,
|
||||
TableHeader: ({ children }: { children: unknown }) => children,
|
||||
TableRow: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
const useAuditLogMock = vi.fn()
|
||||
vi.mock('@/features/admin/use-audit-log', () => ({
|
||||
useAuditLog: () => useAuditLogMock(),
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AuditLogPage } from './audit-log'
|
||||
|
||||
describe('AuditLogPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof AuditLogPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the empty state when there are no audit logs', () => {
|
||||
useAuditLogMock.mockReturnValue({
|
||||
data: { items: [], total: 0, page: 0, size: 20 },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<AuditLogPage />)
|
||||
expect(html).toContain('auditLog.empty')
|
||||
})
|
||||
|
||||
it('renders the page title and subtitle', () => {
|
||||
useAuditLogMock.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<AuditLogPage />)
|
||||
expect(html).toContain('auditLog.title')
|
||||
expect(html).toContain('auditLog.subtitle')
|
||||
})
|
||||
})
|
||||
98
web/src/pages/admin/users.test.tsx
Normal file
98
web/src/pages/admin/users.test.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (value: string) => value,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
normalizeSelectValue: (v: string) => v || null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/table', () => ({
|
||||
Table: ({ children }: { children: unknown }) => children,
|
||||
TableBody: ({ children }: { children: unknown }) => children,
|
||||
TableCell: ({ children }: { children: unknown }) => children,
|
||||
TableHead: ({ children }: { children: unknown }) => children,
|
||||
TableHeader: ({ children }: { children: unknown }) => children,
|
||||
TableRow: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: unknown }) => children,
|
||||
DialogContent: ({ children }: { children: unknown }) => children,
|
||||
DialogDescription: ({ children }: { children: unknown }) => children,
|
||||
DialogFooter: ({ children }: { children: unknown }) => children,
|
||||
DialogHeader: ({ children }: { children: unknown }) => children,
|
||||
DialogTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/label', () => ({
|
||||
Label: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
const useAdminUsersMock = vi.fn()
|
||||
vi.mock('@/features/admin/use-admin-users', () => ({
|
||||
useAdminUsers: () => useAdminUsersMock(),
|
||||
useApproveUser: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDisableUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useEnableUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useUpdateUserRole: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AdminUsersPage } from './users'
|
||||
|
||||
describe('AdminUsersPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof AdminUsersPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the empty state when no users are found', () => {
|
||||
useAdminUsersMock.mockReturnValue({
|
||||
data: { items: [], total: 0, page: 0, size: 20 },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<AdminUsersPage />)
|
||||
expect(html).toContain('adminUsers.empty')
|
||||
})
|
||||
|
||||
it('renders the page title and search UI', () => {
|
||||
useAdminUsersMock.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<AdminUsersPage />)
|
||||
expect(html).toContain('adminUsers.title')
|
||||
expect(html).toContain('adminUsers.subtitle')
|
||||
})
|
||||
})
|
||||
45
web/src/pages/cli-auth.test.ts
Normal file
45
web/src/pages/cli-auth.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// CliAuthPage has internal helpers isValidRedirectUri and decodeLabel which are
|
||||
// not exported. We test the component render paths and validate the redirect
|
||||
// URI logic via the rendered error states.
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getCurrentUser: vi.fn().mockResolvedValue(null),
|
||||
tokenApi: { createToken: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/router', () => ({
|
||||
ORIGINAL_URL_SEARCH: '',
|
||||
}))
|
||||
|
||||
import { CliAuthPage } from './cli-auth'
|
||||
|
||||
describe('CliAuthPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof CliAuthPage).toBe('function')
|
||||
expect(CliAuthPage.name).toBe('CliAuthPage')
|
||||
})
|
||||
})
|
||||
77
web/src/pages/dashboard.test.tsx
Normal file
77
web/src/pages/dashboard.test.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// DashboardPage is a component-only page that wires auth context, skill previews,
|
||||
// and token list. No exported pure functions or constants beyond the component.
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { userId: 'u1', displayName: 'Test User', platformRoles: ['USER'] },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-user-queries', () => ({
|
||||
useMySkills: () => ({
|
||||
data: { items: [], total: 0, page: 0, size: 5 },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/governance-access', () => ({
|
||||
canViewGovernanceCenter: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/skill-lifecycle', () => ({
|
||||
getHeadlineVersion: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/token/token-list', () => ({
|
||||
TokenList: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/page-shell-style', () => ({
|
||||
APP_SHELL_PAGE_CLASS_NAME: 'page-shell',
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { DashboardPage } from './dashboard'
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof DashboardPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the dashboard title and user info section', () => {
|
||||
const html = renderToStaticMarkup(<DashboardPage />)
|
||||
|
||||
expect(html).toContain('dashboard.title')
|
||||
expect(html).toContain('dashboard.userInfo')
|
||||
})
|
||||
|
||||
it('shows the my-skills preview section', () => {
|
||||
const html = renderToStaticMarkup(<DashboardPage />)
|
||||
|
||||
expect(html).toContain('mySkills.title')
|
||||
})
|
||||
})
|
||||
234
web/src/pages/dashboard/governance.test.ts
Normal file
234
web/src/pages/dashboard/governance.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { createElement, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hasRole: vi.fn(),
|
||||
summary: vi.fn(),
|
||||
inbox: vi.fn(),
|
||||
activity: vi.fn(),
|
||||
notifications: vi.fn(),
|
||||
rebuildSearchIndex: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
totalPages: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: mocks.hasRole }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) =>
|
||||
createElement('header', null, createElement('h1', null, title), createElement('p', null, subtitle)),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: ({ page, totalPages }: { page: number; totalPages: number }) =>
|
||||
createElement('div', null, `pagination:${page}/${totalPages}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: ReactNode }) => children,
|
||||
TabsContent: ({ children, value }: { children: ReactNode; value: string }) =>
|
||||
value === 'ALL' ? createElement('div', { 'data-tab': value }, children) : null,
|
||||
TabsList: ({ children }: { children: ReactNode }) => children,
|
||||
TabsTrigger: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-inbox', () => ({
|
||||
GovernanceInbox: ({ items, isLoading }: { items?: Array<{ id: string }>; isLoading: boolean }) =>
|
||||
createElement('div', null, `governance-inbox:${isLoading ? 'loading' : items?.length ?? 0}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-activity', () => ({
|
||||
GovernanceActivity: ({ items, isLoading }: { items?: Array<{ id: string }>; isLoading: boolean }) =>
|
||||
createElement('div', null, `governance-activity:${isLoading ? 'loading' : items?.length ?? 0}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-notifications', () => ({
|
||||
GovernanceNotifications: ({
|
||||
items,
|
||||
isLoading,
|
||||
}: {
|
||||
items?: Array<{ id: string }>
|
||||
isLoading: boolean
|
||||
}) => createElement('div', null, `governance-notifications:${isLoading ? 'loading' : items?.length ?? 0}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-pagination', () => ({
|
||||
getGovernanceTotalPages: (total: number, size: number) => {
|
||||
if (total <= 0 || size <= 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(total / size))
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/use-governance', () => ({
|
||||
GOVERNANCE_PAGE_SIZE: 20,
|
||||
useGovernanceActivity: () => mocks.activity(),
|
||||
useGovernanceInbox: () => mocks.inbox(),
|
||||
useGovernanceNotifications: () => mocks.notifications(),
|
||||
useRebuildSearchIndex: () => mocks.rebuildSearchIndex(),
|
||||
useGovernanceSummary: () => mocks.summary(),
|
||||
useMarkGovernanceNotificationRead: () => mocks.markRead(),
|
||||
}))
|
||||
|
||||
import { GovernancePage } from './governance'
|
||||
|
||||
describe('GovernancePage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.hasRole.mockReturnValue(false)
|
||||
mocks.summary.mockReturnValue({
|
||||
data: {
|
||||
pendingReviews: 11,
|
||||
pendingPromotions: 22,
|
||||
pendingReports: 33,
|
||||
unreadNotifications: 44,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.inbox.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'inbox-1' }, { id: 'inbox-2' }],
|
||||
total: 40,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.activity.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'activity-1' }, { id: 'activity-2' }, { id: 'activity-3' }],
|
||||
total: 60,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.notifications.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'notification-1' }],
|
||||
total: 21,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.rebuildSearchIndex.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
})
|
||||
mocks.markRead.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
})
|
||||
mocks.totalPages.mockClear()
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof GovernancePage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the summary cards and core governance sections', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).toContain('governance.title')
|
||||
expect(html).toContain('governance.subtitle')
|
||||
expect(html).toContain('governance.pendingReviews')
|
||||
expect(html).toContain('11')
|
||||
expect(html).toContain('governance.pendingPromotions')
|
||||
expect(html).toContain('22')
|
||||
expect(html).toContain('governance.pendingReports')
|
||||
expect(html).toContain('33')
|
||||
expect(html).toContain('governance.unreadNotifications')
|
||||
expect(html).toContain('44')
|
||||
expect(html).toContain('governance.inboxTitle')
|
||||
expect(html).toContain('governance.notificationsTitle')
|
||||
expect(html).toContain('governance.activityTitle')
|
||||
expect(html).toContain('governance-inbox:2')
|
||||
expect(html).toContain('governance-notifications:1')
|
||||
expect(html).toContain('governance-activity:3')
|
||||
})
|
||||
|
||||
it('shows pagination only when a section has more than one page', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).toContain('pagination:0/2')
|
||||
expect(html).toContain('pagination:0/3')
|
||||
expect(html.match(/pagination:0\/\d+/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hides all pagination when each section fits on one page', () => {
|
||||
mocks.inbox.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'inbox-1' }],
|
||||
total: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.activity.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'activity-1' }],
|
||||
total: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.notifications.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'notification-1' }],
|
||||
total: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).not.toContain('pagination:')
|
||||
})
|
||||
|
||||
it('hides the search maintenance area for non-super-admin users', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).not.toContain('governance.searchMaintenanceTitle')
|
||||
expect(html).not.toContain('governance.searchRebuildAction')
|
||||
})
|
||||
|
||||
it('shows the search maintenance area for super admins', () => {
|
||||
mocks.hasRole.mockReturnValue(true)
|
||||
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).toContain('governance.searchMaintenanceTitle')
|
||||
expect(html).toContain('governance.searchMaintenanceDescription')
|
||||
expect(html).toContain('governance.searchRebuildAction')
|
||||
expect(html).toContain('governance.searchMaintenanceHint')
|
||||
})
|
||||
})
|
||||
67
web/src/pages/dashboard/my-namespaces.test.ts
Normal file
67
web/src/pages/dashboard/my-namespaces.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: () => false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/namespace-badge', () => ({
|
||||
NamespaceBadge: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/empty-state', () => ({
|
||||
EmptyState: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/create-namespace-dialog', () => ({
|
||||
CreateNamespaceDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useArchiveNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
useFreezeNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
useMyNamespaces: () => ({ data: [], isLoading: false }),
|
||||
useRestoreNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
useUnfreezeNamespace: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
import { MyNamespacesPage } from './my-namespaces'
|
||||
|
||||
describe('MyNamespacesPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof MyNamespacesPage).toBe('function')
|
||||
})
|
||||
})
|
||||
86
web/src/pages/dashboard/my-skills.test.ts
Normal file
86
web/src/pages/dashboard/my-skills.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: () => false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/empty-state', () => ({
|
||||
EmptyState: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useArchiveSkill: () => ({ mutateAsync: vi.fn() }),
|
||||
useUnarchiveSkill: () => ({ mutateAsync: vi.fn() }),
|
||||
useWithdrawSkillReview: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-user-queries', () => ({
|
||||
useMySkills: () => ({
|
||||
data: { items: [], total: 0, page: 0, size: 10 },
|
||||
isLoading: false,
|
||||
}),
|
||||
useSubmitPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/skill-lifecycle', () => ({
|
||||
getHeadlineVersion: () => null,
|
||||
getPublishedVersion: () => null,
|
||||
getOwnerPreviewVersion: () => null,
|
||||
hasPendingOwnerPreview: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/number-format', () => ({
|
||||
formatCompactCount: (v: number) => String(v),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
serverMessageKey?: string
|
||||
},
|
||||
}))
|
||||
|
||||
import { MySkillsPage } from './my-skills'
|
||||
|
||||
describe('MySkillsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof MySkillsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
72
web/src/pages/dashboard/namespace-members.test.ts
Normal file
72
web/src/pages/dashboard/namespace-members.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useParams: () => ({ slug: 'test-ns' }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/add-namespace-member-dialog', () => ({
|
||||
AddNamespaceMemberDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/namespace-header', () => ({
|
||||
NamespaceHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => ({ data: [] }),
|
||||
useNamespaceDetail: () => ({ data: null, isLoading: false }),
|
||||
useNamespaceMembers: () => ({ data: [], isLoading: false, error: null }),
|
||||
useRemoveNamespaceMember: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateNamespaceMemberRole: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
import { NamespaceMembersPage } from './namespace-members'
|
||||
|
||||
describe('NamespaceMembersPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof NamespaceMembersPage).toBe('function')
|
||||
})
|
||||
})
|
||||
67
web/src/pages/dashboard/namespace-reviews.test.ts
Normal file
67
web/src/pages/dashboard/namespace-reviews.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useParams: () => ({ slug: 'test-ns' }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useNamespaceDetail: () => ({ data: null, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/use-review-list', () => ({
|
||||
useReviewList: () => ({ data: null, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/namespace-header', () => ({
|
||||
NamespaceHeader: () => null,
|
||||
}))
|
||||
|
||||
import { NamespaceReviewsPage } from './namespace-reviews'
|
||||
|
||||
describe('NamespaceReviewsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof NamespaceReviewsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
92
web/src/pages/dashboard/profile-review-table.test.ts
Normal file
92
web/src/pages/dashboard/profile-review-table.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Clock3: () => null,
|
||||
ShieldAlert: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: unknown }) => children,
|
||||
DialogContent: ({ children }: { children: unknown }) => children,
|
||||
DialogDescription: ({ children }: { children: unknown }) => children,
|
||||
DialogFooter: ({ children }: { children: unknown }) => children,
|
||||
DialogHeader: ({ children }: { children: unknown }) => children,
|
||||
DialogTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/table', () => ({
|
||||
Table: ({ children }: { children: unknown }) => children,
|
||||
TableBody: ({ children }: { children: unknown }) => children,
|
||||
TableCell: ({ children }: { children: unknown }) => children,
|
||||
TableHead: ({ children }: { children: unknown }) => children,
|
||||
TableHeader: ({ children }: { children: unknown }) => children,
|
||||
TableRow: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/textarea', () => ({
|
||||
Textarea: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/use-profile-review-list', () => ({
|
||||
useApproveProfileReview: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useProfileReviewList: () => ({ data: null, isLoading: false }),
|
||||
useRejectProfileReview: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/empty-state', () => ({
|
||||
EmptyState: () => null,
|
||||
}))
|
||||
|
||||
import { ProfileReviewTable } from './profile-review-table'
|
||||
|
||||
describe('ProfileReviewTable', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ProfileReviewTable).toBe('function')
|
||||
})
|
||||
})
|
||||
53
web/src/pages/dashboard/promotions.test.ts
Normal file
53
web/src/pages/dashboard/promotions.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/promotion/use-promotion-list', () => ({
|
||||
useApprovePromotion: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
usePromotionList: () => ({ data: [], isLoading: false }),
|
||||
useRejectPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
import { PromotionsPage } from './promotions'
|
||||
|
||||
describe('PromotionsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof PromotionsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
70
web/src/pages/dashboard/publish.test.ts
Normal file
70
web/src/pages/dashboard/publish.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/publish/upload-zone', () => ({
|
||||
UploadZone: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
normalizeSelectValue: (v: string) => v || null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/label', () => ({
|
||||
Label: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
usePublishSkill: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
serverMessageKey?: string
|
||||
},
|
||||
}))
|
||||
|
||||
import { PublishPage } from './publish'
|
||||
|
||||
describe('PublishPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof PublishPage).toBe('function')
|
||||
})
|
||||
})
|
||||
65
web/src/pages/dashboard/reports.test.ts
Normal file
65
web/src/pages/dashboard/reports.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/report/use-skill-reports', () => ({
|
||||
useDismissSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useResolveSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useSkillReports: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/report/report-text', () => ({
|
||||
REPORT_TEXT_WRAP_CLASS_NAME: 'text-wrap',
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
import { ReportsPage } from './reports'
|
||||
|
||||
describe('ReportsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ReportsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
84
web/src/pages/dashboard/reviews.test.ts
Normal file
84
web/src/pages/dashboard/reviews.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
FileCheck2: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/table', () => ({
|
||||
Table: ({ children }: { children: unknown }) => children,
|
||||
TableBody: ({ children }: { children: unknown }) => children,
|
||||
TableCell: ({ children }: { children: unknown }) => children,
|
||||
TableHead: ({ children }: { children: unknown }) => children,
|
||||
TableHeader: ({ children }: { children: unknown }) => children,
|
||||
TableRow: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/use-review-list', () => ({
|
||||
useReviewList: () => ({ data: null, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: () => false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('./profile-review-table', () => ({
|
||||
ProfileReviewTable: () => null,
|
||||
}))
|
||||
|
||||
import { ReviewsPage } from './reviews'
|
||||
|
||||
describe('ReviewsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ReviewsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
46
web/src/pages/dashboard/stars.test.ts
Normal file
46
web/src/pages/dashboard/stars.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/skill/skill-card', () => ({
|
||||
SkillCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-user-queries', () => ({
|
||||
useMyStarsPage: () => ({
|
||||
data: { items: [], total: 0, page: 0, size: 12 },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
import { MyStarsPage } from './stars'
|
||||
|
||||
describe('MyStarsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof MyStarsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
41
web/src/pages/dashboard/tokens.test.tsx
Normal file
41
web/src/pages/dashboard/tokens.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/token/token-list', () => ({
|
||||
TokenList: () => <div>token-list</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { TokensPage } from './tokens'
|
||||
|
||||
describe('TokensPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof TokensPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the page title and the token list component', () => {
|
||||
const html = renderToStaticMarkup(<TokensPage />)
|
||||
|
||||
expect(html).toContain('tokens.pageTitle')
|
||||
expect(html).toContain('tokens.pageSubtitle')
|
||||
expect(html).toContain('token-list')
|
||||
})
|
||||
})
|
||||
45
web/src/pages/device.test.ts
Normal file
45
web/src/pages/device.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/label', () => ({
|
||||
Label: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
fetchJson: vi.fn(),
|
||||
getCsrfHeaders: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/error-display', () => ({
|
||||
truncateErrorMessage: (m: string) => m,
|
||||
}))
|
||||
|
||||
import { DeviceAuthPage } from './device'
|
||||
|
||||
describe('DeviceAuthPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof DeviceAuthPage).toBe('function')
|
||||
expect(DeviceAuthPage.name).toBe('DeviceAuthPage')
|
||||
})
|
||||
})
|
||||
65
web/src/pages/home.test.tsx
Normal file
65
web/src/pages/home.test.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// HomePage is a component-only page. We verify it exports correctly
|
||||
// and renders key sections.
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/search/search-bar', () => ({
|
||||
SearchBar: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/skill-card', () => ({
|
||||
SkillCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/skeleton-loader', () => ({
|
||||
SkeletonList: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/quick-start', () => ({
|
||||
QuickStartSection: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSearchSkills: () => ({
|
||||
data: { items: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/search-query', () => ({
|
||||
normalizeSearchQuery: (q: string) => q.trim(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { HomePage } from './home'
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof HomePage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the hero section with brand name', () => {
|
||||
const html = renderToStaticMarkup(<HomePage />)
|
||||
|
||||
expect(html).toContain('SkillHub')
|
||||
expect(html).toContain('home.subtitle')
|
||||
})
|
||||
})
|
||||
73
web/src/pages/landing.test.tsx
Normal file
73
web/src/pages/landing.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
PackageOpen: () => null,
|
||||
Terminal: () => null,
|
||||
Shield: () => null,
|
||||
Users: () => null,
|
||||
GitBranch: () => null,
|
||||
Search: () => null,
|
||||
Settings: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/landing-quick-start', () => ({
|
||||
LandingQuickStartSection: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/skill-card', () => ({
|
||||
SkillCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/skeleton-loader', () => ({
|
||||
SkeletonList: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSearchSkills: () => ({
|
||||
data: { items: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-in-view', () => ({
|
||||
useInView: () => ({ ref: vi.fn(), inView: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/search-query', () => ({
|
||||
normalizeSearchQuery: (q: string) => q.trim(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { LandingPage } from './landing'
|
||||
|
||||
describe('LandingPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof LandingPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the brand name in the hero section', () => {
|
||||
const html = renderToStaticMarkup(<LandingPage />)
|
||||
|
||||
expect(html).toContain('SkillHub')
|
||||
expect(html).toContain('landing.hero.title')
|
||||
})
|
||||
})
|
||||
79
web/src/pages/login.test.tsx
Normal file
79
web/src/pages/login.test.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => ({ returnTo: '' }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { resolvedLanguage: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Eye: () => null,
|
||||
EyeOff: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/login-button', () => ({
|
||||
LoginButton: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/session-bootstrap-entry', () => ({
|
||||
SessionBootstrapEntry: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-auth-methods', () => ({
|
||||
useAuthMethods: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-password-login', () => ({
|
||||
usePasswordLogin: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { LoginPage } from './login'
|
||||
|
||||
describe('LoginPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof LoginPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the login title and form elements', () => {
|
||||
const html = renderToStaticMarkup(<LoginPage />)
|
||||
|
||||
expect(html).toContain('login.title')
|
||||
expect(html).toContain('login.subtitle')
|
||||
expect(html).toContain('login.submit')
|
||||
})
|
||||
})
|
||||
63
web/src/pages/namespace.test.tsx
Normal file
63
web/src/pages/namespace.test.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ namespace: 'global' }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/namespace/namespace-header', () => ({
|
||||
NamespaceHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/skill-card', () => ({
|
||||
SkillCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/skeleton-loader', () => ({
|
||||
SkeletonList: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/empty-state', () => ({
|
||||
EmptyState: ({ title }: { title: string }) => <div>{title}</div>,
|
||||
}))
|
||||
|
||||
const useNamespaceDetailMock = vi.fn()
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useNamespaceDetail: () => useNamespaceDetailMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSearchSkills: () => ({
|
||||
data: { items: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NamespacePage } from './namespace'
|
||||
|
||||
describe('NamespacePage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof NamespacePage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the not-found state when namespace data is missing', () => {
|
||||
useNamespaceDetailMock.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<NamespacePage />)
|
||||
expect(html).toContain('namespace.notFound')
|
||||
})
|
||||
})
|
||||
33
web/src/pages/privacy.test.tsx
Normal file
33
web/src/pages/privacy.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { resolvedLanguage: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/components/legal-document', () => ({
|
||||
LegalDocument: (props: { title: string; summary: string }) => (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
<p>{props.summary}</p>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { PrivacyPolicyPage } from './privacy'
|
||||
|
||||
describe('PrivacyPolicyPage', () => {
|
||||
it('renders the english privacy policy for non-chinese locales', () => {
|
||||
const html = renderToStaticMarkup(<PrivacyPolicyPage />)
|
||||
|
||||
expect(html).toContain('Privacy Policy')
|
||||
expect(html).toContain('This policy explains')
|
||||
})
|
||||
})
|
||||
69
web/src/pages/register.test.tsx
Normal file
69
web/src/pages/register.test.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => ({ returnTo: '' }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/login-button', () => ({
|
||||
LoginButton: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-local-auth', () => ({
|
||||
useLocalRegister: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { RegisterPage } from './register'
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof RegisterPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the registration title and form fields', () => {
|
||||
const html = renderToStaticMarkup(<RegisterPage />)
|
||||
|
||||
expect(html).toContain('register.title')
|
||||
expect(html).toContain('register.subtitle')
|
||||
expect(html).toContain('register.submit')
|
||||
})
|
||||
})
|
||||
45
web/src/pages/settings/accounts.test.ts
Normal file
45
web/src/pages/settings/accounts.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/use-account-merge', () => ({
|
||||
useInitiateAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useVerifyAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useConfirmAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/error-display', () => ({
|
||||
truncateErrorMessage: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
import { AccountSettingsPage } from './accounts'
|
||||
|
||||
describe('AccountSettingsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof AccountSettingsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
62
web/src/pages/settings/profile.test.ts
Normal file
62
web/src/pages/settings/profile.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: null }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
status?: number
|
||||
},
|
||||
profileApi: {
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ user: { displayName: 'Test', avatarUrl: null, email: 'test@test.com' } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/error-display', () => ({
|
||||
truncateErrorMessage: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
import { ProfileSettingsPage } from './profile'
|
||||
|
||||
describe('ProfileSettingsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ProfileSettingsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
61
web/src/pages/settings/security.test.ts
Normal file
61
web/src/pages/settings/security.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
status?: number
|
||||
},
|
||||
authApi: {
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/error-display', () => ({
|
||||
truncateErrorMessage: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
import { SecuritySettingsPage } from './security'
|
||||
|
||||
describe('SecuritySettingsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof SecuritySettingsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
33
web/src/pages/terms.test.tsx
Normal file
33
web/src/pages/terms.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { resolvedLanguage: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/components/legal-document', () => ({
|
||||
LegalDocument: (props: { title: string; summary: string }) => (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
<p>{props.summary}</p>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { TermsOfServicePage } from './terms'
|
||||
|
||||
describe('TermsOfServicePage', () => {
|
||||
it('renders the english terms of service for non-chinese locales', () => {
|
||||
const html = renderToStaticMarkup(<TermsOfServicePage />)
|
||||
|
||||
expect(html).toContain('Terms of Service')
|
||||
expect(html).toContain('These terms apply')
|
||||
})
|
||||
})
|
||||
16
web/src/shared/components/confirm-dialog.test.ts
Normal file
16
web/src/shared/components/confirm-dialog.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './confirm-dialog'
|
||||
|
||||
/**
|
||||
* ConfirmDialog is a React component that wraps Radix Dialog with confirm/cancel buttons.
|
||||
* All logic depends on React hooks (useState, useTranslation) and Dialog UI primitives.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('confirm-dialog module exports', () => {
|
||||
it('exports the ConfirmDialog component', () => {
|
||||
expect(mod.ConfirmDialog).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/shared/components/copy-button.test.ts
Normal file
16
web/src/shared/components/copy-button.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './copy-button'
|
||||
|
||||
/**
|
||||
* CopyButton is a React component that copies text to the clipboard.
|
||||
* All logic uses React state and navigator.clipboard API.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('copy-button module exports', () => {
|
||||
it('exports the CopyButton component', () => {
|
||||
expect(mod.CopyButton).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
17
web/src/shared/components/dashboard-page-header.test.ts
Normal file
17
web/src/shared/components/dashboard-page-header.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './dashboard-page-header'
|
||||
|
||||
/**
|
||||
* DashboardPageHeader is a React component that renders a standard header for
|
||||
* dashboard sub-pages with a back button, title, subtitle, and action slot.
|
||||
* All logic depends on useNavigate and useTranslation hooks.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('dashboard-page-header module exports', () => {
|
||||
it('exports the DashboardPageHeader component', () => {
|
||||
expect(mod.DashboardPageHeader).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/shared/components/empty-state.test.ts
Normal file
16
web/src/shared/components/empty-state.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './empty-state'
|
||||
|
||||
/**
|
||||
* EmptyState is a React component that renders a centered placeholder with
|
||||
* title, optional description, and optional action slot.
|
||||
* It is purely presentational JSX with no exported helpers or constants.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('empty-state module exports', () => {
|
||||
it('exports the EmptyState component', () => {
|
||||
expect(mod.EmptyState).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
17
web/src/shared/components/landing-quick-start.test.ts
Normal file
17
web/src/shared/components/landing-quick-start.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './landing-quick-start'
|
||||
|
||||
/**
|
||||
* LandingQuickStartSection is a React component that renders a tabbed quick-start
|
||||
* section with agent/human tabs and copy-to-clipboard commands.
|
||||
* All logic depends on React state and i18next hooks.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('landing-quick-start module exports', () => {
|
||||
it('exports the LandingQuickStartSection component', () => {
|
||||
expect(mod.LandingQuickStartSection).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
17
web/src/shared/components/language-switcher.test.ts
Normal file
17
web/src/shared/components/language-switcher.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './language-switcher'
|
||||
|
||||
/**
|
||||
* LanguageSwitcher is a React component that renders a dropdown to switch
|
||||
* between Chinese and English using i18next.
|
||||
* All logic depends on i18next hooks and Radix DropdownMenu primitives.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('language-switcher module exports', () => {
|
||||
it('exports the LanguageSwitcher component', () => {
|
||||
expect(mod.LanguageSwitcher).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/shared/components/legal-document.test.ts
Normal file
16
web/src/shared/components/legal-document.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './legal-document'
|
||||
|
||||
/**
|
||||
* LegalDocument is a React component that renders structured legal content
|
||||
* with eyebrow, title, summary, note, and sections (paragraphs + bullets).
|
||||
* It is purely presentational JSX with no exported helpers or constants.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('legal-document module exports', () => {
|
||||
it('exports the LegalDocument component', () => {
|
||||
expect(mod.LegalDocument).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/shared/components/namespace-badge.test.ts
Normal file
16
web/src/shared/components/namespace-badge.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './namespace-badge'
|
||||
|
||||
/**
|
||||
* NamespaceBadge is a React component that renders a styled badge for
|
||||
* GLOBAL or TEAM namespace types using cn() for conditional class merging.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('namespace-badge module exports', () => {
|
||||
it('exports the NamespaceBadge component', () => {
|
||||
expect(mod.NamespaceBadge).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/shared/components/pagination.test.ts
Normal file
16
web/src/shared/components/pagination.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './pagination'
|
||||
|
||||
/**
|
||||
* Pagination is a React component that renders prev/next buttons with a
|
||||
* page counter display. All logic depends on useTranslation and callback props.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('pagination module exports', () => {
|
||||
it('exports the Pagination component', () => {
|
||||
expect(mod.Pagination).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
17
web/src/shared/components/quick-start.test.ts
Normal file
17
web/src/shared/components/quick-start.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './quick-start'
|
||||
|
||||
/**
|
||||
* QuickStartSection is a React component that renders a multi-step quick-start
|
||||
* guide with code blocks, copy buttons, and syntax-highlighted code lines.
|
||||
* Internal helpers (getAppBaseUrl, CodeLine, CodeBlock, CopyButton) are not exported.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('quick-start module exports', () => {
|
||||
it('exports the QuickStartSection component', () => {
|
||||
expect(mod.QuickStartSection).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
18
web/src/shared/components/role-guard.test.ts
Normal file
18
web/src/shared/components/role-guard.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './role-guard'
|
||||
|
||||
/**
|
||||
* RoleGuard is a React component that enforces client-side role-based access.
|
||||
* It delegates to pure helpers canAccessRoute() and shouldNavigateBackOnForbidden()
|
||||
* from @/shared/lib/role-guard, which are already tested in shared/lib/role-guard.test.ts.
|
||||
* The component itself depends on useAuth, useNavigate, and useTranslation hooks.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('role-guard component module exports', () => {
|
||||
it('exports the RoleGuard component', () => {
|
||||
expect(mod.RoleGuard).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
20
web/src/shared/components/skeleton-loader.test.ts
Normal file
20
web/src/shared/components/skeleton-loader.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './skeleton-loader'
|
||||
|
||||
/**
|
||||
* skeleton-loader.tsx exports two purely presentational React components:
|
||||
* SkeletonCard (single shimmer placeholder) and SkeletonList (grid of SkeletonCards).
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('skeleton-loader module exports', () => {
|
||||
it('exports the SkeletonCard component', () => {
|
||||
expect(mod.SkeletonCard).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exports the SkeletonList component', () => {
|
||||
expect(mod.SkeletonList).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
16
web/src/shared/components/toaster.test.ts
Normal file
16
web/src/shared/components/toaster.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './toaster'
|
||||
|
||||
/**
|
||||
* Toaster is a React component that wraps Sonner's Toaster with project-specific
|
||||
* positioning and styling. It uses CENTER_TOASTER_ID from @/shared/lib/toast.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('toaster module exports', () => {
|
||||
it('exports the Toaster component', () => {
|
||||
expect(mod.Toaster).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
18
web/src/shared/components/user-menu.test.ts
Normal file
18
web/src/shared/components/user-menu.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './user-menu'
|
||||
|
||||
/**
|
||||
* UserMenu is a React component that renders a hover/click dropdown menu with
|
||||
* role-based navigation links (dashboard, reviews, admin, etc.) and logout.
|
||||
* Internal helpers (hasRole, closeMenu, handleMouseEnter/Leave) and the
|
||||
* menuItemClassName constant are scoped inside the component function.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('user-menu module exports', () => {
|
||||
it('exports the UserMenu component', () => {
|
||||
expect(mod.UserMenu).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue