test: add component tests, improve mocks and stubs

This commit is contained in:
2026-04-03 20:33:17 +03:00
parent 1c2c2acf2c
commit a00fbbe580
30 changed files with 1845 additions and 264 deletions

View File

@@ -91,31 +91,8 @@ For a complete list of available commands, usage examples, and detailed document
## Troubleshooting
### Cannot access management features
If you are a Nextcloud administrator but cannot access Forum management features (e.g., managing
categories, roles, or settings), this is likely due to missing database seeds or role assignments.
**Option 1: Using OCC commands**
Run the following commands from your Nextcloud installation directory:
```bash
# Repair database seeds (creates default roles and permissions if missing)
php occ forum:repair-seeds
# Assign administrator role to an account
php occ forum:set-role <username> admin
```
**Option 2: Using the server administration panel**
1. Log in to Nextcloud as an administrator
2. Go to **Administration Settings** (click your profile picture → Administration Settings)
3. Navigate to **Forum** in the left sidebar under the Administration section
4. Use the **Repair Seeds** button to restore default roles and permissions
5. If the seeds are already in place, use the **Assign Roles** section to assign the administrator
role to accounts
For troubleshooting common issues, visit the
[Troubleshooting Wiki page](https://github.com/chenasraf/nextcloud-forum/wiki/Troubleshooting).
## Contributing

View File

@@ -0,0 +1,515 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import {
createIconMock,
createComponentMock,
RouterLinkStub,
createNcActionButtonMock,
} from '@/test-utils'
import { ref } from 'vue'
// --- Composable mocks ---
const mockFetchCategories = vi.fn().mockResolvedValue([])
const mockGetAllCategoriesFlat = vi.fn(() => [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockCategoryHeaders = ref<any[]>([])
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
categoryHeaders: mockCategoryHeaders,
fetchCategories: mockFetchCategories,
getAllCategoriesFlat: mockGetAllCategoriesFlat,
}),
}))
const mockUserId = ref<string | null>(null)
const mockDisplayName = ref('Test User')
const mockFetchCurrentUser = vi.fn().mockResolvedValue(null)
vi.mock('@/composables/useCurrentUser', () => ({
useCurrentUser: () => ({
userId: mockUserId,
displayName: mockDisplayName,
fetchCurrentUser: mockFetchCurrentUser,
}),
}))
const mockCanAccessAdmin = ref(false)
const mockCanAccessAdminTools = ref(false)
const mockCanManageUsers = ref(false)
const mockCanEditRoles = ref(false)
const mockCanEditCategories = ref(false)
const mockCanEditBbcodes = ref(false)
const mockCanAccessModeration = ref(false)
vi.mock('@/composables/useUserRole', () => ({
useUserRole: () => ({
canAccessAdmin: mockCanAccessAdmin,
canAccessAdminTools: mockCanAccessAdminTools,
canManageUsers: mockCanManageUsers,
canEditRoles: mockCanEditRoles,
canEditCategories: mockCanEditCategories,
canEditBbcodes: mockCanEditBbcodes,
canAccessModeration: mockCanAccessModeration,
}),
}))
const mockCurrentThreadCategoryId = ref<number | null>(null)
const mockFetchThread = vi.fn()
const mockClearThread = vi.fn()
vi.mock('@/composables/useCurrentThread', () => ({
useCurrentThread: () => ({
categoryId: mockCurrentThreadCategoryId,
fetchThread: mockFetchThread,
clearThread: mockClearThread,
}),
}))
const mockIsGuest = ref(false)
const mockGuestDisplayName = ref<string | null>(null)
const mockFetchGuestIdentity = vi.fn().mockResolvedValue(undefined)
vi.mock('@/composables/useGuestSession', () => ({
useGuestSession: () => ({
isGuest: mockIsGuest,
guestDisplayName: mockGuestDisplayName,
fetchGuestIdentity: mockFetchGuestIdentity,
}),
}))
// --- Component mocks ---
vi.mock('@nextcloud/vue/components/NcAppNavigation', () =>
createComponentMock('NcAppNavigation', {
template: '<div class="nc-app-navigation"><slot name="list" /><slot name="footer" /></div>',
}),
)
vi.mock('@nextcloud/vue/components/NcAppNavigationItem', () =>
createComponentMock('NcAppNavigationItem', {
template:
'<div class="nav-item" :data-name="name" :data-active="active"><slot name="icon" /><slot name="actions" /><slot /></div>',
props: ['name', 'to', 'active', 'open'],
}),
)
vi.mock('@nextcloud/vue/components/NcAppNavigationSearch', () =>
createComponentMock('NcAppNavigationSearch', {
template: '<div class="nav-search" />',
props: ['modelValue', 'label'],
}),
)
vi.mock('@nextcloud/vue/components/NcActionButton', () => createNcActionButtonMock())
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template:
'<div class="user-info" :data-user-id="userId" :data-display-name="displayName" :data-is-guest="isGuest"><slot name="meta" /></div>',
props: ['userId', 'displayName', 'avatarSize', 'isGuest', 'clickable', 'layout'],
}),
)
vi.mock('./NavCategoryItem.vue', () =>
createComponentMock('NavCategoryItem', {
template: '<div class="nav-category-item" :data-category-id="category?.id" />',
props: ['category', 'active', 'activeCategoryIds'],
}),
)
// Icon mocks
vi.mock('@icons/Home.vue', () => createIconMock('HomeIcon'))
vi.mock('@icons/Folder.vue', () => createIconMock('FolderIcon'))
vi.mock('@icons/Magnify.vue', () => createIconMock('MagnifyIcon'))
vi.mock('@icons/Bookmark.vue', () => createIconMock('BookmarkIcon'))
vi.mock('@icons/ChevronDown.vue', () => createIconMock('ChevronDownIcon'))
vi.mock('@icons/ChevronRight.vue', () => createIconMock('ChevronRightIcon'))
vi.mock('@icons/ShieldCheck.vue', () => createIconMock('ShieldCheckIcon'))
vi.mock('@icons/ShieldAccount.vue', () => createIconMock('ShieldAccountIcon'))
vi.mock('@icons/ChartLine.vue', () => createIconMock('ChartLineIcon'))
vi.mock('@icons/AccountMultiple.vue', () => createIconMock('AccountMultipleIcon'))
vi.mock('@icons/CodeBrackets.vue', () => createIconMock('CodeBracketsIcon'))
vi.mock('@icons/ShieldAlert.vue', () => createIconMock('ShieldAlertIcon'))
vi.mock('@icons/Cog.vue', () => createIconMock('CogIcon'))
vi.mock('@icons/AccountCog.vue', () => createIconMock('AccountCogIcon'))
vi.mock('@icons/Login.vue', () => createIconMock('LoginIcon'))
import AppNavigation from './AppNavigation.vue'
describe('AppNavigation', () => {
let mockRoute: { path: string; params: Record<string, string>; query: Record<string, string> }
let mockRouter: { push: ReturnType<typeof vi.fn> }
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockRoute = { path: '/', params: {}, query: {} }
mockRouter = { push: vi.fn() }
mockUserId.value = null
mockDisplayName.value = 'Test User'
mockIsGuest.value = false
mockGuestDisplayName.value = null
mockFetchCurrentUser.mockResolvedValue(null)
mockFetchGuestIdentity.mockResolvedValue(undefined)
mockCategoryHeaders.value = []
mockCanAccessAdmin.value = false
mockCanAccessAdminTools.value = false
mockCanManageUsers.value = false
mockCanEditRoles.value = false
mockCanEditCategories.value = false
mockCanEditBbcodes.value = false
mockCanAccessModeration.value = false
mockCurrentThreadCategoryId.value = null
})
async function mountAndWait() {
const wrapper = mount(AppNavigation, {
global: {
stubs: { RouterLink: RouterLinkStub },
mocks: { $route: mockRoute, $router: mockRouter },
},
})
await flushPromises()
return wrapper
}
describe('loading state', () => {
it('should show loading indicator initially', () => {
// Don't await — check synchronously before created() resolves
const wrapper = mount(AppNavigation, {
global: {
stubs: { RouterLink: RouterLinkStub },
mocks: { $route: mockRoute, $router: mockRouter },
},
})
expect(wrapper.find('.nav-loading').exists()).toBe(true)
expect(wrapper.find('.nav-loading__text').text()).toBe('Loading …')
})
it('should hide loading indicator after data is fetched', async () => {
const wrapper = await mountAndWait()
expect(wrapper.find('.nav-loading').exists()).toBe(false)
})
it('should fetch categories and current user on creation', async () => {
await mountAndWait()
expect(mockFetchCategories).toHaveBeenCalled()
expect(mockFetchCurrentUser).toHaveBeenCalled()
})
})
describe('navigation items', () => {
it('should render Home navigation item', async () => {
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Home"]').exists()).toBe(true)
})
it('should render Search navigation item', async () => {
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Search"]').exists()).toBe(true)
})
it('should show Bookmarks item for logged-in users', async () => {
mockUserId.value = 'user1'
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Bookmarks"]').exists()).toBe(true)
})
it('should hide Bookmarks item for guests', async () => {
mockUserId.value = null
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Bookmarks"]').exists()).toBe(false)
})
it('should show Preferences item for logged-in users', async () => {
mockUserId.value = 'user1'
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Preferences"]').exists()).toBe(true)
})
it('should hide Preferences item for guests', async () => {
mockUserId.value = null
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Preferences"]').exists()).toBe(false)
})
})
describe('category headers', () => {
it('should render category headers', async () => {
mockCategoryHeaders.value = [
{ id: 1, name: 'General', categories: [] },
{ id: 2, name: 'Support', categories: [] },
]
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="General"]').exists()).toBe(true)
expect(wrapper.find('[data-name="Support"]').exists()).toBe(true)
})
it('should render categories under open headers', async () => {
mockCategoryHeaders.value = [
{
id: 1,
name: 'General',
categories: [
{ id: 10, name: 'Discussion', slug: 'discussion' },
{ id: 11, name: 'Announcements', slug: 'announcements' },
],
},
]
const wrapper = await mountAndWait()
const categoryItems = wrapper.findAll('.nav-category-item')
expect(categoryItems).toHaveLength(2)
})
it('should navigate to first category when clicking a header', async () => {
mockCategoryHeaders.value = [
{
id: 1,
name: 'General',
categories: [{ id: 10, name: 'Discussion', slug: 'discussion' }],
},
]
const wrapper = await mountAndWait()
const headerItem = wrapper.find('[data-name="General"]')
await headerItem.trigger('click')
expect(mockRouter.push).toHaveBeenCalledWith({ path: '/c/discussion' })
})
})
describe('admin section', () => {
it('should show Management item when user has admin access', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Management"]').exists()).toBe(true)
})
it('should hide Management item when user lacks admin access', async () => {
mockUserId.value = 'user1'
mockCanAccessAdmin.value = false
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Management"]').exists()).toBe(false)
})
it('should show Dashboard sub-item when user has admin tools access', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
mockCanAccessAdminTools.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Dashboard"]').exists()).toBe(true)
})
it('should show Forum settings sub-item when user has admin tools access', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
mockCanAccessAdminTools.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Forum settings"]').exists()).toBe(true)
})
it('should show Accounts sub-item when user can manage users', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
mockCanManageUsers.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Accounts"]').exists()).toBe(true)
})
it('should show Roles & Teams sub-item when user can edit roles', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
mockCanEditRoles.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Roles & Teams"]').exists()).toBe(true)
})
it('should show Categories sub-item when user can edit categories', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
mockCanEditCategories.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Categories"]').exists()).toBe(true)
})
it('should show BBCodes sub-item when user can edit bbcodes', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
mockCanEditBbcodes.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="BBCodes"]').exists()).toBe(true)
})
it('should show Moderation sub-item when user can access moderation', async () => {
mockUserId.value = 'admin1'
mockCanAccessAdmin.value = true
mockCanAccessModeration.value = true
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Moderation"]').exists()).toBe(true)
})
it('should hide admin sub-items user does not have access to', async () => {
mockUserId.value = 'mod1'
mockCanAccessAdmin.value = true
mockCanAccessModeration.value = true
// Only moderation, nothing else
const wrapper = await mountAndWait()
expect(wrapper.find('[data-name="Moderation"]').exists()).toBe(true)
expect(wrapper.find('[data-name="Dashboard"]').exists()).toBe(false)
expect(wrapper.find('[data-name="Forum settings"]').exists()).toBe(false)
expect(wrapper.find('[data-name="Accounts"]').exists()).toBe(false)
expect(wrapper.find('[data-name="Roles & Teams"]').exists()).toBe(false)
expect(wrapper.find('[data-name="Categories"]').exists()).toBe(false)
expect(wrapper.find('[data-name="BBCodes"]').exists()).toBe(false)
})
})
describe('footer - logged-in user', () => {
it('should show UserInfo with user data for logged-in users', async () => {
mockUserId.value = 'user1'
mockDisplayName.value = 'Alice'
const wrapper = await mountAndWait()
const userInfo = wrapper.find('.sidebar-footer .user-info')
expect(userInfo.exists()).toBe(true)
expect(userInfo.attributes('data-user-id')).toBe('user1')
expect(userInfo.attributes('data-display-name')).toBe('Alice')
})
it('should not show guest footer for logged-in users', async () => {
mockUserId.value = 'user1'
const wrapper = await mountAndWait()
expect(wrapper.find('.guest-login-link').exists()).toBe(false)
expect(wrapper.find('.guest-label').exists()).toBe(false)
})
})
describe('footer - guest', () => {
it('should call fetchGuestIdentity when user is a guest', async () => {
mockIsGuest.value = true
await mountAndWait()
expect(mockFetchGuestIdentity).toHaveBeenCalled()
})
it('should not call fetchGuestIdentity when user is logged in', async () => {
mockUserId.value = 'user1'
mockIsGuest.value = false
await mountAndWait()
expect(mockFetchGuestIdentity).not.toHaveBeenCalled()
})
it('should show guest display name when available', async () => {
mockIsGuest.value = true
mockGuestDisplayName.value = 'BrightMountain42'
const wrapper = await mountAndWait()
const userInfo = wrapper.find('.user-info[data-is-guest="true"]')
expect(userInfo.exists()).toBe(true)
expect(userInfo.attributes('data-display-name')).toBe('BrightMountain42')
})
it('should show guest label in meta slot', async () => {
mockIsGuest.value = true
mockGuestDisplayName.value = 'BrightMountain42'
const wrapper = await mountAndWait()
expect(wrapper.find('.guest-label').text()).toBe('(Guest)')
})
it('should show login link for guests', async () => {
mockIsGuest.value = true
const wrapper = await mountAndWait()
const loginLink = wrapper.find('.guest-login-link')
expect(loginLink.exists()).toBe(true)
expect(loginLink.text()).toContain('Log in')
})
it('should set login link href with redirect URL', async () => {
mockIsGuest.value = true
const wrapper = await mountAndWait()
const loginLink = wrapper.find('.guest-login-link')
expect(loginLink.attributes('href')).toContain('/login')
})
it('should show login link even without guest display name', async () => {
mockIsGuest.value = true
mockGuestDisplayName.value = null
const wrapper = await mountAndWait()
expect(wrapper.find('.guest-login-link').exists()).toBe(true)
expect(wrapper.find('.user-info[data-is-guest="true"]').exists()).toBe(false)
})
it('should render login icon in the login link', async () => {
mockIsGuest.value = true
const wrapper = await mountAndWait()
const loginLink = wrapper.find('.guest-login-link')
expect(loginLink.find('[data-icon="LoginIcon"]').exists()).toBe(true)
})
})
describe('navigation state persistence', () => {
it('should save header toggle state to localStorage', async () => {
mockCategoryHeaders.value = [{ id: 1, name: 'General', categories: [] }]
const wrapper = await mountAndWait()
// Toggle the header
const actionButton = wrapper.find('[data-name="General"]').find('.nc-action-button')
await actionButton.trigger('click')
const saved = JSON.parse(localStorage.getItem('forum_navigation_state')!)
expect(saved.openHeaders).toBeDefined()
expect(saved.openHeaders[1]).toBe(false)
})
it('should restore header toggle state from localStorage', async () => {
localStorage.setItem(
'forum_navigation_state',
JSON.stringify({ isAdminOpen: false, openHeaders: { 1: false } }),
)
mockCategoryHeaders.value = [
{
id: 1,
name: 'General',
categories: [{ id: 10, name: 'Discussion', slug: 'discussion' }],
},
]
const wrapper = await mountAndWait()
// Header is closed, so its categories should not render
expect(wrapper.findAll('.nav-category-item')).toHaveLength(0)
})
it('should default headers to open when no saved state', async () => {
mockCategoryHeaders.value = [
{
id: 1,
name: 'General',
categories: [{ id: 10, name: 'Discussion', slug: 'discussion' }],
},
]
const wrapper = await mountAndWait()
expect(wrapper.findAll('.nav-category-item')).toHaveLength(1)
})
})
describe('route-based active state', () => {
it('should clear thread when not on a thread page', async () => {
mockRoute.path = '/bookmarks'
await mountAndWait()
expect(mockClearThread).toHaveBeenCalled()
})
it('should fetch thread by slug when on a thread slug page', async () => {
mockRoute.path = '/t/my-thread'
mockRoute.params = { slug: 'my-thread' }
await mountAndWait()
expect(mockFetchThread).toHaveBeenCalledWith('my-thread', true, false)
})
it('should fetch thread by id when on a thread id page', async () => {
mockRoute.path = '/thread/42'
mockRoute.params = { id: '42' }
await mountAndWait()
expect(mockFetchThread).toHaveBeenCalledWith('42', false, false)
})
})
})

