mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
test: add component tests, improve mocks and stubs
This commit is contained in:
27
README.md
27
README.md
@@ -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
|
||||
|
||||
|
||||
515
src/components/AppNavigation/AppNavigation.test.ts
Normal file
515
src/components/AppNavigation/AppNavigation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
234
src/components/BBCodeEditor/BBCodeEditor.test.ts
Normal file
234
src/components/BBCodeEditor/BBCodeEditor.test.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
158
src/components/InitializationScreen.test.ts
Normal file
158
src/components/InitializationScreen.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
}))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
getSelectedText,
|
||||
applyBBCodeTemplate,
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]!
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user