Merge remote-tracking branch 'origin/main' into feature/notification-system

This commit is contained in:
yun-zhi-ztl 2026-03-23 17:12:13 +08:00
commit 3f7145a843
116 changed files with 5151 additions and 3 deletions

157
web/src/api/client.test.ts Normal file
View 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)
})
})

View file

@ -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', () => {

View file

@ -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', () => {

View 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')
})
})

View file

@ -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', () => {

View 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')
})
})

View 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()
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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' })
})
})

View 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)
})
})

View 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.

View file

@ -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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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([])
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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)
})
})

View 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)
})
})

View 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')
})
})

View 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')
})
})

View 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>')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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