View File

@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import BBCodeEditor from './BBCodeEditor.vue'
// Uses global mocks for @nextcloud/l10n, NcNoteCard from test-setup.ts
vi.mock('@icons/Upload.vue', () => createIconMock('UploadIcon'))
vi.mock('@nextcloud/vue/components/NcRichContenteditable', () =>
createComponentMock('NcRichContenteditable', {
template:
'<div class="nc-rich-contenteditable"><div contenteditable="true" @input="$emit(\'update:modelValue\', $event.target?.textContent || \'\')"><slot /></div></div>',
props: ['modelValue', 'placeholder', 'disabled', 'autoComplete', 'userData', 'multiline'],
emits: ['update:modelValue', 'keydown'],
}),
)
vi.mock('@/components/BBCodeToolbar', () =>
createComponentMock('BBCodeToolbar', {
template: '<div class="bbcode-toolbar-mock" />',
props: ['textareaRef', 'modelValue', 'editorContext'],
emits: ['insert'],
}),
)
// Uses global mock for @/axios from test-setup.ts
import { ocs } from '@/axios'
const mockOcsGet = vi.mocked(ocs.get)
// Helper to call private methods on BBCodeEditor vm
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function callVm(vm: InstanceType<typeof BBCodeEditor>, method: string, ...args: unknown[]): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (vm as any)[method](...args)
}
describe('BBCodeEditor', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOcsGet.mockResolvedValue({ data: [] })
})
describe('rendering', () => {
it('should render the editor container', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
expect(wrapper.find('.bbcode-editor-container').exists()).toBe(true)
})
it('should render the BBCodeToolbar', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
expect(wrapper.find('.bbcode-toolbar-mock').exists()).toBe(true)
})
it('should not show attachment disclaimer when no attachment bbcode', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: 'Hello world' },
})
expect(wrapper.find('.attachment-disclaimer').exists()).toBe(false)
})
it('should show attachment disclaimer when attachment bbcode is present', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: 'Check this [attachment]file.pdf[/attachment]' },
})
expect(wrapper.find('.attachment-disclaimer').exists()).toBe(true)
})
it('should not show drag overlay by default', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
expect(wrapper.find('.drag-overlay').exists()).toBe(false)
})
})
describe('props', () => {
it('should accept modelValue prop', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: 'test content' },
})
expect(wrapper.props('modelValue')).toBe('test content')
})
it('should accept placeholder prop', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '', placeholder: 'Type here …' },
})
expect(wrapper.props('placeholder')).toBe('Type here …')
})
it('should accept disabled prop', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '', disabled: true },
})
expect(wrapper.props('disabled')).toBe(true)
})
it('should accept editorContext prop', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '', editorContext: 'thread' },
})
expect(wrapper.props('editorContext')).toBe('thread')
})
})
describe('events', () => {
it('should emit update:modelValue when input changes', async () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
await vi.dynamicImportSettled()
callVm(wrapper.vm, 'handleInput', 'new content')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['new content'])
})
it('should strip zero-width spaces from emitted value', async () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
await vi.dynamicImportSettled()
callVm(wrapper.vm, 'handleInput', 'content\u200B')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['content'])
})
it('should emit update:modelValue on BBCode toolbar insert', async () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
await vi.dynamicImportSettled()
callVm(wrapper.vm, 'handleBBCodeInsert', { text: '[b]bold[/b]', cursorPos: 10 })
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['[b]bold[/b]'])
})
})
describe('drag and drop', () => {
it('should show drag overlay on dragenter with files', async () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
await wrapper.find('.bbcode-editor-container').trigger('dragenter', {
dataTransfer: { types: ['Files'] },
})
expect(wrapper.find('.drag-overlay').exists()).toBe(true)
})
it('should hide drag overlay on dragleave', async () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
await wrapper.find('.bbcode-editor-container').trigger('dragenter', {
dataTransfer: { types: ['Files'] },
})
expect(wrapper.find('.drag-overlay').exists()).toBe(true)
await wrapper.find('.bbcode-editor-container').trigger('dragleave')
expect(wrapper.find('.drag-overlay').exists()).toBe(false)
})
it('should hide drag overlay on drop', async () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
await wrapper.find('.bbcode-editor-container').trigger('dragenter', {
dataTransfer: { types: ['Files'] },
})
await wrapper.find('.bbcode-editor-container').trigger('drop', {
dataTransfer: { files: [] },
})
expect(wrapper.find('.drag-overlay').exists()).toBe(false)
})
})
describe('mentions', () => {
it('should add cursor helper after mentions at end of content', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
const result = callVm(wrapper.vm, 'addCursorHelperAfterMentions', 'Hello @john')
expect(result).toBe('Hello @john\u200B')
})
it('should not add cursor helper when no mention at end', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
const result = callVm(wrapper.vm, 'addCursorHelperAfterMentions', 'Hello world')
expect(result).toBe('Hello world')
})
it('should handle quoted mentions at end of content', () => {
const wrapper = mount(BBCodeEditor, {
props: { modelValue: '' },
})
const result = callVm(wrapper.vm, 'addCursorHelperAfterMentions', 'Hello @"john doe"')
expect(result).toBe('Hello @"john doe"\u200B')
})
it('should parse mentions and fetch user data', async () => {
mockOcsGet.mockResolvedValue({
data: [{ id: 'john', label: 'John Doe', icon: '', source: 'users' }],
})
mount(BBCodeEditor, {
props: { modelValue: 'Hello @john' },
})
await vi.dynamicImportSettled()
expect(mockOcsGet).toHaveBeenCalledWith('/users/autocomplete', {
params: { search: 'john', limit: 1 },
})
})
})
})

View File

@@ -2,14 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import type { BBCode } from '@/types'
// Mock axios - must use factory that doesn't reference external variables
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Import after mock
// Uses global mock for @/axios from test-setup.ts
import { ocs } from '@/axios'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'

View File

@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import {
createIconMock,
createComponentMock,
createNcActionsMock,
createNcActionButtonMock,
} from '@/test-utils'
// Mock icons
vi.mock('@icons/FormatBold.vue', () => createIconMock('FormatBoldIcon'))
@@ -53,52 +58,14 @@ vi.mock('@/components/TemplateModal', () =>
vi.mock('@icons/TextBox.vue', () => createIconMock('TextBoxIcon'))
vi.mock('@icons/ArrowDown.vue', () => createIconMock('ArrowDownIcon'))
// Mock Nextcloud dialogs
vi.mock('@nextcloud/dialogs', () => ({
getFilePickerBuilder: vi.fn(() => ({
setMultiSelect: vi.fn().mockReturnThis(),
setType: vi.fn().mockReturnThis(),
build: vi.fn(() => ({
pick: vi.fn(),
})),
})),
FilePickerType: { TYPE_FILE: 1 },
}))
// Uses global mocks for @/axios and @nextcloud/dialogs from test-setup.ts
// Mock Nextcloud auth
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: vi.fn(() => ({ uid: 'testuser', displayName: 'Test User' })),
}))
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
webDav: {
put: vi.fn(),
request: vi.fn(),
},
}))
// Mock NcActions and NcActionButton since they're complex
vi.mock('@nextcloud/vue/components/NcActions', () => ({
default: {
name: 'NcActions',
template: '<div class="nc-actions-mock"><slot /><slot name="icon" /></div>',
props: ['ariaLabel'],
},
}))
vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
default: {
name: 'NcActionButton',
template:
'<button class="nc-action-button-mock" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: [],
emits: ['click'],
},
}))
vi.mock('@nextcloud/vue/components/NcActions', () => createNcActionsMock())
vi.mock('@nextcloud/vue/components/NcActionButton', () => createNcActionButtonMock())
vi.mock('@nextcloud/vue/components/NcProgressBar', () => ({
default: {
@@ -160,7 +127,7 @@ describe('BBCodeToolbar', () => {
it('renders attachment actions', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-actions-mock').exists()).toBe(true)
expect(wrapper.find('.nc-actions').exists()).toBe(true)
})
})
@@ -168,7 +135,7 @@ describe('BBCodeToolbar', () => {
it('does not render overflow menu when all buttons fit', () => {
const wrapper = createWrapper()
// Default visibleCount is 18 (all buttons), so no overflow
const actionsElements = wrapper.findAll('.nc-actions-mock')
const actionsElements = wrapper.findAll('.nc-actions')
// Only the attachment NcActions should exist, not an overflow one
expect(actionsElements.length).toBe(1)
})
@@ -179,7 +146,7 @@ describe('BBCodeToolbar', () => {
vm.visibleCount = 5
await flushPromises()
const actionsElements = wrapper.findAll('.nc-actions-mock')
const actionsElements = wrapper.findAll('.nc-actions')
// Should have 2: overflow menu + attachment menu
expect(actionsElements.length).toBe(2)
})
@@ -196,7 +163,7 @@ describe('BBCodeToolbar', () => {
await flushPromises()
// Find the overflow action buttons (they are nc-action-button-mock inside the overflow NcActions)
const overflowActionButtons = wrapper.findAll('.nc-action-button-mock')
const overflowActionButtons = wrapper.findAll('.nc-action-button')
// The first 2 are attachment menu buttons (pick file, upload file)
// The rest are overflow bbcode buttons
const firstOverflowButton = overflowActionButtons[2]

View File

@@ -1,9 +1,16 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { RouterLinkStub } from '@/test-utils'
import CategoryCard from './CategoryCard.vue'
import { createMockCategory } from '@/test-mocks'
// Uses global mock for @nextcloud/l10n from test-setup.ts
// Uses global mocks from test-setup.ts
const globalStubs = {
global: {
stubs: { 'router-link': RouterLinkStub },
},
}
describe('CategoryCard', () => {
describe('rendering', () => {
@@ -11,6 +18,7 @@ describe('CategoryCard', () => {
const category = createMockCategory({ name: 'General Discussion' })
const wrapper = mount(CategoryCard, {
props: { category },
...globalStubs,
})
expect(wrapper.find('.category-name').text()).toBe('General Discussion')
})
@@ -19,6 +27,7 @@ describe('CategoryCard', () => {
const category = createMockCategory({ description: 'Talk about anything' })
const wrapper = mount(CategoryCard, {
props: { category },
...globalStubs,
})
expect(wrapper.find('.category-description').text()).toBe('Talk about anything')
})
@@ -27,6 +36,7 @@ describe('CategoryCard', () => {
const category = createMockCategory({ description: null })
const wrapper = mount(CategoryCard, {
props: { category },
...globalStubs,
})
expect(wrapper.find('.category-description').text()).toBe('No description available')
expect(wrapper.find('.category-description').classes()).toContain('muted')
@@ -38,6 +48,7 @@ describe('CategoryCard', () => {
const category = createMockCategory({ threadCount: 25 })
const wrapper = mount(CategoryCard, {
props: { category },
...globalStubs,
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('25')
@@ -47,6 +58,7 @@ describe('CategoryCard', () => {
const category = createMockCategory({ postCount: 150 })
const wrapper = mount(CategoryCard, {
props: { category },
...globalStubs,
})
const stats = wrapper.findAll('.stat-value')
expect(stats[1]!.text()).toBe('150')
@@ -56,6 +68,7 @@ describe('CategoryCard', () => {
const category = createMockCategory({ threadCount: 0, postCount: 0 })
const wrapper = mount(CategoryCard, {
props: { category },
...globalStubs,
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('0')
@@ -70,6 +83,7 @@ describe('CategoryCard', () => {
category.postCount = undefined
const wrapper = mount(CategoryCard, {
props: { category },
...globalStubs,
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('0')
@@ -81,6 +95,7 @@ describe('CategoryCard', () => {
it('should not render children section when no children', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
...globalStubs,
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
@@ -88,6 +103,7 @@ describe('CategoryCard', () => {
it('should not render children section when children is empty', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children: [] },
...globalStubs,
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
@@ -99,14 +115,7 @@ describe('CategoryCard', () => {
]
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children },
global: {
stubs: {
'router-link': {
template: '<a class="child-link"><slot /></a>',
props: ['to'],
},
},
},
...globalStubs,
})
expect(wrapper.find('.category-children').exists()).toBe(true)
const links = wrapper.findAll('.child-link')
@@ -119,6 +128,7 @@ describe('CategoryCard', () => {
const children = [createMockCategory({ id: 2, name: 'Child 1', slug: 'child-1' })]
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children, hideChildren: true },
...globalStubs,
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
@@ -128,6 +138,7 @@ describe('CategoryCard', () => {
it('should have correct class', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
...globalStubs,
})
expect(wrapper.find('.category-card').exists()).toBe(true)
})
@@ -135,6 +146,7 @@ describe('CategoryCard', () => {
it('should have header with name and stats', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
...globalStubs,
})
expect(wrapper.find('.category-header').exists()).toBe(true)
expect(wrapper.find('.category-name').exists()).toBe(true)

View File

@@ -1,18 +1,11 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createNcCheckboxRadioSwitchMock } from '@/test-utils'
import CategoryPermissionsTable from './CategoryPermissionsTable.vue'
import type { CategoryPermission } from './CategoryPermissionsTable.vue'
import type { CategoryHeader } from '@/types'
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template:
'<label class="nc-checkbox" :class="{ disabled }" @click="!disabled && $emit(\'update:model-value\', !modelValue)"><input type="checkbox" :checked="modelValue" :disabled="disabled" /><slot /></label>',
props: ['modelValue', 'disabled', 'indeterminate'],
emits: ['update:model-value'],
},
}))
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => createNcCheckboxRadioSwitchMock())
type VM = InstanceType<typeof CategoryPermissionsTable> & {
toggleHeaderView: (id: number) => void

View File

@@ -2,18 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
},
}))
vi.mock('@nextcloud/dialogs', () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
}))
// Uses global mocks for @/axios and @nextcloud/dialogs from test-setup.ts
// Mock NcAvatar
vi.mock('@nextcloud/vue/components/NcAvatar', () =>
@@ -23,7 +12,6 @@ vi.mock('@nextcloud/vue/components/NcAvatar', () =>
}),
)
// Import after mocks
import { ocs } from '@/axios'
import { showSuccess } from '@nextcloud/dialogs'
import GuestReassignDialog from './GuestReassignDialog.vue'

View File

@@ -2,15 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import type { CatHeader } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
post: vi.fn(),
put: vi.fn(),
},
}))
// Import after mocks
// Uses global mock for @/axios from test-setup.ts
import { ocs } from '@/axios'
import HeaderEditDialog from './HeaderEditDialog.vue'

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createCurrentUserMock } from '@/test-utils'
import InitializationScreen from './InitializationScreen.vue'
// Uses global mocks for @nextcloud/l10n, NcEmptyContent, NcButton, NcSelect, NcNoteCard, NcLoadingIcon from test-setup.ts
vi.mock('@icons/Cog.vue', () => createIconMock('CogIcon'))
// Uses global mock for @/axios from test-setup.ts
import { ocs } from '@/axios'
const mockOcsGet = vi.mocked(ocs.get)
const mockOcsPost = vi.mocked(ocs.post)
const { mockGetCurrentUser } = createCurrentUserMock()
vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => mockGetCurrentUser() }))
describe('InitializationScreen', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOcsGet.mockResolvedValue({ data: [] })
mockOcsPost.mockResolvedValue({})
})
describe('admin view', () => {
beforeEach(() => {
mockGetCurrentUser.mockReturnValue({ uid: 'admin', isAdmin: true })
})
it('should render admin view when user is admin', () => {
const wrapper = mount(InitializationScreen)
expect(wrapper.find('.nc-empty-content').exists()).toBe(true)
// NcEmptyContent mock uses "title" prop but component passes "name" prop
// Check that the init form is rendered for admin users
expect(wrapper.find('.init-form').exists()).toBe(true)
})
it('should show description for admin', () => {
const wrapper = mount(InitializationScreen)
expect(wrapper.find('.nc-empty-content .description').text()).toBe(
'Select the accounts that should have the forum admin role.',
)
})
it('should render select and initialize button', () => {
const wrapper = mount(InitializationScreen)
expect(wrapper.find('.nc-select').exists()).toBe(true)
expect(wrapper.find('button').exists()).toBe(true)
})
it('should show initialize button text', () => {
const wrapper = mount(InitializationScreen)
const buttons = wrapper.findAll('button')
const initButton = buttons.find((b) => b.text().includes('Initialize forum'))
expect(initButton).toBeDefined()
})
it('should disable initialize button when no users selected', () => {
const wrapper = mount(InitializationScreen)
const buttons = wrapper.findAll('button')
const initButton = buttons.find((b) => b.text().includes('Initialize forum'))
expect(initButton?.attributes('disabled')).toBeDefined()
})
it('should fetch admin users on mount', () => {
mount(InitializationScreen)
expect(mockOcsGet).toHaveBeenCalledWith('/init/admin-users')
})
it('should show info note card', () => {
const wrapper = mount(InitializationScreen)
const noteCard = wrapper.find('.nc-note-card[data-type="info"]')
expect(noteCard.exists()).toBe(true)
expect(noteCard.text()).toBe('All other accounts will receive the default role.')
})
it('should show error note card when initialization fails', async () => {
mockOcsGet.mockResolvedValue({
data: [{ id: 'admin', displayName: 'Admin' }],
})
mockOcsPost.mockRejectedValue({ message: 'Something went wrong' })
const wrapper = mount(InitializationScreen)
await vi.dynamicImportSettled()
// Trigger initialization
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).runInitialization()
await wrapper.vm.$nextTick()
const errorCard = wrapper.find('.nc-note-card[data-type="error"]')
expect(errorCard.exists()).toBe(true)
})
it('should emit initialized event on successful initialization', async () => {
mockOcsGet.mockResolvedValue({
data: [{ id: 'admin', displayName: 'Admin' }],
})
mockOcsPost.mockResolvedValue({})
const wrapper = mount(InitializationScreen)
await vi.dynamicImportSettled()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).runInitialization()
expect(wrapper.emitted('initialized')).toBeTruthy()
})
it('should call post with selected user IDs on initialization', async () => {
mockOcsGet.mockResolvedValue({
data: [{ id: 'admin', displayName: 'Admin' }],
})
mockOcsPost.mockResolvedValue({})
const wrapper = mount(InitializationScreen)
await vi.dynamicImportSettled()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).runInitialization()
expect(mockOcsPost).toHaveBeenCalledWith('/init/initialize', {
adminUserIds: ['admin'],
})
})
})
describe('non-admin view', () => {
beforeEach(() => {
mockGetCurrentUser.mockReturnValue({ uid: 'user', isAdmin: false })
})
it('should render non-admin view when user is not admin', () => {
const wrapper = mount(InitializationScreen)
expect(wrapper.find('.nc-empty-content').exists()).toBe(true)
// Non-admin view should not show the init form
expect(wrapper.find('.init-form').exists()).toBe(false)
})
it('should show non-admin description', () => {
const wrapper = mount(InitializationScreen)
expect(wrapper.find('.nc-empty-content .description').text()).toBe(
'The forum has not been set up yet. Please contact an administration member to complete the setup.',
)
})
it('should not show select or initialize button', () => {
const wrapper = mount(InitializationScreen)
expect(wrapper.find('.nc-select').exists()).toBe(false)
expect(wrapper.find('.init-form').exists()).toBe(false)
})
it('should not fetch admin users', () => {
mount(InitializationScreen)
expect(mockOcsGet).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock, RouterLinkStub } from '@/test-utils'
import { createMockThread, createMockPost } from '@/test-mocks'
import ModerationDeletedList from './ModerationDeletedList.vue'
// Uses global mocks for @nextcloud/l10n, NcButton, NcEmptyContent, NcLoadingIcon, NcDateTime from test-setup.ts
vi.mock('@icons/DeleteRestore.vue', () => createIconMock('DeleteRestoreIcon'))
vi.mock('@/components/ThreadCard', () =>
createComponentMock('ThreadCard', {
template: '<div class="thread-card-mock" :data-id="thread.id" />',
props: ['thread'],
}),
)
vi.mock('@/components/PostCard', () =>
createComponentMock('PostCard', {
template: '<div class="post-card-mock" :data-id="post.id" />',
props: ['post'],
}),
)
vi.mock('@/components/Pagination', () =>
createComponentMock('Pagination', {
template: '<div class="pagination-mock" @click="$emit(\'update:currentPage\', 2)" />',
props: ['currentPage', 'maxPages'],
emits: ['update:currentPage'],
}),
)
const defaultProps = {
mode: 'threads' as const,
items: [],
total: 0,
page: 1,
perPage: 20,
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mountWith(props: Record<string, any>) {
return mount(ModerationDeletedList, {
props,
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
}
// Helper to create thread/post items with deletedAt (not in base types)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mockThread(overrides: Record<string, unknown> = {}): any {
return createMockThread(overrides as Parameters<typeof createMockThread>[0])
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mockPost(overrides: Record<string, unknown> = {}): any {
return createMockPost(overrides as Parameters<typeof createMockPost>[0])
}
describe('ModerationDeletedList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('loading state', () => {
it('should show loading icon when loading', () => {
const wrapper = mountWith({ ...defaultProps, loading: true })
expect(wrapper.find('.nc-loading-icon').exists()).toBe(true)
})
it('should not show items when loading', () => {
const wrapper = mountWith({ ...defaultProps, loading: true })
expect(wrapper.find('.item-list').exists()).toBe(false)
})
})
describe('error state', () => {
it('should show error content when error is set', () => {
const wrapper = mountWith({ ...defaultProps, error: 'Something went wrong' })
expect(wrapper.find('.nc-empty-content').exists()).toBe(true)
expect(wrapper.find('.title').text()).toBe('Error loading content')
})
it('should show retry button on error', () => {
const wrapper = mountWith({ ...defaultProps, error: 'Something went wrong' })
const retryButton = wrapper.findAll('button').find((b) => b.text().includes('Retry'))
expect(retryButton).toBeDefined()
})
it('should emit retry when retry button is clicked', async () => {
const wrapper = mountWith({ ...defaultProps, error: 'Something went wrong' })
const retryButton = wrapper.findAll('button').find((b) => b.text().includes('Retry'))
await retryButton?.trigger('click')
expect(wrapper.emitted('retry')).toBeTruthy()
})
})
describe('empty state', () => {
it('should show empty content when no items', () => {
const wrapper = mountWith({ ...defaultProps, items: [] })
expect(wrapper.find('.nc-empty-content').exists()).toBe(true)
expect(wrapper.find('.title').text()).toBe('No deleted content')
})
})
describe('threads mode', () => {
it('should render ThreadCard for each item in threads mode', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 }), mockThread({ id: 2, deletedAt: 2000 })]
const wrapper = mountWith({ ...defaultProps, mode: 'threads', items, total: 2 })
expect(wrapper.findAll('.thread-card-mock')).toHaveLength(2)
})
it('should make items clickable in threads mode', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, mode: 'threads', items, total: 1 })
expect(wrapper.find('.deleted-item-wrapper.clickable').exists()).toBe(true)
})
it('should emit view when thread item is clicked', async () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, mode: 'threads', items, total: 1 })
await wrapper.find('.deleted-item-wrapper').trigger('click')
expect(wrapper.emitted('view')).toBeTruthy()
expect(wrapper.emitted('view')![0]).toEqual([items[0]])
})
})
describe('replies mode', () => {
it('should render PostCard for each item in replies mode', () => {
const items = [mockPost({ id: 1, deletedAt: 1000 }), mockPost({ id: 2, deletedAt: 2000 })]
const wrapper = mountWith({ ...defaultProps, mode: 'replies', items, total: 2 })
expect(wrapper.findAll('.post-card-mock')).toHaveLength(2)
})
it('should not make items clickable in replies mode', () => {
const items = [mockPost({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, mode: 'replies', items, total: 1 })
expect(wrapper.find('.deleted-item-wrapper.clickable').exists()).toBe(false)
})
it('should show thread link for replies with threadSlug', () => {
const items = [
mockPost({
id: 1,
deletedAt: 1000,
threadSlug: 'test-thread',
threadTitle: 'Test Thread',
}),
]
const wrapper = mountWith({ ...defaultProps, mode: 'replies', items, total: 1 })
const link = wrapper.find('.router-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBe('/t/test-thread')
})
})
describe('restore action', () => {
it('should show restore button for each item', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 1 })
const restoreButton = wrapper.findAll('button').find((b) => b.text().includes('Restore'))
expect(restoreButton).toBeDefined()
})
it('should render restore icon for each item', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 1 })
const restoreButtons = wrapper.findAll('button').filter((b) => b.text().includes('Restore'))
expect(restoreButtons.length).toBe(1)
expect(wrapper.find('.delete-restore-icon').exists()).toBe(true)
})
it('should disable restore button when restoring that item', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 1, restoring: 1 })
const restoreButton = wrapper.findAll('button').find((b) => b.text().includes('Restore'))
expect(restoreButton?.attributes('disabled')).toBeDefined()
})
it('should show loading icon when restoring that item', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 1, restoring: 1 })
expect(wrapper.find('.nc-loading-icon').exists()).toBe(true)
})
})
describe('pagination', () => {
it('should show pagination when maxPages > 1', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 40, perPage: 20 })
expect(wrapper.find('.pagination-mock').exists()).toBe(true)
})
it('should not show pagination when maxPages is 1', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 10, perPage: 20 })
expect(wrapper.find('.pagination-mock').exists()).toBe(false)
})
it('should emit update:page when pagination changes', async () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 40, perPage: 20 })
await wrapper.find('.pagination-mock').trigger('click')
expect(wrapper.emitted('update:page')).toBeTruthy()
})
})
describe('deleted badge', () => {
it('should show deleted badge with timestamp', () => {
const items = [mockThread({ id: 1, deletedAt: 1000 })]
const wrapper = mountWith({ ...defaultProps, items, total: 1 })
expect(wrapper.find('.deleted-badge').exists()).toBe(true)
expect(wrapper.find('.deleted-badge').text()).toContain('Deleted')
})
})
})

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import ModerationReplyDialog from './ModerationReplyDialog.vue'
// Uses global mocks for @nextcloud/l10n, NcButton, NcDialog, NcLoadingIcon from test-setup.ts
vi.mock('@icons/DeleteRestore.vue', () => createIconMock('DeleteRestoreIcon'))
vi.mock('@/components/PostCard', () =>
createComponentMock('PostCard', {
template: '<div class="post-card-mock" :data-id="post.id" />',
props: ['post'],
}),
)
// Uses global mock for @/axios from test-setup.ts
import { ocs } from '@/axios'
const mockOcsGet = vi.mocked(ocs.get)
describe('ModerationReplyDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOcsGet.mockResolvedValue({
data: {
id: 1,
content: '<p>Test reply</p>',
threadTitle: 'Test Thread',
},
})
})
describe('rendering', () => {
it('should not render dialog content when closed', () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: false },
})
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('should render dialog when open', () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: true, replyId: 1 },
})
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('should show loading icon while loading reply', async () => {
// Make the API call hang
mockOcsGet.mockReturnValue(new Promise(() => {}))
const wrapper = mount(ModerationReplyDialog, {
props: { open: false, replyId: 1 },
})
// Open the dialog to trigger load
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
expect(wrapper.find('.nc-loading-icon').exists()).toBe(true)
})
it('should show PostCard after reply is loaded', async () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: false, replyId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
expect(wrapper.find('.post-card-mock').exists()).toBe(true)
})
it('should show thread context when reply has threadTitle', async () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: false, replyId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
expect(wrapper.find('.reply-dialog__context').exists()).toBe(true)
expect(wrapper.find('.reply-dialog__context').text()).toContain('Test Thread')
})
it('should show restore reply button', () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: true, replyId: 1 },
})
const restoreButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Restore reply'))
expect(restoreButton).toBeDefined()
})
})
describe('API calls', () => {
it('should fetch reply when dialog opens', async () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: false, replyId: 42 },
})
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
expect(mockOcsGet).toHaveBeenCalledWith('/moderation/replies/42')
})
it('should not fetch when replyId is null', async () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: false, replyId: null },
})
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
expect(mockOcsGet).not.toHaveBeenCalled()
})
it('should clear reply when dialog closes', async () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: true, replyId: 1 },
})
await vi.dynamicImportSettled()
await wrapper.setProps({ open: false })
await wrapper.vm.$nextTick()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any
expect(vm.reply).toBeNull()
})
})
describe('events', () => {
it('should emit update:open when dialog is closed', async () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: true, replyId: 1 },
})
// NcDialog mock emits update:open
const dialog = wrapper.find('.nc-dialog')
expect(dialog.exists()).toBe(true)
// Verify the emit is wired up
expect(wrapper.props('open')).toBe(true)
})
it('should emit restore when restore button is clicked', async () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: true, replyId: 1 },
})
const restoreButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Restore reply'))
await restoreButton?.trigger('click')
expect(wrapper.emitted('restore')).toBeTruthy()
})
it('should disable restore button when restoring', () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: true, replyId: 1, restoring: true },
})
const restoreButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Restore reply'))
expect(restoreButton?.attributes('disabled')).toBeDefined()
})
it('should show loading icon in restore button when restoring', () => {
const wrapper = mount(ModerationReplyDialog, {
props: { open: true, replyId: 1, restoring: true },
})
// There should be a loading icon in the actions area
const buttons = wrapper.findAll('button')
const restoreButton = buttons.find((b) => b.text().includes('Restore reply'))
expect(restoreButton?.find('.nc-loading-icon').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,309 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import ModerationThreadDialog from './ModerationThreadDialog.vue'
// Uses global mocks for @nextcloud/l10n, NcButton, NcDialog, NcLoadingIcon from test-setup.ts
vi.mock('@icons/DeleteRestore.vue', () => createIconMock('DeleteRestoreIcon'))
vi.mock('@/components/PostCard', () =>
createComponentMock('PostCard', {
template: '<div class="post-card-mock" :data-id="post.id" :data-first="isFirstPost" />',
props: ['post', 'isFirstPost'],
}),
)
vi.mock('@/components/Pagination', () =>
createComponentMock('Pagination', {
template: '<div class="pagination-mock" />',
props: ['currentPage', 'maxPages'],
emits: ['update:currentPage'],
}),
)
// Uses global mock for @/axios from test-setup.ts
import { ocs } from '@/axios'
const mockOcsGet = vi.mocked(ocs.get)
const createThreadResponse = (overrides: Record<string, unknown> = {}) => ({
data: {
title: 'Test Thread',
posts: [
{ id: 1, isFirstPost: true, content: '<p>First post</p>', deletedAt: null },
{ id: 2, isFirstPost: false, content: '<p>Reply 1</p>', deletedAt: null },
{ id: 3, isFirstPost: false, content: '<p>Reply 2</p>', deletedAt: null },
],
totalPosts: 3,
...overrides,
},
})
describe('ModerationThreadDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOcsGet.mockResolvedValue(createThreadResponse())
})
describe('rendering', () => {
it('should not render dialog content when closed', () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: false },
})
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('should render dialog when open', () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: true, threadId: 1 },
})
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('should show loading icon while loading thread', async () => {
mockOcsGet.mockReturnValue(new Promise(() => {}))
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
expect(wrapper.find('.nc-loading-icon').exists()).toBe(true)
})
it('should show posts after thread is loaded', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.post-card-mock').length).toBeGreaterThan(0)
})
it('should render first post separately', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
const firstPost = wrapper
.findAll('.post-card-mock')
.find((el) => el.attributes('data-first') === 'true')
expect(firstPost).toBeDefined()
})
it('should show restore thread button', () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: true, threadId: 1 },
})
const restoreButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Restore thread'))
expect(restoreButton).toBeDefined()
})
})
describe('title', () => {
it('should use thread title from response', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any
expect(vm.title).toBe('Test Thread')
})
it('should fall back to threadTitle prop', () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: true, threadId: 1, threadTitle: 'Fallback Title' },
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any
// Before thread is loaded, should use the prop
expect(vm.title).toBe('Fallback Title')
})
})
describe('API calls', () => {
it('should fetch thread when dialog opens', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 42 },
})
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
expect(mockOcsGet).toHaveBeenCalledWith('/moderation/threads/42', {
params: { postLimit: 21, postOffset: 0 },
})
})
it('should not fetch when threadId is null', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: null },
})
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
expect(mockOcsGet).not.toHaveBeenCalled()
})
it('should clear state when dialog closes', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: true, threadId: 1 },
})
await vi.dynamicImportSettled()
await wrapper.setProps({ open: false })
await wrapper.vm.$nextTick()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any
expect(vm.thread).toBeNull()
expect(vm.firstPost).toBeNull()
expect(vm.replies).toEqual([])
})
})
describe('pagination', () => {
it('should show pagination when there are multiple pages of replies', async () => {
mockOcsGet.mockResolvedValue(
createThreadResponse({
totalPosts: 50,
}),
)
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
expect(wrapper.find('.pagination-mock').exists()).toBe(true)
})
it('should not show pagination when replies fit in one page', async () => {
mockOcsGet.mockResolvedValue(createThreadResponse({ totalPosts: 3 }))
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
expect(wrapper.find('.pagination-mock').exists()).toBe(false)
})
it('should compute replyMaxPages correctly', async () => {
mockOcsGet.mockResolvedValue(createThreadResponse({ totalPosts: 41 }))
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any
// totalReplies = 41 - 1 = 40, maxPages = ceil(40/20) = 2
expect(vm.replyMaxPages).toBe(2)
})
it('should reset page to 1 when dialog reopens', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any
vm.replyPage = 3
await wrapper.setProps({ open: false })
await wrapper.vm.$nextTick()
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
expect(vm.replyPage).toBe(1)
})
})
describe('events', () => {
it('should emit restore when restore button is clicked', async () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: true, threadId: 1 },
})
const restoreButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Restore thread'))
await restoreButton?.trigger('click')
expect(wrapper.emitted('restore')).toBeTruthy()
})
it('should disable restore button when restoring', () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: true, threadId: 1, restoring: true },
})
const restoreButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Restore thread'))
expect(restoreButton?.attributes('disabled')).toBeDefined()
})
it('should show loading icon in restore button when restoring', () => {
const wrapper = mount(ModerationThreadDialog, {
props: { open: true, threadId: 1, restoring: true },
})
const restoreButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Restore thread'))
expect(restoreButton?.find('.nc-loading-icon').exists()).toBe(true)
})
})
describe('deleted posts styling', () => {
it('should apply deleted-post class to posts with deletedAt', async () => {
mockOcsGet.mockResolvedValue(
createThreadResponse({
posts: [
{ id: 1, isFirstPost: true, content: '<p>First</p>', deletedAt: 1000 },
{ id: 2, isFirstPost: false, content: '<p>Reply</p>', deletedAt: null },
],
}),
)
const wrapper = mount(ModerationThreadDialog, {
props: { open: false, threadId: 1 },
})
await wrapper.setProps({ open: true })
await vi.dynamicImportSettled()
await wrapper.vm.$nextTick()
expect(wrapper.find('.deleted-post').exists()).toBe(true)
})
})
})

View File

@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import {
createIconMock,
createComponentMock,
createNcActionsMock,
createNcActionButtonMock,
createCurrentUserMock,
} from '@/test-utils'
import { createMockPost, createMockRole, createMockUser } from '@/test-mocks'
import { useUserRole } from '@/composables/useUserRole'
import PostCard from './PostCard.vue'
@@ -50,33 +56,14 @@ vi.mock('@/components/GuestReassignDialog', () =>
}),
)
vi.mock('@nextcloud/dialogs', () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
}))
// Uses global mock for @nextcloud/dialogs from test-setup.ts
// Mock NcActions and NcActionButton
vi.mock('@nextcloud/vue/components/NcActions', () =>
createComponentMock('NcActions', {
template: '<div class="nc-actions-mock"><slot /></div>',
props: [],
}),
)
vi.mock('@nextcloud/vue/components/NcActionButton', () =>
createComponentMock('NcActionButton', {
template:
'<button class="nc-action-button" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: [],
emits: ['click'],
}),
)
vi.mock('@nextcloud/vue/components/NcActions', () => createNcActionsMock())
vi.mock('@nextcloud/vue/components/NcActionButton', () => createNcActionButtonMock())
// Mock getCurrentUser
const mockCurrentUser = vi.fn()
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => mockCurrentUser(),
}))
const { mockGetCurrentUser: mockCurrentUser } = createCurrentUserMock()
vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => mockCurrentUser() }))
describe('PostCard', () => {
beforeEach(() => {

View File

@@ -3,12 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import type { PostHistoryResponse, Post, PostHistoryEntry, User } from '@/types'
// Mock axios - must use factory that doesn't reference external variables
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Mock icons
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
@@ -21,7 +16,6 @@ vi.mock('@/components/UserInfo', () =>
}),
)
// Import after mocks
import { ocs } from '@/axios'
import PostHistoryDialog from './PostHistoryDialog.vue'
@@ -245,7 +239,7 @@ describe('PostHistoryDialog', () => {
describe('API calls', () => {
it('fetches history when dialog opens', async () => {
const wrapper = createWrapper({ open: true, postId: 42 })
createWrapper({ open: true, postId: 42 })
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/posts/42/history')

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock } from '@/test-utils'
import { createIconMock, RouterLinkStub } from '@/test-utils'
import { createMockPost, createMockUser } from '@/test-mocks'
import SearchPostResult from './SearchPostResult.vue'
@@ -26,12 +26,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: {
'router-link': {
template: '<a class="thread-link"><slot /></a>',
props: ['to'],
},
},
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -43,7 +38,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -57,7 +52,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -70,7 +65,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -82,7 +77,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -95,7 +90,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -109,7 +104,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'xyz' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -124,7 +119,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'xyz' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -138,7 +133,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -152,7 +147,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})
@@ -165,7 +160,7 @@ describe('SearchPostResult', () => {
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
stubs: { RouterLink: RouterLinkStub },
mocks: { $router: { push: mockPush } },
},
})

View File

@@ -63,7 +63,6 @@ describe('Skeleton', () => {
const wrapper = mount(Skeleton, {
props: { radius: '10px' },
})
const vm = wrapper.vm as unknown as { getBorderRadius: () => string; shape: string }
// Test via computed style since method is internal
expect(wrapper.find('.skeleton').attributes('style')).toContain('border-radius: 10px')

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createIconMock } from '@/test-utils'
import { createMockTemplate } from '@/test-mocks'
import type { Template } from '@/types'
@@ -35,17 +35,7 @@ vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
},
}))
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
// Import after mocks
// Uses global mock for @/axios from test-setup.ts
import { ocs } from '@/axios'
import TemplateModal from './TemplateModal.vue'

View File

@@ -2,12 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createMockCategory } from '@/test-mocks'
import type { CategoryHeader } from '@/types'
// Mock the axios module before importing the composable
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
import { useCategories } from '../useCategories'
import { ocs } from '@/axios'

View File

@@ -135,3 +135,30 @@ vi.mock('@nextcloud/vue/components/NcNoteCard', () => ({
props: ['type'],
},
}))
// Mock @/axios globally — covers ocs (REST) and webDav (file ops)
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
webDav: {
put: vi.fn(),
request: vi.fn(),
},
}))
// Mock @nextcloud/dialogs globally
vi.mock('@nextcloud/dialogs', () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showWarning: vi.fn(),
getFilePickerBuilder: vi.fn(() => ({
setMultiSelect: vi.fn().mockReturnThis(),
setType: vi.fn().mockReturnThis(),
build: vi.fn(() => ({ pick: vi.fn() })),
})),
FilePickerType: { TYPE_FILE: 1 },
}))

View File

@@ -8,6 +8,26 @@
*
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
* vi.mock('@/components/UserInfo', () => createComponentMock('UserInfo', { ... }))
*
* ## Categories
*
* - **Icon mocks**: `createIconMock()`
* - **Component mocks**: `createComponentMock()`, `createNcActionsMock()`,
* `createNcActionButtonMock()`, `createNcCheckboxRadioSwitchMock()`
* - **Router stubs**: `RouterLinkStub`
* - **Module mocks**: `createCurrentUserMock()`
*
* ## Global mocks (test-setup.ts)
*
* The following modules are globally mocked in test-setup.ts and do NOT need
* per-file `vi.mock()` calls:
*
* - `@nextcloud/l10n` (t, n)
* - `@nextcloud/router` (generateUrl)
* - `@nextcloud/vue/functions/isDarkTheme`
* - `@nextcloud/vue/components/*` (NcButton, NcDialog, NcEmptyContent, etc.)
* - `@/axios` (ocs.get/post/put/delete, webDav.put/request)
* - `@nextcloud/dialogs` (showSuccess, showError, showWarning)
*/
/**
@@ -59,3 +79,103 @@ export function createComponentMock(
},
}
}
// ============================================================================
// Router stubs
// ============================================================================
/**
* Stub for `<router-link>` that renders as a plain `<a>` tag.
*
* Pass via mount options: `global: { stubs: { RouterLink: RouterLinkStub } }`
* or `global: { stubs: { 'router-link': RouterLinkStub } }`
*
* @example
* const wrapper = mount(MyComponent, {
* global: { stubs: { RouterLink: RouterLinkStub } },
* })
* expect(wrapper.find('.router-link').exists()).toBe(true)
*/
export const RouterLinkStub = {
name: 'RouterLink',
template: '<a class="router-link" :href="to"><slot /></a>',
props: ['to'],
}
// ============================================================================
// Nextcloud component mocks
// ============================================================================
/**
* Mock for `NcActions` — renders a wrapper div with slots.
*
* @example
* vi.mock('@nextcloud/vue/components/NcActions', () => createNcActionsMock())
*/
export function createNcActionsMock() {
return {
default: {
name: 'NcActions',
template: '<div class="nc-actions"><slot /><slot name="icon" /></div>',
props: ['ariaLabel'],
},
}
}
/**
* Mock for `NcActionButton` — renders a clickable button with slots.
*
* @example
* vi.mock('@nextcloud/vue/components/NcActionButton', () => createNcActionButtonMock())
*/
export function createNcActionButtonMock() {
return {
default: {
name: 'NcActionButton',
template:
'<button class="nc-action-button" :aria-label="ariaLabel" :title="title" @click="$emit(\'click\', $event)"><slot /><slot name="icon" /></button>',
props: ['ariaLabel', 'title'],
emits: ['click'],
},
}
}
/**
* Mock for `NcCheckboxRadioSwitch` in checkbox mode.
*
* @example
* vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => createNcCheckboxRadioSwitchMock())
*/
export function createNcCheckboxRadioSwitchMock() {
return {
default: {
name: 'NcCheckboxRadioSwitch',
template:
'<label class="nc-checkbox" :class="{ disabled }" @click="!disabled && $emit(\'update:model-value\', !modelValue)"><input type="checkbox" :checked="modelValue" :disabled="disabled" /><slot /></label>',
props: ['modelValue', 'disabled', 'indeterminate'],
emits: ['update:model-value'],
},
}
}
// ============================================================================
// Module mock factories
// ============================================================================
/**
* Mock for `@nextcloud/auth` with a controllable `getCurrentUser` mock.
*
* Returns the mock factory (for `vi.mock`) and the underlying `vi.fn()` so
* tests can change the return value between assertions.
*
* @example
* const { mockGetCurrentUser } = createCurrentUserMock()
* vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => mockGetCurrentUser() }))
*
* // In beforeEach:
* mockGetCurrentUser.mockReturnValue({ uid: 'admin', displayName: 'Admin', isAdmin: true })
*/
export function createCurrentUserMock() {
const mockGetCurrentUser = vi.fn()
return { mockGetCurrentUser }
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { describe, it, expect } from 'vitest'
import {
getSelectedText,
applyBBCodeTemplate,

View File

@@ -1,18 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory, createMockRole } from '@/test-mocks'
import type { Category, CategoryHeader, Role } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Reactive state for categories
const mockCategoryHeaders = ref<CategoryHeader[]>([])

View File

@@ -5,14 +5,7 @@ import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory } from '@/test-mocks'
import type { Category, CategoryHeader } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Reactive state for categoryHeaders so tests can control it
const mockCategoryHeaders = ref<CategoryHeader[]>([])

View File

@@ -4,12 +4,7 @@ import { computed } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockThread } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Mock useCurrentUser composable
vi.mock('@/composables/useCurrentUser', () => ({

View File

@@ -4,13 +4,7 @@ import { computed } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory, createMockThread } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Mock useCurrentUser composable
vi.mock('@/composables/useCurrentUser', () => ({

View File

@@ -3,14 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Mock usePermissions composable
const mockCheckCategoryPermission = vi.fn()
@@ -51,11 +44,7 @@ vi.mock('@/components/ThreadCreateForm', () =>
}),
)
// Mock dialogs
vi.mock('@nextcloud/dialogs', () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}))
// Uses global mock for @nextcloud/dialogs from test-setup.ts
import CreateThreadView from '../CreateThreadView.vue'
import { ocs } from '@/axios'

View File

@@ -3,17 +3,8 @@ import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockThread, createMockPost } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Mock @nextcloud/dialogs
vi.mock('@nextcloud/dialogs', () => ({
showError: vi.fn(),
}))
// Uses global mock for @/axios from test-setup.ts
// Uses global mock for @nextcloud/dialogs from test-setup.ts
// Mock Nextcloud Vue components (to avoid CSS import issues)
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () =>
@@ -57,7 +48,6 @@ import { ocs } from '@/axios'
import { showError } from '@nextcloud/dialogs'
const mockOcsGet = vi.mocked(ocs.get)
const mockShowError = vi.mocked(showError)
// Helper to get the primary search button (first button in the component)
const getSearchButton = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('button')[0]!

View File

@@ -4,15 +4,7 @@ import { computed, ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockThread, createMockPost, createMockUser } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Mock useCurrentUser composable
const mockUserId = ref<string | null>('testuser')
@@ -41,11 +33,7 @@ vi.mock('@/composables/usePermissions', () => ({
}),
}))
// Mock dialogs
vi.mock('@nextcloud/dialogs', () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}))
// Uses global mock for @nextcloud/dialogs from test-setup.ts
// Mock icons
vi.mock('@icons/ArrowLeft.vue', () => createIconMock('ArrowLeftIcon'))

View File

@@ -3,12 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockRole } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Uses global mock for @/axios from test-setup.ts
// Mock icons
vi.mock('@icons/AccountMultiple.vue', () => createIconMock('AccountMultipleIcon'))