test: add comprehensive component tests

This commit is contained in:
2026-01-05 18:53:26 +02:00
parent a07c8e452f
commit 7732f22f4e
28 changed files with 5679 additions and 83 deletions

View File

@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AdminTable from './AdminTable.vue'
describe('AdminTable', () => {
const defaultColumns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
]
const defaultRows = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
]
describe('rendering', () => {
it('should render column headers', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.find('.header-row').text()).toContain('Name')
expect(wrapper.find('.header-row').text()).toContain('Email')
})
it('should render data rows', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
const dataRows = wrapper.findAll('.data-row')
expect(dataRows).toHaveLength(2)
})
it('should render cell values', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('john@example.com')
})
})
describe('actions column', () => {
it('should not show actions column by default', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.find('.col-actions').exists()).toBe(false)
})
it('should show actions column when hasActions is true', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
})
expect(wrapper.findAll('.col-actions').length).toBeGreaterThan(0)
})
it('should use custom actions label', () => {
const wrapper = mount(AdminTable, {
props: {
columns: defaultColumns,
rows: defaultRows,
hasActions: true,
actionsLabel: 'Operations',
},
})
expect(wrapper.find('.header-row').text()).toContain('Operations')
})
})
describe('grid style', () => {
it('should compute grid template columns', () => {
const columns = [
{ key: 'name', label: 'Name', width: '200px' },
{ key: 'email', label: 'Email', minWidth: '150px' },
]
const wrapper = mount(AdminTable, {
props: { columns, rows: defaultRows },
})
const grid = wrapper.find('.table-grid')
expect(grid.attributes('style')).toContain('grid-template-columns')
})
})
describe('row key', () => {
it('should use id as row key by default', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.findAll('.data-row')).toHaveLength(2)
})
it('should use custom row key', () => {
const rows = [
{ customId: 'a', name: 'Test' },
{ customId: 'b', name: 'Test 2' },
]
const wrapper = mount(AdminTable, {
props: { columns: [{ key: 'name', label: 'Name' }], rows, rowKey: 'customId' },
})
expect(wrapper.findAll('.data-row')).toHaveLength(2)
})
})
describe('slots', () => {
it('should render custom cell content via slot', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
slots: {
'cell-name': '<span class="custom-cell">Custom Name</span>',
},
})
expect(wrapper.findAll('.custom-cell').length).toBeGreaterThan(0)
})
it('should render actions slot', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
slots: {
actions: '<button class="action-btn">Edit</button>',
},
})
expect(wrapper.findAll('.action-btn').length).toBeGreaterThan(0)
})
})
describe('row class', () => {
it('should apply string row class', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows, rowClass: 'custom-row' },
})
expect(wrapper.findAll('.data-row.custom-row')).toHaveLength(2)
})
it('should apply function row class', () => {
const wrapper = mount(AdminTable, {
props: {
columns: defaultColumns,
rows: defaultRows,
rowClass: (row: { id: number }) => (row.id === 1 ? 'first-row' : ''),
},
})
expect(wrapper.find('.data-row.first-row').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AppToolbar from './AppToolbar.vue'
describe('AppToolbar', () => {
describe('rendering', () => {
it('should render toolbar container', () => {
const wrapper = mount(AppToolbar)
expect(wrapper.find('.app-toolbar').exists()).toBe(true)
})
it('should render left and right sections', () => {
const wrapper = mount(AppToolbar)
expect(wrapper.find('.toolbar-left').exists()).toBe(true)
expect(wrapper.find('.toolbar-right').exists()).toBe(true)
})
})
describe('slots', () => {
it('should render left slot content', () => {
const wrapper = mount(AppToolbar, {
slots: {
left: '<span class="left-content">Left Content</span>',
},
})
expect(wrapper.find('.toolbar-left .left-content').exists()).toBe(true)
expect(wrapper.find('.toolbar-left').text()).toBe('Left Content')
})
it('should render right slot content', () => {
const wrapper = mount(AppToolbar, {
slots: {
right: '<span class="right-content">Right Content</span>',
},
})
expect(wrapper.find('.toolbar-right .right-content').exists()).toBe(true)
expect(wrapper.find('.toolbar-right').text()).toBe('Right Content')
})
it('should render both slots simultaneously', () => {
const wrapper = mount(AppToolbar, {
slots: {
left: '<button>Action</button>',
right: '<span>Status</span>',
},
})
expect(wrapper.find('.toolbar-left button').exists()).toBe(true)
expect(wrapper.find('.toolbar-right span').exists()).toBe(true)
})
it('should render multiple elements in a slot', () => {
const wrapper = mount(AppToolbar, {
slots: {
left: '<button>One</button><button>Two</button><button>Three</button>',
},
})
const buttons = wrapper.findAll('.toolbar-left button')
expect(buttons).toHaveLength(3)
})
})
})

View File

@@ -0,0 +1,419 @@
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
import { ocs } from '@/axios'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
const mockGet = vi.mocked(ocs.get)
describe('BBCodeHelpDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: [] } as never)
})
const createWrapper = (props = {}) => {
return mount(BBCodeHelpDialog, {
props: {
open: true,
showCustom: true,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('renders built-in BBCodes section', () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-section').exists()).toBe(true)
expect(wrapper.text()).toContain('Built-in BBCodes')
})
it('renders all built-in BBCode tags', () => {
const wrapper = createWrapper()
const tags = wrapper.findAll('.bbcode-tag')
// Check for some expected built-in tags
const tagTexts = tags.map((t) => t.text())
expect(tagTexts).toContain('[b]')
expect(tagTexts).toContain('[i]')
expect(tagTexts).toContain('[code]')
expect(tagTexts).toContain('[url]')
expect(tagTexts).toContain('[img]')
expect(tagTexts).toContain('[quote]')
})
it('renders BBCode examples', () => {
const wrapper = createWrapper()
const examples = wrapper.findAll('.example-code')
expect(examples.length).toBeGreaterThan(0)
// Check for a specific example
expect(wrapper.text()).toContain('[b]Hello world![/b]')
})
it('renders custom BBCodes section when showCustom is true', () => {
const wrapper = createWrapper({ showCustom: true })
expect(wrapper.text()).toContain('Custom BBCodes')
})
it('does not render custom BBCodes section when showCustom is false', () => {
const wrapper = createWrapper({ showCustom: false })
expect(wrapper.text()).not.toContain('Custom BBCodes')
})
})
describe('fetching builtin DB codes', () => {
it('fetches builtin codes when dialog opens', async () => {
createWrapper({ open: true })
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/bbcodes/builtin')
})
it('displays builtin DB codes', async () => {
const builtinCodes: BBCode[] = [
{
id: 1,
tag: 'spoiler',
replacement: '<span class="spoiler">{content}</span>',
example: '[spoiler]Hidden text[/spoiler]',
description: 'Spoiler text',
enabled: true,
parseInner: true,
isBuiltin: true,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes/builtin') {
return Promise.resolve({ data: builtinCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.text()).toContain('[spoiler]')
expect(wrapper.text()).toContain('Spoiler text')
})
it('silently fails when builtin codes fetch fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes/builtin') {
return Promise.reject(new Error('Network error'))
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true })
await flushPromises()
// Should not show error state for builtin codes
expect(wrapper.find('.error-state').exists()).toBe(false)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('fetching custom codes', () => {
it('fetches custom codes when dialog opens with showCustom true', async () => {
createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/bbcodes')
})
it('does not fetch custom codes when showCustom is false', async () => {
createWrapper({ open: true, showCustom: false })
await flushPromises()
expect(mockGet).not.toHaveBeenCalledWith('/bbcodes')
})
it('displays loading state while fetching custom codes', async () => {
let resolvePromise: (value: unknown) => void
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return new Promise((resolve) => {
resolvePromise = resolve
}) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading custom BBCodes')
resolvePromise!({ data: [] })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(false)
})
it('displays custom codes after fetch', async () => {
const customCodes: BBCode[] = [
{
id: 10,
tag: 'highlight',
replacement: '<mark>{content}</mark>',
example: '[highlight]Important text[/highlight]',
description: 'Highlight text',
enabled: true,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.resolve({ data: customCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.text()).toContain('[highlight]')
expect(wrapper.text()).toContain('Highlight text')
})
it('filters out disabled custom codes', async () => {
const customCodes: BBCode[] = [
{
id: 10,
tag: 'enabled',
replacement: '<span>{content}</span>',
example: '[enabled]Text[/enabled]',
description: 'Enabled code',
enabled: true,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
{
id: 11,
tag: 'disabled',
replacement: '<span>{content}</span>',
example: '[disabled]Text[/disabled]',
description: 'Disabled code',
enabled: false,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.resolve({ data: customCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.text()).toContain('[enabled]')
expect(wrapper.text()).not.toContain('[disabled]')
})
it('displays empty state when no custom codes exist', async () => {
mockGet.mockResolvedValue({ data: [] } as never)
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.find('.empty-state').exists()).toBe(true)
expect(wrapper.text()).toContain('No custom BBCodes configured')
})
it('displays error state when fetch fails', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.reject(new Error('Network error'))
}
return Promise.resolve({ data: [] }) as never
})
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.find('.error-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Failed to load custom BBCodes')
})
})
describe('caching', () => {
it('does not refetch builtin codes if already loaded when reopening', async () => {
// Mock returns data
const builtinCodes: BBCode[] = [
{
id: 1,
tag: 'test',
replacement: '<span>{content}</span>',
example: '[test]Hello[/test]',
description: 'Test',
enabled: true,
parseInner: true,
isBuiltin: true,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes/builtin') {
return Promise.resolve({ data: builtinCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true })
await flushPromises()
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
expect(callCount).toBe(1)
// Close and reopen - since builtinDbCodes.length > 0, should not refetch
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
expect(newCallCount).toBe(1) // Should still be 1
})
it('does not refetch custom codes if already loaded when reopening', async () => {
// Mock returns data for custom codes
const customCodes: BBCode[] = [
{
id: 10,
tag: 'custom',
replacement: '<span>{content}</span>',
example: '[custom]Hello[/custom]',
description: 'Custom',
enabled: true,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.resolve({ data: customCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
expect(callCount).toBe(1)
// Close and reopen - since customCodes.length > 0, should not refetch
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
expect(newCallCount).toBe(1) // Should still be 1
})
})
describe('close event', () => {
it('emits update:open event when dialog closes', async () => {
const wrapper = createWrapper({ open: true })
;(wrapper.vm as unknown as { handleClose: (v: boolean) => void }).handleClose(false)
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
describe('built-in codes content', () => {
it('contains bold tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Font style bold')
})
it('contains italic tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Font style italic')
})
it('contains code tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[code]')
expect(wrapper.text()).toContain('Code')
})
it('contains email tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[email]')
expect(wrapper.text()).toContain('Email (clickable)')
})
it('contains url tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[url=http://example.com]')
expect(wrapper.text()).toContain('URL (clickable)')
})
it('contains image tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[img]')
expect(wrapper.text()).toContain('Image (not clickable)')
})
it('contains quote tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[quote]')
expect(wrapper.text()).toContain('Quote')
})
it('contains youtube tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[youtube]')
expect(wrapper.text()).toContain('Embedded YouTube video')
})
it('contains list tags examples', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[list]')
expect(wrapper.text()).toContain('List')
})
it('contains alignment tag examples', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[left]')
expect(wrapper.text()).toContain('[center]')
expect(wrapper.text()).toContain('[right]')
})
})
})

View File

@@ -0,0 +1,498 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
// Mock icons
vi.mock('@icons/FormatBold.vue', () => createIconMock('FormatBoldIcon'))
vi.mock('@icons/FormatItalic.vue', () => createIconMock('FormatItalicIcon'))
vi.mock('@icons/FormatStrikethrough.vue', () => createIconMock('FormatStrikethroughIcon'))
vi.mock('@icons/FormatUnderline.vue', () => createIconMock('FormatUnderlineIcon'))
vi.mock('@icons/CodeTags.vue', () => createIconMock('CodeTagsIcon'))
vi.mock('@icons/Email.vue', () => createIconMock('EmailIcon'))
vi.mock('@icons/Link.vue', () => createIconMock('LinkIcon'))
vi.mock('@icons/Image.vue', () => createIconMock('ImageIcon'))
vi.mock('@icons/FormatQuoteClose.vue', () => createIconMock('FormatQuoteCloseIcon'))
vi.mock('@icons/Youtube.vue', () => createIconMock('YoutubeIcon'))
vi.mock('@icons/FormatFont.vue', () => createIconMock('FormatFontIcon'))
vi.mock('@icons/FormatSize.vue', () => createIconMock('FormatSizeIcon'))
vi.mock('@icons/FormatColorFill.vue', () => createIconMock('FormatColorFillIcon'))
vi.mock('@icons/FormatAlignLeft.vue', () => createIconMock('FormatAlignLeftIcon'))
vi.mock('@icons/FormatAlignCenter.vue', () => createIconMock('FormatAlignCenterIcon'))
vi.mock('@icons/FormatAlignRight.vue', () => createIconMock('FormatAlignRightIcon'))
vi.mock('@icons/EyeOff.vue', () => createIconMock('EyeOffIcon'))
vi.mock('@icons/FormatListBulleted.vue', () => createIconMock('FormatListBulletedIcon'))
vi.mock('@icons/Paperclip.vue', () => createIconMock('PaperclipIcon'))
vi.mock('@icons/Upload.vue', () => createIconMock('UploadIcon'))
vi.mock('@icons/Emoticon.vue', () => createIconMock('EmoticonIcon'))
vi.mock('@icons/HelpCircle.vue', () => createIconMock('HelpCircleIcon'))
// Mock child components
vi.mock('@/components/LazyEmojiPicker', () =>
createComponentMock('LazyEmojiPicker', {
template: '<div class="emoji-picker-mock"><slot /></div>',
props: [],
}),
)
vi.mock('@/components/BBCodeHelpDialog', () =>
createComponentMock('BBCodeHelpDialog', {
template: '<div class="bbcode-help-dialog-mock" v-if="open" />',
props: ['open'],
}),
)
// 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 },
}))
// 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/NcProgressBar', () => ({
default: {
name: 'NcProgressBar',
template: '<div class="nc-progress-bar-mock" :data-value="value" />',
props: ['value', 'size'],
},
}))
// Import after mocks
import BBCodeToolbar from './BBCodeToolbar.vue'
describe('BBCodeToolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('prompt', vi.fn())
})
const createWrapper = (props = {}) => {
return mount(BBCodeToolbar, {
props: {
textareaRef: null,
modelValue: '',
...props,
},
})
}
describe('rendering', () => {
it('renders the toolbar', () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-toolbar').exists()).toBe(true)
})
it('renders BBCode formatting buttons', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.bbcode-button')
// Should have multiple BBCode buttons (bold, italic, etc.) + emoji + help
expect(buttons.length).toBeGreaterThan(10)
})
it('renders help button', () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-help-button').exists()).toBe(true)
})
it('renders emoji picker trigger', () => {
const wrapper = createWrapper()
expect(wrapper.find('.emoji-picker-mock').exists()).toBe(true)
})
it('renders attachment actions', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-actions-mock').exists()).toBe(true)
})
})
describe('bbcodeButtons computed', () => {
it('includes bold button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'b')).toBe(true)
})
it('includes italic button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'i')).toBe(true)
})
it('includes underline button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'u')).toBe(true)
})
it('includes strikethrough button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 's')).toBe(true)
})
it('includes code button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'code')).toBe(true)
})
it('includes quote button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'quote')).toBe(true)
})
it('includes url button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'url')).toBe(true)
})
it('includes img button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'img')).toBe(true)
})
it('includes youtube button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'youtube')).toBe(true)
})
it('includes list button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'list')).toBe(true)
})
it('includes color button with hasValue', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
bbcodeButtons: Array<{ tag: string; hasValue?: boolean }>
}
const colorButton = vm.bbcodeButtons.find((b) => b.tag === 'color')
expect(colorButton).toBeDefined()
expect(colorButton!.hasValue).toBe(true)
})
it('includes spoiler button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'spoiler')).toBe(true)
})
})
describe('help dialog', () => {
it('opens help dialog when help button is clicked', async () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
await wrapper.find('.bbcode-help-button').trigger('click')
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
})
it('closes help dialog when showHelp is set to false', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { showHelp: boolean }
vm.showHelp = true
await flushPromises()
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
vm.showHelp = false
await flushPromises()
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
})
})
describe('getEditorState', () => {
it('returns null when textareaRef is null', () => {
const wrapper = createWrapper({ textareaRef: null })
const vm = wrapper.vm as unknown as { getEditorState: () => unknown }
expect(vm.getEditorState()).toBeNull()
})
it('returns editor state for textarea element', () => {
const textarea = document.createElement('textarea')
textarea.value = 'Hello world'
textarea.selectionStart = 0
textarea.selectionEnd = 5
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
getEditorState: () => {
value: string
start: number
end: number
selectedText: string
} | null
}
const state = vm.getEditorState()
expect(state).not.toBeNull()
expect(state!.value).toBe('Hello world')
expect(state!.start).toBe(0)
expect(state!.end).toBe(5)
expect(state!.selectedText).toBe('Hello')
})
})
describe('isTextarea', () => {
it('returns true for textarea elements', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { isTextarea: (el: HTMLElement) => boolean }
const textarea = document.createElement('textarea')
expect(vm.isTextarea(textarea)).toBe(true)
})
it('returns false for div elements', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { isTextarea: (el: HTMLElement) => boolean }
const div = document.createElement('div')
expect(vm.isTextarea(div)).toBe(false)
})
})
describe('insertBBCode', () => {
it('does nothing when textareaRef is null', async () => {
const wrapper = createWrapper({ textareaRef: null })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: { tag: string; template: string }) => Promise<void>
}
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]' })
expect(wrapper.emitted('insert')).toBeFalsy()
})
it('emits insert event with new text for simple BBCode', async () => {
const textarea = document.createElement('textarea')
textarea.value = 'Hello world'
textarea.selectionStart = 0
textarea.selectionEnd = 5
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: { tag: string; template: string; label: string }) => Promise<void>
}
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]', label: 'Bold' })
expect(wrapper.emitted('insert')).toBeTruthy()
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
expect(emitted.text).toBe('[b]Hello[/b] world')
})
it('prompts for value when button has hasValue', async () => {
const mockPrompt = vi.fn().mockReturnValue('red')
vi.stubGlobal('prompt', mockPrompt)
const textarea = document.createElement('textarea')
textarea.value = 'Hello'
textarea.selectionStart = 0
textarea.selectionEnd = 5
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: {
tag: string
template: string
label: string
hasValue: boolean
placeholder: string
}) => Promise<void>
}
await vm.insertBBCode({
tag: 'color',
template: '[color={value}]{text}[/color]',
label: 'Color',
hasValue: true,
placeholder: 'red',
})
expect(mockPrompt).toHaveBeenCalled()
expect(wrapper.emitted('insert')).toBeTruthy()
})
it('does nothing when prompt is cancelled for hasValue button', async () => {
const mockPrompt = vi.fn().mockReturnValue(null)
vi.stubGlobal('prompt', mockPrompt)
const textarea = document.createElement('textarea')
textarea.value = 'Hello'
textarea.selectionStart = 0
textarea.selectionEnd = 5
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: {
tag: string
template: string
label: string
hasValue: boolean
placeholder: string
}) => Promise<void>
}
await vm.insertBBCode({
tag: 'color',
template: '[color={value}]{text}[/color]',
label: 'Color',
hasValue: true,
placeholder: 'red',
})
expect(wrapper.emitted('insert')).toBeFalsy()
})
it('prompts for content when no selection and promptForContent is true', async () => {
const mockPrompt = vi.fn().mockReturnValue('http://example.com/image.png')
vi.stubGlobal('prompt', mockPrompt)
const textarea = document.createElement('textarea')
textarea.value = ''
textarea.selectionStart = 0
textarea.selectionEnd = 0
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: {
tag: string
template: string
label: string
promptForContent: boolean
contentPlaceholder: string
}) => Promise<void>
}
await vm.insertBBCode({
tag: 'img',
template: '[img]{text}[/img]',
label: 'Image',
promptForContent: true,
contentPlaceholder: 'http://example.com/image.png',
})
expect(mockPrompt).toHaveBeenCalled()
expect(wrapper.emitted('insert')).toBeTruthy()
})
})
describe('handleEmojiSelect', () => {
it('emits insert event with emoji', async () => {
const textarea = document.createElement('textarea')
textarea.value = 'Hello '
textarea.selectionStart = 6
textarea.selectionEnd = 6
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
vm.handleEmojiSelect('😀')
expect(wrapper.emitted('insert')).toBeTruthy()
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
expect(emitted.text).toBe('Hello 😀')
expect(emitted.cursorPos).toBe(8) // After emoji
})
it('does nothing when textareaRef is null', () => {
const wrapper = createWrapper({ textareaRef: null })
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
vm.handleEmojiSelect('😀')
expect(wrapper.emitted('insert')).toBeFalsy()
})
})
describe('upload dialog', () => {
it('initializes with upload dialog closed', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { uploadDialog: boolean }
expect(vm.uploadDialog).toBe(false)
})
it('closeUploadDialog resets upload state', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
uploadDialog: boolean
uploadProgress: number
uploadFileName: string
uploadError: string | null
closeUploadDialog: () => void
}
vm.uploadDialog = true
vm.uploadProgress = 50
vm.uploadFileName = 'test.pdf'
vm.uploadError = 'Some error'
vm.closeUploadDialog()
expect(vm.uploadDialog).toBe(false)
expect(vm.uploadProgress).toBe(0)
expect(vm.uploadFileName).toBe('')
expect(vm.uploadError).toBeNull()
})
})
describe('strings', () => {
it('has correct translation keys', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { strings: Record<string, string> }
expect(vm.strings.helpLabel).toBe('BBCode help')
expect(vm.strings.emojiLabel).toBe('Insert emoji')
expect(vm.strings.attachmentLabel).toBe('Attachment')
expect(vm.strings.pickFileLabel).toBe('Pick file from Nextcloud')
expect(vm.strings.uploadFileLabel).toBe('Upload file to Nextcloud')
})
})
})

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CategoryCard from './CategoryCard.vue'
import { createMockCategory } from '@/test-mocks'
// Uses global mock for @nextcloud/l10n from test-setup.ts
describe('CategoryCard', () => {
describe('rendering', () => {
it('should render category name', () => {
const category = createMockCategory({ name: 'General Discussion' })
const wrapper = mount(CategoryCard, {
props: { category },
})
expect(wrapper.find('.category-name').text()).toBe('General Discussion')
})
it('should render category description', () => {
const category = createMockCategory({ description: 'Talk about anything' })
const wrapper = mount(CategoryCard, {
props: { category },
})
expect(wrapper.find('.category-description').text()).toBe('Talk about anything')
})
it('should render placeholder when no description', () => {
const category = createMockCategory({ description: null })
const wrapper = mount(CategoryCard, {
props: { category },
})
expect(wrapper.find('.category-description').text()).toBe('No description available')
expect(wrapper.find('.category-description').classes()).toContain('muted')
})
})
describe('stats', () => {
it('should display thread count', () => {
const category = createMockCategory({ threadCount: 25 })
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('25')
})
it('should display post count', () => {
const category = createMockCategory({ postCount: 150 })
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[1]!.text()).toBe('150')
})
it('should handle zero counts', () => {
const category = createMockCategory({ threadCount: 0, postCount: 0 })
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('0')
expect(stats[1]!.text()).toBe('0')
})
it('should handle undefined counts as zero', () => {
const category = createMockCategory()
// @ts-expect-error Testing undefined case
category.threadCount = undefined
// @ts-expect-error Testing undefined case
category.postCount = undefined
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('0')
expect(stats[1]!.text()).toBe('0')
})
})
describe('structure', () => {
it('should have correct class', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
})
expect(wrapper.find('.category-card').exists()).toBe(true)
})
it('should have header with name and stats', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
})
expect(wrapper.find('.category-header').exists()).toBe(true)
expect(wrapper.find('.category-name').exists()).toBe(true)
expect(wrapper.find('.category-stats').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,521 @@
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
import { ocs } from '@/axios'
import HeaderEditDialog from './HeaderEditDialog.vue'
const mockPost = vi.mocked(ocs.post)
const mockPut = vi.mocked(ocs.put)
describe('HeaderEditDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createWrapper = (props = {}) => {
return mount(HeaderEditDialog, {
props: {
open: true,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render the dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('shows create title when headerId is null', () => {
const wrapper = createWrapper({ headerId: null })
const vm = wrapper.vm as unknown as { isEditing: boolean }
expect(vm.isEditing).toBe(false)
})
it('shows edit title when headerId is provided', () => {
const wrapper = createWrapper({ headerId: 1 })
const vm = wrapper.vm as unknown as { isEditing: boolean }
expect(vm.isEditing).toBe(true)
})
it('renders name field', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-text-field').exists()).toBe(true)
})
it('renders description field', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
})
it('renders sort order field', () => {
const wrapper = createWrapper()
const inputs = wrapper.findAll('.nc-text-field')
// Name and sort order
expect(inputs.length).toBe(2)
})
it('renders cancel button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
})
it('renders create button when creating', () => {
const wrapper = createWrapper({ headerId: null })
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Create')).toBe(true)
})
it('renders update button when editing', () => {
const wrapper = createWrapper({ headerId: 1 })
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Update')).toBe(true)
})
})
describe('initial values', () => {
it('initializes with empty values when creating', () => {
const wrapper = createWrapper({ headerId: null })
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
}
expect(vm.localName).toBe('')
expect(vm.localDescription).toBe('')
expect(vm.localSortOrder).toBe(0)
})
it('initializes with provided values when editing', () => {
const wrapper = createWrapper({
headerId: 1,
name: 'Test Header',
description: 'Test Description',
sortOrder: 5,
})
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
}
expect(vm.localName).toBe('Test Header')
expect(vm.localDescription).toBe('Test Description')
expect(vm.localSortOrder).toBe(5)
})
it('resets values when dialog reopens', async () => {
const wrapper = createWrapper({
headerId: 1,
name: 'Original Name',
description: 'Original Description',
sortOrder: 3,
})
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
}
// Modify local values
vm.localName = 'Modified Name'
vm.localDescription = 'Modified Description'
vm.localSortOrder = 10
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(vm.localName).toBe('Original Name')
expect(vm.localDescription).toBe('Original Description')
expect(vm.localSortOrder).toBe(3)
})
})
describe('validation', () => {
it('disables save button when name is empty', () => {
const wrapper = createWrapper({ headerId: null, name: '' })
const vm = wrapper.vm as unknown as { canSave: boolean }
expect(vm.canSave).toBe(false)
})
it('disables save button when name is only whitespace', () => {
const wrapper = createWrapper({ headerId: null, name: ' ' })
const vm = wrapper.vm as unknown as { canSave: boolean }
expect(vm.canSave).toBe(false)
})
it('enables save button when name has content', () => {
const wrapper = createWrapper({ headerId: null, name: 'Valid Name' })
const vm = wrapper.vm as unknown as { canSave: boolean }
expect(vm.canSave).toBe(true)
})
})
describe('creating headers', () => {
it('calls ocs.post when creating a new header', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'New Header',
description: 'New Description',
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
name: 'New Header',
}),
)
})
it('emits saved event with new header data', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'New Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(wrapper.emitted('saved')).toBeTruthy()
expect(wrapper.emitted('saved')![0]).toEqual([mockHeader])
})
it('closes dialog after successful create', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'New Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
describe('updating headers', () => {
it('calls ocs.put when updating an existing header', async () => {
const mockHeader: CatHeader = {
id: 5,
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
createdAt: Date.now(),
}
mockPut.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: 5,
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPut).toHaveBeenCalledWith(
'/headers/5',
expect.objectContaining({
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
}),
)
})
it('emits saved event with updated header data', async () => {
const mockHeader: CatHeader = {
id: 5,
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
createdAt: Date.now(),
}
mockPut.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: 5,
name: 'Updated Header',
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(wrapper.emitted('saved')).toBeTruthy()
expect(wrapper.emitted('saved')![0]).toEqual([mockHeader])
})
})
describe('error handling', () => {
it('logs error when save fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockRejectedValue(new Error('Network error'))
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('does not close dialog when save fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockRejectedValue(new Error('Network error'))
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
// Should not emit update:open on failure
expect(wrapper.emitted('update:open')).toBeFalsy()
})
it('resets submitting state after error', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockRejectedValue(new Error('Network error'))
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as {
handleSave: () => Promise<void>
submitting: boolean
}
await vm.handleSave()
await flushPromises()
expect(vm.submitting).toBe(false)
})
})
describe('close handling', () => {
it('emits update:open when cancel button is clicked', async () => {
const wrapper = createWrapper()
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
await cancelButton!.trigger('click')
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('does not close when submitting', async () => {
let resolvePromise: (value: unknown) => void
mockPost.mockImplementation(
() =>
new Promise((resolve) => {
resolvePromise = resolve
}) as never,
)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
// Start submitting
const vm = wrapper.vm as unknown as {
handleSave: () => Promise<void>
handleClose: () => void
}
vm.handleSave() // Don't await
await flushPromises()
// Try to close while submitting
vm.handleClose()
// Should not emit close event
expect(wrapper.emitted('update:open')).toBeFalsy()
// Clean up
resolvePromise!({ data: {} })
await flushPromises()
})
})
describe('data transformation', () => {
it('trims name before sending', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'Trimmed Name',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: ' Trimmed Name ' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
name: 'Trimmed Name',
}),
)
})
it('trims description before sending', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'Header',
description: 'Trimmed Description',
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: null,
name: 'Header',
description: ' Trimmed Description ',
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
description: 'Trimmed Description',
}),
)
})
it('sends null for empty description', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: null,
name: 'Header',
description: '',
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
description: null,
}),
)
})
})
describe('reset method', () => {
it('resets all local values', () => {
const wrapper = createWrapper({
headerId: 1,
name: 'Header',
description: 'Description',
sortOrder: 5,
})
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
submitting: boolean
reset: () => void
}
vm.reset()
expect(vm.localName).toBe('')
expect(vm.localDescription).toBe('')
expect(vm.localSortOrder).toBe(0)
expect(vm.submitting).toBe(false)
})
})
describe('prop watchers', () => {
it('updates localName when name prop changes', async () => {
const wrapper = createWrapper({ name: 'Initial' })
await wrapper.setProps({ name: 'Updated' })
const vm = wrapper.vm as unknown as { localName: string }
expect(vm.localName).toBe('Updated')
})
it('updates localDescription when description prop changes', async () => {
const wrapper = createWrapper({ description: 'Initial' })
await wrapper.setProps({ description: 'Updated' })
const vm = wrapper.vm as unknown as { localDescription: string }
expect(vm.localDescription).toBe('Updated')
})
it('updates localSortOrder when sortOrder prop changes', async () => {
const wrapper = createWrapper({ sortOrder: 1 })
await wrapper.setProps({ sortOrder: 10 })
const vm = wrapper.vm as unknown as { localSortOrder: number }
expect(vm.localSortOrder).toBe(10)
})
})
})

View File

@@ -0,0 +1,536 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import type { CategoryHeader, Category } from '@/types'
// Mock useCategories composable
const mockFetchCategories = vi.fn()
const mockCategoryHeaders = ref<CategoryHeader[]>([])
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
categoryHeaders: mockCategoryHeaders,
fetchCategories: mockFetchCategories,
}),
}))
// Import after mocks
import MoveCategoryDialog from './MoveCategoryDialog.vue'
describe('MoveCategoryDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCategoryHeaders.value = []
mockFetchCategories.mockResolvedValue([])
})
const createMockCategory = (overrides: Partial<Category> = {}): Category => ({
id: 1,
headerId: 1,
name: 'Test Category',
description: null,
slug: 'test-category',
sortOrder: 0,
threadCount: 0,
postCount: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
})
const createMockHeader = (overrides: Partial<CategoryHeader> = {}): CategoryHeader => ({
id: 1,
name: 'Test Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [],
...overrides,
})
const createWrapper = (props = {}) => {
return mount(MoveCategoryDialog, {
props: {
open: true,
currentCategoryId: 1,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render the dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('displays the correct title', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { strings: { title: string } }
expect(vm.strings.title).toBe('Move thread to category')
})
it('displays description text', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Select the category to move this thread to')
})
it('renders cancel button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
})
it('renders move button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Move')).toBe(true)
})
})
describe('loading state', () => {
it('shows loading state while fetching categories', async () => {
let resolvePromise: (value: unknown) => void
mockFetchCategories.mockImplementation(
() =>
new Promise((resolve) => {
resolvePromise = resolve
}),
)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading categories')
resolvePromise!(undefined)
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(false)
})
})
describe('error state', () => {
it('displays error state when fetch fails', async () => {
mockFetchCategories.mockRejectedValue(new Error('Network error'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.error-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Failed to load categories')
})
})
describe('category options', () => {
it('creates category options from headers', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header 1',
categories: [
createMockCategory({ id: 10, name: 'Category A' }),
createMockCategory({ id: 11, name: 'Category B' }),
],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
// Should have 1 header + 2 categories
expect(vm.categoryOptions.length).toBe(3)
})
it('marks headers with negative IDs', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 5,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
const headerOption = vm.categoryOptions.find((o) => o.isHeader)
expect(headerOption).toBeDefined()
expect(headerOption!.id).toBe(-5) // Negative of header ID
})
it('marks categories with isHeader false', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
expect(categoryOption).toBeDefined()
expect(categoryOption!.isHeader).toBe(false)
})
it('indents category names with spaces', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
expect(categoryOption!.name).toBe(' Category') // Two spaces prefix
})
it('excludes headers with no categories', async () => {
mockCategoryHeaders.value = [
createMockHeader({ id: 1, name: 'Empty Header', categories: [] }),
createMockHeader({
id: 2,
name: 'Header with Categories',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
// Should only have header 2 and its category
expect(vm.categoryOptions.length).toBe(2)
expect(vm.categoryOptions.some((o) => o.name === 'Empty Header')).toBe(false)
})
})
describe('validation warnings', () => {
it('shows error when header is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
// Select a header
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
await flushPromises()
expect(wrapper.text()).toContain('Cannot move to a category header')
})
it('shows warning when same category is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
// Select the same category
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: 10, name: 'Category', isHeader: false }
await flushPromises()
expect(wrapper.text()).toContain('This thread is already in this category')
})
})
describe('move button state', () => {
it('disables move button when no category is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
expect(moveButton!.attributes('disabled')).toBeDefined()
})
it('disables move button when header is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
await flushPromises()
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
expect(moveButton!.attributes('disabled')).toBeDefined()
})
it('disables move button when same category is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: 10, name: 'Category', isHeader: false }
await flushPromises()
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
expect(moveButton!.attributes('disabled')).toBeDefined()
})
})
describe('move action', () => {
it('emits move event with selected category ID', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Target Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
}
vm.selectedCategory = { id: 20, name: 'Target Category', isHeader: false }
vm.handleMove()
expect(wrapper.emitted('move')).toBeTruthy()
expect(wrapper.emitted('move')![0]).toEqual([20])
})
it('sets moving state when move is triggered', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
moving: boolean
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
vm.handleMove()
expect(vm.moving).toBe(true)
})
it('does not emit move when header is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
}
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
vm.handleMove()
expect(wrapper.emitted('move')).toBeFalsy()
})
})
describe('close handling', () => {
it('emits update:open when cancel button is clicked', async () => {
const wrapper = createWrapper()
await flushPromises()
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
await cancelButton!.trigger('click')
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('does not close when moving', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
handleClose: () => void
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
// Start moving
vm.handleMove()
// Try to close
vm.handleClose()
// Should not emit close event
expect(wrapper.emitted('update:open')).toBeFalsy()
})
})
describe('reset method', () => {
it('resets moving and selectedCategory', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
moving: boolean
reset: () => void
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
vm.moving = true
vm.reset()
expect(vm.selectedCategory).toBeNull()
expect(vm.moving).toBe(false)
})
})
describe('dialog reopening', () => {
it('resets selectedCategory when dialog reopens', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
expect(vm.selectedCategory).toBeNull()
})
it('refetches categories when dialog reopens', async () => {
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(mockFetchCategories).toHaveBeenCalledTimes(1)
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
expect(mockFetchCategories).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock } from '@/test-utils'
import NotFoundPage from './NotFoundPage.vue'
// Uses global mocks for @nextcloud/l10n, @nextcloud/router, NcButton, NcEmptyContent from test-setup.ts
vi.mock('@icons/ArrowLeft.vue', () => createIconMock('ArrowLeftIcon'))
vi.mock('@icons/Home.vue', () => createIconMock('HomeIcon'))
vi.mock('@icons/AlertCircle.vue', () => createIconMock('AlertCircleIcon'))
const mockBack = vi.fn()
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ back: mockBack, push: mockPush }),
}))
describe('NotFoundPage', () => {
beforeEach(() => {
mockBack.mockClear()
mockPush.mockClear()
Object.defineProperty(window, 'history', {
value: { length: 5 },
writable: true,
})
})
describe('rendering', () => {
it('should render with default props', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.not-found-page').exists()).toBe(true)
expect(wrapper.find('.nc-empty-content').exists()).toBe(true)
})
it('should display default title', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.title').text()).toBe('Page not found')
})
it('should display default description', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.description').text()).toBe(
'The page you are looking for could not be found.',
)
})
it('should display custom title', () => {
const wrapper = mount(NotFoundPage, {
props: { title: 'Custom Title' },
})
expect(wrapper.find('.title').text()).toBe('Custom Title')
})
it('should display custom description', () => {
const wrapper = mount(NotFoundPage, {
props: { description: 'Custom description text' },
})
expect(wrapper.find('.description').text()).toBe('Custom description text')
})
})
describe('buttons', () => {
it('should show back button by default', () => {
const wrapper = mount(NotFoundPage)
const buttons = wrapper.findAll('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
expect(wrapper.find('.arrow-left-icon').exists()).toBe(true)
})
it('should show home button by default', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.home-icon').exists()).toBe(true)
})
it('should hide back button when showBackButton is false', () => {
const wrapper = mount(NotFoundPage, {
props: { showBackButton: false },
})
expect(wrapper.find('.arrow-left-icon').exists()).toBe(false)
})
it('should hide home button when showHomeButton is false', () => {
const wrapper = mount(NotFoundPage, {
props: { showHomeButton: false },
})
expect(wrapper.find('.home-icon').exists()).toBe(false)
})
it('should have correct home URL', () => {
const wrapper = mount(NotFoundPage)
const homeButton = wrapper.findAll('button').find((b) => b.find('.home-icon').exists())
expect(homeButton?.attributes('href')).toBe('/apps/forum')
})
})
describe('navigation', () => {
it('should go back when back button is clicked and history exists', async () => {
Object.defineProperty(window, 'history', {
value: { length: 5 },
writable: true,
})
const wrapper = mount(NotFoundPage)
const backButton = wrapper.findAll('button').find((b) => b.find('.arrow-left-icon').exists())
await backButton?.trigger('click')
expect(mockBack).toHaveBeenCalled()
})
it('should navigate to home when back button is clicked and no history', async () => {
Object.defineProperty(window, 'history', {
value: { length: 1 },
writable: true,
})
const wrapper = mount(NotFoundPage)
const backButton = wrapper.findAll('button').find((b) => b.find('.arrow-left-icon').exists())
await backButton?.trigger('click')
expect(mockPush).toHaveBeenCalledWith('/')
})
})
describe('icon', () => {
it('should render default AlertCircle icon', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.alert-circle-icon').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
import PageHeader from './PageHeader.vue'
vi.mock('@/components/Skeleton', () =>
createComponentMock('Skeleton', {
template: '<div class="skeleton-mock" :style="{ width, height }"></div>',
props: ['width', 'height', 'radius'],
}),
)
describe('PageHeader', () => {
describe('rendering', () => {
it('should render title', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Test Title' },
})
expect(wrapper.find('.page-title').text()).toBe('Test Title')
})
it('should render subtitle when provided', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', subtitle: 'Subtitle text' },
})
expect(wrapper.find('.page-subtitle').exists()).toBe(true)
expect(wrapper.find('.page-subtitle').text()).toBe('Subtitle text')
})
it('should not render subtitle when not provided', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title' },
})
expect(wrapper.find('.page-subtitle').exists()).toBe(false)
})
it('should not render subtitle when empty string', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', subtitle: '' },
})
expect(wrapper.find('.page-subtitle').exists()).toBe(false)
})
})
describe('loading state', () => {
it('should show skeleton loaders when loading', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', loading: true },
})
expect(wrapper.findAll('.skeleton-mock').length).toBe(2)
expect(wrapper.find('.page-title').exists()).toBe(false)
})
it('should show content when not loading', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', loading: false },
})
expect(wrapper.find('.skeleton-mock').exists()).toBe(false)
expect(wrapper.find('.page-title').exists()).toBe(true)
})
})
describe('default props', () => {
it('should have empty title by default', () => {
const wrapper = mount(PageHeader)
expect(wrapper.find('.page-title').text()).toBe('')
})
it('should not be loading by default', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Test' },
})
expect(wrapper.find('.page-title').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PageWrapper from './PageWrapper.vue'
describe('PageWrapper', () => {
describe('rendering', () => {
it('should render default slot content', () => {
const wrapper = mount(PageWrapper, {
slots: {
default: '<div class="test-content">Content</div>',
},
})
expect(wrapper.find('.test-content').exists()).toBe(true)
expect(wrapper.find('.test-content').text()).toBe('Content')
})
it('should render toolbar slot when provided', () => {
const wrapper = mount(PageWrapper, {
slots: {
toolbar: '<div class="test-toolbar">Toolbar</div>',
default: '<div>Content</div>',
},
})
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(true)
expect(wrapper.find('.test-toolbar').exists()).toBe(true)
})
it('should not render toolbar wrapper when slot is empty', () => {
const wrapper = mount(PageWrapper, {
slots: {
default: '<div>Content</div>',
},
})
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(false)
})
})
describe('fullWidth prop', () => {
it('should not have full-width class by default', () => {
const wrapper = mount(PageWrapper, {
slots: { default: '<div>Content</div>' },
})
expect(wrapper.find('.page-wrapper-content').classes()).not.toContain('full-width')
})
it('should have full-width class when fullWidth is true', () => {
const wrapper = mount(PageWrapper, {
props: { fullWidth: true },
slots: { default: '<div>Content</div>' },
})
expect(wrapper.find('.page-wrapper-content').classes()).toContain('full-width')
})
})
describe('structure', () => {
it('should have correct container structure', () => {
const wrapper = mount(PageWrapper, {
slots: {
toolbar: '<div>Toolbar</div>',
default: '<div>Content</div>',
},
})
expect(wrapper.find('.page-wrapper-container').exists()).toBe(true)
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(true)
expect(wrapper.find('.page-wrapper-content').exists()).toBe(true)
})
})
})

View File

@@ -1,43 +1,12 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock } from '@/test-utils'
import Pagination from './Pagination.vue'
// Mock @nextcloud/l10n
vi.mock('@nextcloud/l10n', () => ({
t: (app: string, text: string, vars?: Record<string, unknown>) => {
if (vars) {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
text,
)
}
return text
},
}))
// Mock @nextcloud/vue/components/NcButton
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: ['variant', 'disabled', 'ariaLabel', 'title'],
},
}))
// Mock icon components
vi.mock('@icons/PageFirst.vue', () => ({
default: { name: 'PageFirstIcon', template: '<span>«</span>', props: ['size'] },
}))
vi.mock('@icons/PageLast.vue', () => ({
default: { name: 'PageLastIcon', template: '<span>»</span>', props: ['size'] },
}))
vi.mock('@icons/ChevronLeft.vue', () => ({
default: { name: 'ChevronLeftIcon', template: '<span></span>', props: ['size'] },
}))
vi.mock('@icons/ChevronRight.vue', () => ({
default: { name: 'ChevronRightIcon', template: '<span></span>', props: ['size'] },
}))
vi.mock('@icons/PageFirst.vue', () => createIconMock('PageFirstIcon'))
vi.mock('@icons/PageLast.vue', () => createIconMock('PageLastIcon'))
vi.mock('@icons/ChevronLeft.vue', () => createIconMock('ChevronLeftIcon'))
vi.mock('@icons/ChevronRight.vue', () => createIconMock('ChevronRightIcon'))
describe('Pagination', () => {
describe('visibility', () => {
@@ -61,7 +30,6 @@ describe('Pagination', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 5 },
})
// Access the computed property via vm
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 4, 5])
})
@@ -79,7 +47,6 @@ describe('Pagination', () => {
props: { currentPage: 1, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Should show: 1, 2, 3 (first 3) + ellipsis + 18, 19, 20 (last 3)
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
})
@@ -88,7 +55,6 @@ describe('Pagination', () => {
props: { currentPage: 20, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Should show: 1, 2, 3 (first 3) + ellipsis + 18, 19, 20 (last 3)
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
})
@@ -97,7 +63,6 @@ describe('Pagination', () => {
props: { currentPage: 10, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Should show: 1, 2, 3 + ellipsis + 8, 9, 10, 11, 12 + ellipsis + 18, 19, 20
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 8, 9, 10, 11, 12, 'ellipsis', 18, 19, 20])
})
@@ -106,7 +71,6 @@ describe('Pagination', () => {
props: { currentPage: 4, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Current=4, so around: 2,3,4,5,6, combined with first 3 (1,2,3): 1,2,3,4,5,6
expect(pageItems).toContain(1)
expect(pageItems).toContain(4)
expect(pageItems).toContain(6)
@@ -117,7 +81,6 @@ describe('Pagination', () => {
props: { currentPage: 17, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Current=17, so around: 15,16,17,18,19, combined with last 3 (18,19,20): 15,16,17,18,19,20
expect(pageItems).toContain(15)
expect(pageItems).toContain(17)
expect(pageItems).toContain(20)
@@ -130,7 +93,6 @@ describe('Pagination', () => {
props: { currentPage: 5, maxPages: 10 },
})
// Find all buttons and click the one for page 3
const buttons = wrapper.findAll('button')
const page3Button = buttons.find((btn) => btn.text() === '3')
expect(page3Button).toBeDefined()
@@ -158,9 +120,8 @@ describe('Pagination', () => {
})
const buttons = wrapper.findAll('button')
// First two buttons are first page and previous page
expect(buttons[0].attributes('disabled')).toBeDefined()
expect(buttons[1].attributes('disabled')).toBeDefined()
expect(buttons[0]!.attributes('disabled')).toBeDefined()
expect(buttons[1]!.attributes('disabled')).toBeDefined()
})
it('should disable next/last buttons on last page', () => {
@@ -169,10 +130,9 @@ describe('Pagination', () => {
})
const buttons = wrapper.findAll('button')
// Last two buttons are next page and last page
const lastIdx = buttons.length - 1
expect(buttons[lastIdx].attributes('disabled')).toBeDefined()
expect(buttons[lastIdx - 1].attributes('disabled')).toBeDefined()
expect(buttons[lastIdx]!.attributes('disabled')).toBeDefined()
expect(buttons[lastIdx - 1]!.attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,375 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockPost, createMockUser, createMockRole } from '@/test-mocks'
import PostCard from './PostCard.vue'
// Mock icons
vi.mock('@icons/Reply.vue', () => createIconMock('ReplyIcon'))
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
// Mock components
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<div class="user-info-mock" :data-user-id="userId"><slot name="meta" /></div>',
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'roles'],
}),
)
vi.mock('@/components/PostReactions', () =>
createComponentMock('PostReactions', {
template: '<div class="post-reactions-mock" :data-post-id="postId" />',
props: ['postId', 'reactions'],
}),
)
vi.mock('@/components/PostEditForm', () =>
createComponentMock('PostEditForm', {
template: '<div class="post-edit-form-mock" />',
props: ['initialContent'],
}),
)
vi.mock('@/components/PostHistoryDialog', () =>
createComponentMock('PostHistoryDialog', {
template: '<div class="post-history-dialog-mock" v-if="open" />',
props: ['open', 'postId'],
}),
)
// 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'],
}),
)
// Mock getCurrentUser
const mockCurrentUser = vi.fn()
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => mockCurrentUser(),
}))
// Mock useUserRole
const mockIsAdmin = vi.fn(() => false)
const mockIsModerator = vi.fn(() => false)
vi.mock('@/composables/useUserRole', () => ({
useUserRole: () => ({
isAdmin: mockIsAdmin(),
isModerator: mockIsModerator(),
}),
}))
describe('PostCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentUser.mockReturnValue({ uid: 'testuser', displayName: 'Test User' })
mockIsAdmin.mockReturnValue(false)
mockIsModerator.mockReturnValue(false)
})
describe('rendering', () => {
it('should render post content', () => {
const post = createMockPost({ content: '<p>Hello world</p>' })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.content-text').html()).toContain('Hello world')
})
it('should render user info with author data', () => {
const author = createMockUser({ userId: 'john', displayName: 'John Doe' })
const post = createMockPost({ author })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.user-info-mock').attributes('data-user-id')).toBe('john')
})
it('should render reactions component', () => {
const post = createMockPost({ id: 42 })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-reactions-mock').attributes('data-post-id')).toBe('42')
})
it('should render edited badge when post is edited', () => {
const post = createMockPost({ isEdited: true, editedAt: Date.now() / 1000 })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.edited-badge').exists()).toBe(true)
expect(wrapper.find('.edited-label').text()).toBe('Edited')
})
it('should not render edited badge when post is not edited', () => {
const post = createMockPost({ isEdited: false })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.edited-badge').exists()).toBe(false)
})
})
describe('CSS classes', () => {
it('should apply first-post class when isFirstPost is true', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post, isFirstPost: true },
})
expect(wrapper.find('.post-card').classes()).toContain('first-post')
})
it('should apply unread class when isUnread is true', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post, isUnread: true },
})
expect(wrapper.find('.post-card').classes()).toContain('unread')
})
it('should show unread indicator when isUnread is true', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post, isUnread: true },
})
expect(wrapper.find('.unread-indicator').exists()).toBe(true)
})
})
describe('signature', () => {
it('should render signature when author has one', () => {
const author = createMockUser({ signature: '<p>My signature</p>' })
const post = createMockPost({ author })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-signature').exists()).toBe(true)
expect(wrapper.find('.signature-content').html()).toContain('My signature')
})
it('should not render signature when author has none', () => {
const author = createMockUser({ signature: null })
const post = createMockPost({ author })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-signature').exists()).toBe(false)
})
})
describe('action buttons', () => {
it('should always show reply button', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Quote reply'))).toBe(true)
})
it('should show edit button when user is author', () => {
mockCurrentUser.mockReturnValue({ uid: 'author123' })
const post = createMockPost({ authorId: 'author123' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should show edit button when user is admin', () => {
mockCurrentUser.mockReturnValue({ uid: 'admin' })
mockIsAdmin.mockReturnValue(true)
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should show edit button when user is moderator', () => {
mockCurrentUser.mockReturnValue({ uid: 'mod' })
mockIsModerator.mockReturnValue(true)
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should show edit button when user can moderate category', () => {
mockCurrentUser.mockReturnValue({ uid: 'catmod' })
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post, canModerateCategory: true },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should not show edit button when user has no permissions', () => {
mockCurrentUser.mockReturnValue({ uid: 'random_user' })
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false)
})
it('should show view history button when post is edited', () => {
const post = createMockPost({ isEdited: true })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(true)
})
it('should not show view history button when post is not edited', () => {
const post = createMockPost({ isEdited: false })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(false)
})
})
describe('events', () => {
it('should emit reply event when reply button is clicked', async () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post },
})
const replyButton = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Quote reply'))
await replyButton?.trigger('click')
expect(wrapper.emitted('reply')).toBeTruthy()
expect(wrapper.emitted('reply')![0]).toEqual([post])
})
it('should emit delete event when delete is confirmed', async () => {
const confirmMock = vi.fn(() => true)
vi.stubGlobal('confirm', confirmMock)
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
const deleteButton = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Delete'))
await deleteButton?.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')![0]).toEqual([post])
vi.unstubAllGlobals()
})
it('should not emit delete event when delete is cancelled', async () => {
const confirmMock = vi.fn(() => false)
vi.stubGlobal('confirm', confirmMock)
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
const deleteButton = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Delete'))
await deleteButton?.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('delete')).toBeFalsy()
vi.unstubAllGlobals()
})
})
describe('edit mode', () => {
it('should show edit form when edit button is clicked', async () => {
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author', contentRaw: 'Raw content' })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(false)
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
await editButton?.trigger('click')
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(true)
expect(wrapper.find('.content-text').exists()).toBe(false)
})
it('should hide reactions when in edit mode', async () => {
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-reactions-mock').exists()).toBe(true)
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
await editButton?.trigger('click')
expect(wrapper.find('.post-reactions-mock').exists()).toBe(false)
})
it('should exit edit mode when cancel is triggered', async () => {
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
await editButton?.trigger('click')
const vm = wrapper.vm as InstanceType<typeof PostCard>
vm.cancelEdit()
await wrapper.vm.$nextTick()
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(false)
expect(wrapper.find('.content-text').exists()).toBe(true)
})
})
describe('unauthenticated user', () => {
it('should not show edit or delete buttons when not logged in', () => {
mockCurrentUser.mockReturnValue(null)
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false)
expect(buttons.some((b) => b.text().includes('Delete'))).toBe(false)
})
})
})

View File

@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
import PostEditForm from './PostEditForm.vue'
// Mock BBCodeEditor
vi.mock('@/components/BBCodeEditor', () =>
createComponentMock('BBCodeEditor', {
template: `<div class="bbcode-editor-mock">
<textarea
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
@keydown="$emit('keydown', $event)"
/>
</div>`,
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
emits: ['update:modelValue', 'keydown'],
}),
)
// Mock NcLoadingIcon
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () =>
createComponentMock('NcLoadingIcon', {
template: '<span class="loading-icon-mock" />',
props: ['size'],
}),
)
describe('PostEditForm', () => {
const initialContent = 'Original post content'
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render with initial content', () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe(initialContent)
})
it('should render cancel and save buttons', () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0]!.text()).toBe('Cancel')
expect(buttons[1]!.text()).toBe('Save')
})
})
describe('submit button state', () => {
it('should disable save button when content is unchanged', () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it('should disable save button when content is empty', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('')
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it('should disable save button when content is only whitespace', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue(' ')
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it('should enable save button when content is changed and not empty', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Updated content')
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeUndefined()
})
})
describe('submit', () => {
it('should emit submit with trimmed content when save is clicked', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue(' New content with spaces ')
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')![0]).toEqual(['New content with spaces'])
})
it('should not emit submit when content is unchanged', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('cancel', () => {
it('should emit cancel when cancel button is clicked with no changes', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('should show confirmation when canceling with changes', async () => {
const confirmMock = vi.fn(() => false)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Changed content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('cancel')).toBeFalsy()
vi.unstubAllGlobals()
})
it('should emit cancel when confirmation is accepted', async () => {
const confirmMock = vi.fn(() => true)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Changed content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('cancel')).toBeTruthy()
vi.unstubAllGlobals()
})
})
describe('submitting state', () => {
it('should disable buttons when submitting', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('New content')
// Trigger submit
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
// Both buttons should be disabled
const buttons = wrapper.findAll('button')
expect(buttons[0]!.attributes('disabled')).toBeDefined()
expect(buttons[1]!.attributes('disabled')).toBeDefined()
})
it('should disable editor when submitting', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('New content')
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
})
it('should expose setSubmitting method', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
vm.setSubmitting(true)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
vm.setSubmitting(false)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined()
})
})
describe('computed properties', () => {
it('should correctly compute hasChanges', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
expect(vm.hasChanges).toBe(false)
const textarea = wrapper.find('textarea')
await textarea.setValue('Different content')
expect(vm.hasChanges).toBe(true)
})
it('should correctly compute canSubmit', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
// Same content - cannot submit
expect(vm.canSubmit).toBe(false)
const textarea = wrapper.find('textarea')
// Empty content - cannot submit
await textarea.setValue('')
expect(vm.canSubmit).toBe(false)
// Different non-empty content - can submit
await textarea.setValue('New content')
expect(vm.canSubmit).toBe(true)
})
})
})

View File

@@ -0,0 +1,409 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
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(),
},
}))
// Mock icons
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
// Mock UserInfo component
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<span class="user-info-mock">{{ displayName }}</span>',
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'inline'],
}),
)
// Import after mocks
import { ocs } from '@/axios'
import PostHistoryDialog from './PostHistoryDialog.vue'
const mockGet = vi.mocked(ocs.get)
describe('PostHistoryDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: null } as never)
})
const createMockUser = (overrides: Partial<User> = {}): User => ({
userId: 'testuser',
displayName: 'Test User',
isDeleted: false,
roles: [],
signature: null,
signatureRaw: null,
...overrides,
})
const createMockPost = (overrides: Partial<Post> = {}): Post => ({
id: 1,
threadId: 1,
authorId: 'testuser',
content: '<p>Current content</p>',
contentRaw: 'Current content',
isEdited: true,
isFirstPost: false,
editedAt: 1700000000,
createdAt: 1699000000,
updatedAt: 1700000000,
author: createMockUser(),
...overrides,
})
const createMockHistoryEntry = (overrides: Partial<PostHistoryEntry> = {}): PostHistoryEntry => ({
id: 1,
postId: 1,
content: '<p>Old content</p>',
editedBy: 'editor1',
editedAt: 1699500000,
editor: createMockUser({ userId: 'editor1', displayName: 'Editor One' }),
...overrides,
})
const createMockHistoryResponse = (
overrides: Partial<PostHistoryResponse> = {},
): PostHistoryResponse => ({
current: createMockPost(),
history: [createMockHistoryEntry()],
...overrides,
})
const createWrapper = (props = {}) => {
return mount(PostHistoryDialog, {
props: {
open: true,
postId: 1,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render the dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('passes the correct title to dialog', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper()
await flushPromises()
// The title is passed as a prop to NcDialog, not rendered as text
const vm = wrapper.vm as unknown as { strings: { title: string } }
expect(vm.strings.title).toBe('Edit history')
})
})
describe('loading state', () => {
it('shows loading state while fetching history', async () => {
let resolvePromise: (value: unknown) => void
mockGet.mockImplementation(
() =>
new Promise((resolve) => {
resolvePromise = resolve
}) as never,
)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading history')
resolvePromise!({ data: createMockHistoryResponse() })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(false)
})
})
describe('error state', () => {
it('displays error state when fetch fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.error-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Failed to load edit history')
})
})
describe('empty state', () => {
it('displays empty state when no history exists', async () => {
mockGet.mockResolvedValue({
data: createMockHistoryResponse({ history: [] }),
} as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.empty-state').exists()).toBe(true)
expect(wrapper.text()).toContain('This post has no edit history')
})
it('displays history icon in empty state', async () => {
mockGet.mockResolvedValue({
data: createMockHistoryResponse({ history: [] }),
} as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
// The icon mock uses kebab-case class name: HistoryIcon -> .history-icon
expect(wrapper.find('.history-icon').exists()).toBe(true)
})
})
describe('history content', () => {
it('displays current version', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.current-version').exists()).toBe(true)
expect(wrapper.text()).toContain('Current version')
})
it('displays current version content', async () => {
const response = createMockHistoryResponse({
current: createMockPost({ content: '<p>This is current content</p>' }),
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.entry-content').html()).toContain('This is current content')
})
it('displays historical versions', async () => {
const response = createMockHistoryResponse({
history: [
createMockHistoryEntry({ id: 1, content: '<p>Version 1 content</p>' }),
createMockHistoryEntry({ id: 2, content: '<p>Version 2 content</p>' }),
],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const entries = wrapper.findAll('.history-entry')
// 1 current + 2 historical
expect(entries.length).toBe(3)
})
it('displays version labels correctly', async () => {
const response = createMockHistoryResponse({
history: [createMockHistoryEntry({ id: 1 }), createMockHistoryEntry({ id: 2 })],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
// Translation mock replaces {index} with actual values
// Version labels should be "Version 2", "Version 1" (in reverse order)
expect(wrapper.text()).toContain('Version 2')
expect(wrapper.text()).toContain('Version 1')
})
it('displays editor info for historical versions', async () => {
const response = createMockHistoryResponse({
history: [
createMockHistoryEntry({
editor: createMockUser({ userId: 'editor1', displayName: 'Editor One' }),
}),
],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.text()).toContain('Edited by')
expect(wrapper.find('.user-info-mock').exists()).toBe(true)
})
})
describe('API calls', () => {
it('fetches history when dialog opens', async () => {
const wrapper = createWrapper({ open: true, postId: 42 })
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/posts/42/history')
})
it('does not fetch when dialog is closed', async () => {
createWrapper({ open: false, postId: 42 })
await flushPromises()
expect(mockGet).not.toHaveBeenCalled()
})
it('refetches when dialog reopens', async () => {
const wrapper = createWrapper({ open: true, postId: 42 })
await flushPromises()
expect(mockGet).toHaveBeenCalledTimes(1)
// Close
await wrapper.setProps({ open: false })
await flushPromises()
// Reopen
await wrapper.setProps({ open: true })
await flushPromises()
expect(mockGet).toHaveBeenCalledTimes(2)
})
it('clears data when dialog closes', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.history-content').exists()).toBe(true)
await wrapper.setProps({ open: false })
await flushPromises()
// Reopen - should show loading again
mockGet.mockImplementation(
() =>
new Promise(() => {
/* never resolves */
}) as never,
)
await wrapper.setProps({ open: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
})
})
describe('close event', () => {
it('emits update:open event when close button is clicked', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const closeButton = wrapper.find('button')
await closeButton.trigger('click')
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('emits update:open event when handleClose is called', async () => {
const wrapper = createWrapper({ open: true })
;(wrapper.vm as unknown as { handleClose: () => void }).handleClose()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
describe('timestamps', () => {
it('displays editedAt timestamp for current version when edited', async () => {
const response = createMockHistoryResponse({
current: createMockPost({ editedAt: 1700000000, createdAt: 1699000000 }),
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const dateTime = wrapper.find('.current-version .nc-datetime')
expect(dateTime.exists()).toBe(true)
// editedAt * 1000 = 1700000000000
expect(dateTime.attributes('data-timestamp')).toBe('1700000000000')
})
it('displays createdAt timestamp for current version when not edited', async () => {
const response = createMockHistoryResponse({
current: createMockPost({ editedAt: null, createdAt: 1699000000 }),
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const dateTime = wrapper.find('.current-version .nc-datetime')
expect(dateTime.exists()).toBe(true)
// createdAt * 1000 = 1699000000000
expect(dateTime.attributes('data-timestamp')).toBe('1699000000000')
})
it('displays timestamps for historical versions', async () => {
const response = createMockHistoryResponse({
history: [createMockHistoryEntry({ editedAt: 1699500000 })],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const entries = wrapper.findAll('.history-entry:not(.current-version)')
expect(entries.length).toBeGreaterThan(0)
const dateTime = entries[0]!.find('.nc-datetime')
expect(dateTime.exists()).toBe(true)
expect(dateTime.attributes('data-timestamp')).toBe('1699500000000')
})
})
describe('edge cases', () => {
it('handles null historyData gracefully', async () => {
mockGet.mockResolvedValue({ data: null } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.empty-state').exists()).toBe(true)
})
it('uses editedBy as fallback when editor is not available', async () => {
const response = createMockHistoryResponse({
history: [
{
id: 1,
postId: 1,
content: '<p>Old content</p>',
editedBy: 'fallback_user',
editedAt: 1699500000,
editor: undefined,
},
],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
// Should use editedBy as userId and displayName
const userInfo = wrapper.find('.user-info-mock')
expect(userInfo.text()).toBe('fallback_user')
})
})
})

View File

@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
import PostReactions from './PostReactions.vue'
import type { ReactionGroup } from '@/composables/useReactions'
// Mock LazyEmojiPicker
vi.mock('@/components/LazyEmojiPicker', () =>
createComponentMock('LazyEmojiPicker', {
template: '<div class="emoji-picker-mock"><slot /></div>',
props: [],
}),
)
// Mock useReactions composable
const mockToggleReaction = vi.fn()
vi.mock('@/composables/useReactions', () => ({
useReactions: () => ({
toggleReaction: mockToggleReaction,
}),
}))
// Mock getCurrentUser
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => ({ uid: 'testuser', displayName: 'Test User' }),
}))
describe('PostReactions', () => {
beforeEach(() => {
mockToggleReaction.mockReset()
})
const defaultEmojis = ['👍', '❤️', '😄', '🎉', '👏']
describe('rendering', () => {
it('should render default emojis', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const buttons = wrapper.findAll('.reaction-button')
expect(buttons.length).toBe(defaultEmojis.length)
defaultEmojis.forEach((emoji) => {
expect(wrapper.text()).toContain(emoji)
})
})
it('should render add reaction button', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
expect(wrapper.find('.add-reaction-button').exists()).toBe(true)
})
it('should display reaction counts when present', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 5, hasReacted: false, userIds: ['user1', 'user2'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
expect(wrapper.find('.count').text()).toBe('5')
})
it('should not display count when zero', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.find('.count').exists()).toBe(false)
})
})
describe('CSS classes', () => {
it('should apply reacted class when user has reacted', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.classes()).toContain('reacted')
})
it('should not apply reacted class when user has not reacted', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: false, userIds: ['otheruser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.classes()).not.toContain('reacted')
})
it('should apply has-count class when count is greater than zero', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 3, hasReacted: false, userIds: ['user1'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.classes()).toContain('has-count')
})
})
describe('sorting', () => {
it('should sort emojis by count (highest first)', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 2, hasReacted: false, userIds: [] },
{ emoji: '❤️', count: 10, hasReacted: false, userIds: [] },
{ emoji: '😄', count: 5, hasReacted: false, userIds: [] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const buttons = wrapper.findAll('.reaction-button')
const emojis = buttons.map((b) => b.find('.emoji').text())
// ❤️ (10) should be first, then 😄 (5), then 👍 (2)
expect(emojis[0]).toBe('❤️')
expect(emojis[1]).toBe('😄')
expect(emojis[2]).toBe('👍')
})
it('should preserve default order for equal counts', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const buttons = wrapper.findAll('.reaction-button')
const emojis = buttons.map((b) => b.find('.emoji').text())
expect(emojis).toEqual(defaultEmojis)
})
it('should show custom emojis with reactions', () => {
const reactions: ReactionGroup[] = [{ emoji: '🚀', count: 3, hasReacted: false, userIds: [] }]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
expect(wrapper.text()).toContain('🚀')
})
})
describe('tooltips', () => {
it('should show "React with" tooltip for zero reactions', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.attributes('title')).toBe('React with 👍')
})
it('should show "You reacted" tooltip when user is sole reactor', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.attributes('title')).toBe('You reacted with 👍')
})
it('should show count tooltip when user has not reacted', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 3, hasReacted: false, userIds: ['a', 'b', 'c'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.attributes('title')).toBe('%n people reacted with 👍')
})
})
describe('toggle reaction', () => {
it('should call toggleReaction when clicking a reaction button', async () => {
mockToggleReaction.mockResolvedValue({ action: 'added' })
const wrapper = mount(PostReactions, {
props: { postId: 42, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
expect(mockToggleReaction).toHaveBeenCalledWith(42, '👍')
})
it('should emit update event after toggling reaction', async () => {
mockToggleReaction.mockResolvedValue({ action: 'added' })
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
expect(wrapper.emitted('update')).toBeTruthy()
})
it('should update local state when adding reaction', async () => {
mockToggleReaction.mockResolvedValue({ action: 'added' })
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
// Wait for async update
await wrapper.vm.$nextTick()
// Check that the button now shows as reacted
expect(thumbsUpButton.classes()).toContain('reacted')
expect(thumbsUpButton.find('.count').text()).toBe('1')
})
it('should update local state when removing reaction', async () => {
mockToggleReaction.mockResolvedValue({ action: 'removed' })
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
await wrapper.vm.$nextTick()
expect(thumbsUpButton.classes()).not.toContain('reacted')
})
})
describe('props reactivity', () => {
it('should update when reactions prop changes', async () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
// Initially no count
expect(wrapper.find('.count').exists()).toBe(false)
// Update reactions
await wrapper.setProps({
reactions: [{ emoji: '👍', count: 5, hasReacted: false, userIds: [] }],
})
expect(wrapper.find('.count').text()).toBe('5')
})
})
})

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import PostReplyForm from './PostReplyForm.vue'
// Mock BBCodeEditor
vi.mock('@/components/BBCodeEditor', () =>
createComponentMock('BBCodeEditor', {
template: `<div class="bbcode-editor-mock">
<textarea
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
@keydown="$emit('keydown', $event)"
/>
</div>`,
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
emits: ['update:modelValue', 'keydown'],
}),
)
// Mock UserInfo
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<div class="user-info-mock" :data-user-id="userId">{{ displayName }}</div>',
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
}),
)
// Mock icons
vi.mock('@icons/Send.vue', () => createIconMock('SendIcon'))
// Mock NcLoadingIcon
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () =>
createComponentMock('NcLoadingIcon', {
template: '<span class="loading-icon-mock" />',
props: ['size'],
}),
)
// Mock useCurrentUser composable
vi.mock('@/composables/useCurrentUser', () => ({
useCurrentUser: () => ({
userId: 'testuser',
displayName: 'Test User',
}),
}))
describe('PostReplyForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render user info header', () => {
const wrapper = mount(PostReplyForm)
const userInfo = wrapper.find('.user-info-mock')
expect(userInfo.exists()).toBe(true)
expect(userInfo.attributes('data-user-id')).toBe('testuser')
expect(userInfo.text()).toBe('Test User')
})
it('should render editor', () => {
const wrapper = mount(PostReplyForm)
expect(wrapper.find('.bbcode-editor-mock').exists()).toBe(true)
})
it('should render cancel and submit buttons', () => {
const wrapper = mount(PostReplyForm)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0]!.text()).toBe('Cancel')
expect(buttons[1]!.text()).toContain('Submit reply')
})
it('should render send icon in submit button', () => {
const wrapper = mount(PostReplyForm)
expect(wrapper.find('.send-icon').exists()).toBe(true)
})
})
describe('button states', () => {
it('should disable submit button when content is empty', () => {
const wrapper = mount(PostReplyForm)
const submitButton = wrapper.findAll('button')[1]!
expect(submitButton.attributes('disabled')).toBeDefined()
})
it('should disable cancel button when content is empty', () => {
const wrapper = mount(PostReplyForm)
const cancelButton = wrapper.findAll('button')[0]!
expect(cancelButton.attributes('disabled')).toBeDefined()
})
it('should enable submit button when content is not empty', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some reply content')
const submitButton = wrapper.findAll('button')[1]!
expect(submitButton.attributes('disabled')).toBeUndefined()
})
it('should enable cancel button when content is not empty', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some reply content')
const cancelButton = wrapper.findAll('button')[0]!
expect(cancelButton.attributes('disabled')).toBeUndefined()
})
})
describe('submit', () => {
it('should emit submit with trimmed content', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue(' Reply content with spaces ')
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')![0]).toEqual(['Reply content with spaces'])
})
it('should not emit submit when content is empty', async () => {
const wrapper = mount(PostReplyForm)
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('cancel', () => {
it('should show confirmation when canceling with content', async () => {
const confirmMock = vi.fn(() => false)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('cancel')).toBeFalsy()
vi.unstubAllGlobals()
})
it('should emit cancel and clear content when confirmation is accepted', async () => {
const confirmMock = vi.fn(() => true)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
expect(textarea.element.value).toBe('')
vi.unstubAllGlobals()
})
})
describe('exposed methods', () => {
it('should clear content with clear()', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
vm.clear()
await wrapper.vm.$nextTick()
expect(textarea.element.value).toBe('')
})
it('should set submitting state with setSubmitting()', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
vm.setSubmitting(true)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
expect(wrapper.find('.loading-icon-mock').exists()).toBe(true)
vm.setSubmitting(false)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined()
})
it('should set quoted content with setQuotedContent()', async () => {
const wrapper = mount(PostReplyForm)
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
vm.setQuotedContent('Original message')
await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe('[quote]Original message[/quote]\n')
})
})
describe('submitting state', () => {
it('should disable editor when submitting', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Reply content')
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
})
it('should show loading icon when submitting', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Reply content')
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.find('.loading-icon-mock').exists()).toBe(true)
})
})
})

View File

@@ -1,30 +1,9 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import RoleBadge from './RoleBadge.vue'
import type { Role } from '@/types/models'
import { createMockRole } from '@/test-mocks'
// Mock isDarkTheme - default to light theme
vi.mock('@nextcloud/vue/functions/isDarkTheme', () => ({
isDarkTheme: false,
}))
// Helper to create a mock role
function createMockRole(overrides: Partial<Role> = {}): Role {
return {
id: 100,
name: 'Test Role',
description: null,
colorLight: null,
colorDark: null,
canAccessAdminTools: false,
canEditRoles: false,
canEditCategories: false,
isSystemRole: false,
roleType: 'custom',
createdAt: Date.now(),
...overrides,
}
}
// Uses global mock for @nextcloud/vue/functions/isDarkTheme from test-setup.ts
describe('RoleBadge', () => {
describe('rendering', () => {
@@ -69,7 +48,6 @@ describe('RoleBadge', () => {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Fallback light color for Admin is #dc2626
expect(style).toContain('background-color: #dc2626')
})
@@ -79,7 +57,6 @@ describe('RoleBadge', () => {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Fallback light color for Moderator is #2563eb
expect(style).toContain('background-color: #2563eb')
})
@@ -89,7 +66,6 @@ describe('RoleBadge', () => {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Fallback light color for User is #059669
expect(style).toContain('background-color: #059669')
})
@@ -99,14 +75,12 @@ describe('RoleBadge', () => {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Default light fallback is #000000
expect(style).toContain('background-color: #000000')
})
})
describe('text color calculation (contrast)', () => {
it('should use dark text on light backgrounds', () => {
// White background should have black text
const role = createMockRole({ colorLight: '#ffffff' })
const wrapper = mount(RoleBadge, {
props: { role },
@@ -116,7 +90,6 @@ describe('RoleBadge', () => {
})
it('should use light text on dark backgrounds', () => {
// Black background should have white text
const role = createMockRole({ colorLight: '#000000' })
const wrapper = mount(RoleBadge, {
props: { role },
@@ -126,7 +99,6 @@ describe('RoleBadge', () => {
})
it('should use light text on moderately dark backgrounds', () => {
// Dark blue should have white text
const role = createMockRole({ colorLight: '#1e3a5f' })
const wrapper = mount(RoleBadge, {
props: { role },
@@ -136,7 +108,6 @@ describe('RoleBadge', () => {
})
it('should use dark text on moderately light backgrounds', () => {
// Light yellow should have black text
const role = createMockRole({ colorLight: '#ffeb3b' })
const wrapper = mount(RoleBadge, {
props: { role },
@@ -152,7 +123,6 @@ describe('RoleBadge', () => {
const wrapper = mount(RoleBadge, {
props: { role },
})
// Access the method via vm
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
@@ -168,7 +138,6 @@ describe('RoleBadge', () => {
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
// #f00 expands to #ff0000
const result = vm.hexToRgb('#f00')
expect(result).toEqual({ r: 255, g: 0, b: 0 })
})

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock } from '@/test-utils'
import { createMockPost, createMockUser } from '@/test-mocks'
import SearchPostResult from './SearchPostResult.vue'
// Uses global mocks for @nextcloud/l10n, isDarkTheme, NcDateTime from test-setup.ts
vi.mock('@icons/Account.vue', () => createIconMock('AccountIcon'))
vi.mock('@icons/Clock.vue', () => createIconMock('ClockIcon'))
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
describe('SearchPostResult', () => {
beforeEach(() => {
mockPush.mockClear()
})
describe('rendering', () => {
it('should render thread title link', () => {
const post = createMockPost({ threadTitle: 'Discussion Thread' })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: {
'router-link': {
template: '<a class="thread-link"><slot /></a>',
props: ['to'],
},
},
mocks: { $router: { push: mockPush } },
},
})
expect(wrapper.find('.thread-link').text()).toBe('Discussion Thread')
})
it('should show thread unavailable when no slug', () => {
const post = createMockPost({ threadSlug: undefined })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
expect(wrapper.find('.thread-missing').text()).toBe('Thread unavailable')
})
it('should render author name', () => {
const post = createMockPost({
author: createMockUser({ userId: 'john', displayName: 'John Doe' }),
})
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
expect(wrapper.find('.author').text()).toContain('John Doe')
})
it('should show "Deleted user" when author is missing', () => {
const post = createMockPost()
post.author = undefined
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
expect(wrapper.find('.author').text()).toContain('Deleted user')
})
it('should show edited indicator when post is edited', () => {
const post = createMockPost({ isEdited: true })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
expect(wrapper.find('.edited').exists()).toBe(true)
expect(wrapper.find('.pencil-icon').exists()).toBe(true)
})
it('should not show edited indicator when post is not edited', () => {
const post = createMockPost({ isEdited: false })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
expect(wrapper.find('.edited').exists()).toBe(false)
})
})
describe('content processing', () => {
it('should strip HTML from content', () => {
const post = createMockPost({ content: '<p>Hello <strong>World</strong></p>' })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'xyz' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
const content = wrapper.find('.post-content').text()
expect(content).not.toContain('<p>')
expect(content).not.toContain('<strong>')
})
it('should truncate long content', () => {
const longContent = '<p>' + 'A'.repeat(500) + '</p>'
const post = createMockPost({ content: longContent })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'xyz' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
const content = wrapper.find('.post-content').text()
expect(content.length).toBeLessThan(300)
expect(content).toContain('...')
})
it('should highlight search terms', () => {
const post = createMockPost({ content: '<p>This is a test post</p>' })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
expect(wrapper.find('.post-content').html()).toContain('<mark>test</mark>')
})
})
describe('navigation', () => {
it('should navigate to post when clicked', async () => {
const post = createMockPost({ threadSlug: 'my-thread', id: 42 })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
await wrapper.find('.search-post-result').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/t/my-thread#post-42')
})
it('should not navigate when thread slug is missing', async () => {
const post = createMockPost({ threadSlug: undefined })
const wrapper = mount(SearchPostResult, {
props: { post, query: 'test' },
global: {
stubs: { 'router-link': true },
mocks: { $router: { push: mockPush } },
},
})
await wrapper.find('.search-post-result').trigger('click')
expect(mockPush).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock } from '@/test-utils'
import { createMockThread, createMockUser } from '@/test-mocks'
import SearchThreadResult from './SearchThreadResult.vue'
// Uses global mocks for @nextcloud/l10n, isDarkTheme, NcDateTime from test-setup.ts
vi.mock('@icons/Folder.vue', () => createIconMock('FolderIcon'))
vi.mock('@icons/Account.vue', () => createIconMock('AccountIcon'))
vi.mock('@icons/Message.vue', () => createIconMock('MessageIcon'))
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
vi.mock('@icons/Clock.vue', () => createIconMock('ClockIcon'))
vi.mock('@icons/Pin.vue', () => createIconMock('PinIcon'))
vi.mock('@icons/Lock.vue', () => createIconMock('LockIcon'))
describe('SearchThreadResult', () => {
describe('rendering', () => {
it('should render thread title', () => {
const thread = createMockThread({ title: 'How to configure settings' })
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
expect(wrapper.find('.thread-title').text()).toContain('How to configure settings')
})
it('should render category name', () => {
const thread = createMockThread({ categoryName: 'Technical Support' })
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
expect(wrapper.find('.category').text()).toContain('Technical Support')
})
it('should render author name', () => {
const thread = createMockThread({
author: createMockUser({ userId: 'john', displayName: 'John Doe' }),
})
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
expect(wrapper.find('.author').text()).toContain('John Doe')
})
it('should show "Deleted user" when author is missing', () => {
const thread = createMockThread()
thread.author = undefined
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
expect(wrapper.find('.author').text()).toContain('Deleted user')
})
})
describe('badges', () => {
it('should show pin icon when thread is pinned', () => {
const thread = createMockThread({ isPinned: true })
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
expect(wrapper.find('.pin-icon').exists()).toBe(true)
})
it('should show lock icon when thread is locked', () => {
const thread = createMockThread({ isLocked: true })
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
expect(wrapper.find('.lock-icon').exists()).toBe(true)
})
})
describe('highlighting', () => {
it('should highlight search terms in title', () => {
const thread = createMockThread({ title: 'How to test your code' })
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
expect(wrapper.find('.thread-title').html()).toContain('<mark>test</mark>')
})
it('should handle quoted phrases', () => {
const thread = createMockThread({ title: 'Testing the exact phrase match' })
const wrapper = mount(SearchThreadResult, {
props: { thread, query: '"exact phrase"' },
})
expect(wrapper.find('.thread-title').html()).toContain('<mark>exact phrase</mark>')
})
it('should exclude AND/OR operators from highlighting', () => {
const thread = createMockThread({ title: 'Test AND production environments' })
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test AND production' },
})
const html = wrapper.find('.thread-title').html().toLowerCase()
expect(html).toContain('<mark>test</mark>')
expect(html).toContain('<mark>production</mark>')
expect(html).not.toContain('<mark>and</mark>')
})
})
describe('events', () => {
it('should emit click event when clicked', async () => {
const thread = createMockThread()
const wrapper = mount(SearchThreadResult, {
props: { thread, query: 'test' },
})
await wrapper.find('.search-thread-result').trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Skeleton from './Skeleton.vue'
describe('Skeleton', () => {
describe('rendering', () => {
it('should render with default props', () => {
const wrapper = mount(Skeleton)
expect(wrapper.find('.skeleton').exists()).toBe(true)
})
it('should apply default width and height', () => {
const wrapper = mount(Skeleton)
const style = wrapper.find('.skeleton').attributes('style')
expect(style).toContain('width: 100%')
expect(style).toContain('height: 20px')
})
it('should apply custom width and height', () => {
const wrapper = mount(Skeleton, {
props: { width: '200px', height: '40px' },
})
const style = wrapper.find('.skeleton').attributes('style')
expect(style).toContain('width: 200px')
expect(style).toContain('height: 40px')
})
})
describe('shapes', () => {
it('should apply rounded-rect border radius by default', () => {
const wrapper = mount(Skeleton)
const style = wrapper.find('.skeleton').attributes('style')
expect(style).toContain('border-radius: 4px')
})
it('should apply circle border radius', () => {
const wrapper = mount(Skeleton, {
props: { shape: 'circle' },
})
const style = wrapper.find('.skeleton').attributes('style')
expect(style).toContain('border-radius: 50%')
})
it('should apply square border radius (0)', () => {
const wrapper = mount(Skeleton, {
props: { shape: 'square' },
})
const style = wrapper.find('.skeleton').attributes('style')
expect(style).toContain('border-radius: 0')
})
it('should apply custom radius for rounded-rect', () => {
const wrapper = mount(Skeleton, {
props: { shape: 'rounded-rect', radius: '8px' },
})
const style = wrapper.find('.skeleton').attributes('style')
expect(style).toContain('border-radius: 8px')
})
})
describe('getBorderRadius method', () => {
it('should return correct radius for each shape', () => {
const wrapper = mount(Skeleton, {
props: { radius: '10px' },
})
const vm = wrapper.vm as unknown as { getBorderRadius: () => string; shape: string }
// Test via computed style since method is internal
expect(wrapper.find('.skeleton').attributes('style')).toContain('border-radius: 10px')
})
})
})

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockThread } from '@/test-mocks'
import ThreadCard from './ThreadCard.vue'
// Uses global mocks for @nextcloud/l10n, NcDateTime from test-setup.ts
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<div class="user-info-mock"><slot name="meta" /></div>',
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'roles', 'showRoles', 'layout'],
}),
)
vi.mock('@icons/Pin.vue', () => createIconMock('PinIcon'))
vi.mock('@icons/Lock.vue', () => createIconMock('LockIcon'))
vi.mock('@icons/Comment.vue', () => createIconMock('CommentIcon'))
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
describe('ThreadCard', () => {
describe('rendering', () => {
it('should render thread title', () => {
const thread = createMockThread({ title: 'My First Thread' })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.thread-title').text()).toContain('My First Thread')
})
it('should render post count', () => {
const thread = createMockThread({ postCount: 25 })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.thread-stats').text()).toContain('25')
})
it('should render view count', () => {
const thread = createMockThread({ viewCount: 500 })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.thread-stats').text()).toContain('500')
})
})
describe('badges', () => {
it('should show pin icon when thread is pinned', () => {
const thread = createMockThread({ isPinned: true })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.pin-icon').exists()).toBe(true)
expect(wrapper.find('.badge-pinned').exists()).toBe(true)
})
it('should not show pin icon when thread is not pinned', () => {
const thread = createMockThread({ isPinned: false })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.pin-icon').exists()).toBe(false)
})
it('should show lock icon when thread is locked', () => {
const thread = createMockThread({ isLocked: true })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.lock-icon').exists()).toBe(true)
expect(wrapper.find('.badge-locked').exists()).toBe(true)
})
it('should not show lock icon when thread is not locked', () => {
const thread = createMockThread({ isLocked: false })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.lock-icon').exists()).toBe(false)
})
})
describe('CSS classes', () => {
it('should have pinned class when thread is pinned', () => {
const thread = createMockThread({ isPinned: true })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.thread-card').classes()).toContain('pinned')
})
it('should have locked class when thread is locked', () => {
const thread = createMockThread({ isLocked: true })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.thread-card').classes()).toContain('locked')
})
it('should have unread class when isUnread prop is true', () => {
const thread = createMockThread()
const wrapper = mount(ThreadCard, {
props: { thread, isUnread: true },
})
expect(wrapper.find('.thread-card').classes()).toContain('unread')
})
it('should show unread indicator when isUnread is true', () => {
const thread = createMockThread()
const wrapper = mount(ThreadCard, {
props: { thread, isUnread: true },
})
expect(wrapper.find('.unread-indicator').exists()).toBe(true)
})
it('should not show unread indicator by default', () => {
const thread = createMockThread()
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.unread-indicator').exists()).toBe(false)
})
})
describe('stats handling', () => {
it('should handle zero counts', () => {
const thread = createMockThread({ postCount: 0, viewCount: 0 })
const wrapper = mount(ThreadCard, {
props: { thread },
})
const statValues = wrapper.findAll('.stat-value')
expect(statValues[0]!.text()).toBe('0')
expect(statValues[1]!.text()).toBe('0')
})
})
})

View File

@@ -0,0 +1,512 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref, computed } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
// Mock useCurrentUser composable
vi.mock('@/composables/useCurrentUser', () => ({
useCurrentUser: () => ({
userId: computed(() => 'testuser'),
displayName: computed(() => 'Test User'),
}),
}))
// Mock icons
vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
vi.mock('@icons/ContentSave.vue', () => createIconMock('ContentSaveIcon'))
vi.mock('@icons/ContentSaveCheck.vue', () => createIconMock('ContentSaveCheckIcon'))
vi.mock('@icons/ContentSaveAlert.vue', () => createIconMock('ContentSaveAlertIcon'))
// Mock UserInfo component
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<div class="user-info-mock">{{ displayName }}</div>',
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
}),
)
// Mock BBCodeEditor component
vi.mock('@/components/BBCodeEditor', () => ({
default: {
name: 'BBCodeEditor',
template:
'<textarea class="bbcode-editor-mock" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :disabled="disabled" />',
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
emits: ['update:modelValue'],
methods: {
focus() {},
},
},
}))
// Import after mocks
import ThreadCreateForm from './ThreadCreateForm.vue'
describe('ThreadCreateForm', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal(
'confirm',
vi.fn(() => true),
)
})
const createWrapper = (props = {}) => {
return mount(ThreadCreateForm, {
props: {
...props,
},
})
}
describe('rendering', () => {
it('renders the form', () => {
const wrapper = createWrapper()
expect(wrapper.find('.thread-create-form').exists()).toBe(true)
})
it('renders user info header', () => {
const wrapper = createWrapper()
expect(wrapper.find('.user-info-mock').exists()).toBe(true)
})
it('renders title input', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-text-field').exists()).toBe(true)
})
it('renders BBCode editor', () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-editor-mock').exists()).toBe(true)
})
it('renders cancel button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
})
it('renders submit button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Create thread')).toBe(true)
})
})
describe('draft status', () => {
it('shows saving status', () => {
const wrapper = createWrapper({ draftStatus: 'saving' })
expect(wrapper.text()).toContain('Saving draft')
})
it('shows saved status', () => {
const wrapper = createWrapper({ draftStatus: 'saved' })
expect(wrapper.text()).toContain('Draft saved')
})
it('shows dirty status', () => {
const wrapper = createWrapper({ draftStatus: 'dirty' })
expect(wrapper.text()).toContain('Unsaved changes')
})
it('hides status when null', () => {
const wrapper = createWrapper({ draftStatus: null })
expect(wrapper.text()).not.toContain('Saving draft')
expect(wrapper.text()).not.toContain('Draft saved')
expect(wrapper.text()).not.toContain('Unsaved changes')
})
it('displays saving icon for saving status', () => {
const wrapper = createWrapper({ draftStatus: 'saving' })
expect(wrapper.find('.content-save-icon').exists()).toBe(true)
})
it('displays saved icon for saved status', () => {
const wrapper = createWrapper({ draftStatus: 'saved' })
expect(wrapper.find('.content-save-check-icon').exists()).toBe(true)
})
it('displays dirty icon for dirty status', () => {
const wrapper = createWrapper({ draftStatus: 'dirty' })
expect(wrapper.find('.content-save-alert-icon').exists()).toBe(true)
})
})
describe('validation', () => {
it('disables submit when title is empty', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { canSubmit: boolean }
expect(vm.canSubmit).toBe(false)
})
it('disables submit when content is empty', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; canSubmit: boolean }
vm.title = 'Test Title'
await flushPromises()
expect(vm.canSubmit).toBe(false)
})
it('enables submit when both title and content have values', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; content: string; canSubmit: boolean }
vm.title = 'Test Title'
vm.content = 'Test Content'
await flushPromises()
expect(vm.canSubmit).toBe(true)
})
it('disables submit when title is only whitespace', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; content: string; canSubmit: boolean }
vm.title = ' '
vm.content = 'Test Content'
await flushPromises()
expect(vm.canSubmit).toBe(false)
})
})
describe('submitting', () => {
it('emits submit event with trimmed data', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
submitThread: () => Promise<void>
}
vm.title = ' Test Title '
vm.content = ' Test Content '
await vm.submitThread()
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')![0]).toEqual([
{ title: 'Test Title', content: 'Test Content' },
])
})
it('sets submitting state to true', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
submitting: boolean
submitThread: () => Promise<void>
}
vm.title = 'Test Title'
vm.content = 'Test Content'
await vm.submitThread()
expect(vm.submitting).toBe(true)
})
it('does not submit when already submitting', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
submitting: boolean
submitThread: () => Promise<void>
}
vm.title = 'Test Title'
vm.content = 'Test Content'
vm.submitting = true
await vm.submitThread()
expect(wrapper.emitted('submit')).toBeFalsy()
})
it('does not submit when validation fails', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
submitThread: () => Promise<void>
}
vm.title = ''
vm.content = ''
await vm.submitThread()
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('canceling', () => {
it('emits cancel event when cancel is clicked without content', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { cancel: () => void }
vm.cancel()
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('shows confirmation when content exists', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
vm.title = 'Test Title'
vm.content = 'Test Content'
vm.cancel()
expect(window.confirm).toHaveBeenCalled()
})
it('emits cancel event when confirmed', async () => {
vi.stubGlobal(
'confirm',
vi.fn(() => true),
)
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
vm.title = 'Test Title'
vm.content = 'Test Content'
vm.cancel()
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('does not emit cancel event when not confirmed', async () => {
vi.stubGlobal(
'confirm',
vi.fn(() => false),
)
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
vm.title = 'Test Title'
vm.content = 'Test Content'
vm.cancel()
expect(wrapper.emitted('cancel')).toBeFalsy()
})
it('clears title and content on cancel', async () => {
vi.stubGlobal(
'confirm',
vi.fn(() => true),
)
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
cancel: () => void
}
vm.title = 'Test Title'
vm.content = 'Test Content'
vm.cancel()
expect(vm.title).toBe('')
expect(vm.content).toBe('')
})
})
describe('update events', () => {
it('emits update:title when title changes', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string }
vm.title = 'New Title'
await flushPromises()
expect(wrapper.emitted('update:title')).toBeTruthy()
expect(wrapper.emitted('update:title')![0]).toEqual(['New Title'])
})
it('emits update:content when content changes', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { content: string }
vm.content = 'New Content'
await flushPromises()
expect(wrapper.emitted('update:content')).toBeTruthy()
expect(wrapper.emitted('update:content')![0]).toEqual(['New Content'])
})
})
describe('clear method', () => {
it('resets title, content, and submitting state', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
submitting: boolean
clear: () => void
}
vm.title = 'Test Title'
vm.content = 'Test Content'
vm.submitting = true
vm.clear()
expect(vm.title).toBe('')
expect(vm.content).toBe('')
expect(vm.submitting).toBe(false)
})
})
describe('setSubmitting method', () => {
it('sets submitting state', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
submitting: boolean
setSubmitting: (value: boolean) => void
}
vm.setSubmitting(true)
expect(vm.submitting).toBe(true)
vm.setSubmitting(false)
expect(vm.submitting).toBe(false)
})
})
describe('setTitle method', () => {
it('sets title value', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
setTitle: (value: string) => void
}
vm.setTitle('New Title')
expect(vm.title).toBe('New Title')
})
})
describe('setContent method', () => {
it('sets content value', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
content: string
setContent: (value: string) => void
}
vm.setContent('New Content')
expect(vm.content).toBe('New Content')
})
})
describe('hasContent computed', () => {
it('returns false when both title and content are empty', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { hasContent: boolean }
expect(vm.hasContent).toBe(false)
})
it('returns true when title has content', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; hasContent: boolean }
vm.title = 'Test'
await flushPromises()
expect(vm.hasContent).toBe(true)
})
it('returns true when content has content', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { content: string; hasContent: boolean }
vm.content = 'Test'
await flushPromises()
expect(vm.hasContent).toBe(true)
})
it('returns false when title and content are only whitespace', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { title: string; content: string; hasContent: boolean }
vm.title = ' '
vm.content = ' '
expect(vm.hasContent).toBe(false)
})
})
describe('disabled state', () => {
it('disables inputs when submitting', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
submitting: boolean
}
vm.title = 'Test'
vm.content = 'Test'
vm.submitting = true
await flushPromises()
const titleInput = wrapper.find('.nc-text-field')
expect(titleInput.attributes('disabled')).toBeDefined()
})
it('disables cancel button when submitting', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { submitting: boolean }
vm.submitting = true
await flushPromises()
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
expect(cancelButton!.attributes('disabled')).toBeDefined()
})
it('disables submit button when submitting', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
title: string
content: string
submitting: boolean
}
vm.title = 'Test'
vm.content = 'Test'
vm.submitting = true
await flushPromises()
const submitButton = wrapper.findAll('button').find((b) => b.text() === 'Create thread')
expect(submitButton!.attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserAvatar from './UserAvatar.vue'
// Uses global mock for @nextcloud/vue/components/NcAvatar from test-setup.ts
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
describe('UserAvatar', () => {
beforeEach(() => {
mockPush.mockClear()
})
describe('rendering', () => {
it('should render avatar for active user', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-avatar').exists()).toBe(true)
expect(wrapper.find('.nc-avatar-mock').exists()).toBe(true)
})
it('should pass userId to NcAvatar', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'john_doe' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.nc-avatar-mock').attributes('data-user')).toBe('john_doe')
})
it('should pass size to NcAvatar', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser', size: 48 },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.nc-avatar-mock').attributes('data-size')).toBe('48')
})
it('should apply height style based on size', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser', size: 64 },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-avatar').attributes('style')).toContain('height: 64px')
})
})
describe('deleted user', () => {
it('should render differently for deleted user', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'deleteduser', isDeleted: true, displayName: 'Deleted User' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-avatar').exists()).toBe(true)
expect(wrapper.find('.user-avatar').classes()).not.toContain('clickable')
})
})
describe('clickable behavior', () => {
it('should have clickable class by default', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-avatar').classes()).toContain('clickable')
})
it('should not have clickable class when clickable is false', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser', clickable: false },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-avatar').classes()).not.toContain('clickable')
})
it('should not be clickable when user is deleted', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser', isDeleted: true, clickable: true },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-avatar').classes()).not.toContain('clickable')
})
it('should emit click and navigate when clicked', async () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser' },
global: { mocks: { $router: { push: mockPush } } },
})
await wrapper.find('.user-avatar').trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')![0]).toEqual(['testuser'])
expect(mockPush).toHaveBeenCalledWith('/u/testuser')
})
it('should not navigate when not clickable', async () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser', clickable: false },
global: { mocks: { $router: { push: mockPush } } },
})
await wrapper.find('.user-avatar').trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
expect(mockPush).not.toHaveBeenCalled()
})
})
describe('default props', () => {
it('should use default size of 32', () => {
const wrapper = mount(UserAvatar, {
props: { userId: 'testuser' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.nc-avatar-mock').attributes('data-size')).toBe('32')
})
})
})

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
import { createMockRole } from '@/test-mocks'
import UserInfo from './UserInfo.vue'
vi.mock('@/components/UserAvatar', () =>
createComponentMock('UserAvatar', {
template: '<div class="user-avatar-mock" :data-user-id="userId"></div>',
props: ['userId', 'displayName', 'size', 'isDeleted', 'clickable'],
}),
)
vi.mock('@/components/RoleBadge', () =>
createComponentMock('RoleBadge', {
template: '<span class="role-badge-mock">{{ role.name }}</span>',
props: ['role', 'density'],
}),
)
const mockPush = vi.fn()
describe('UserInfo', () => {
describe('rendering', () => {
it('should render user avatar', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', displayName: 'Test User' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-avatar-mock').exists()).toBe(true)
})
it('should render user name', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', displayName: 'Test User' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-name').text()).toBe('Test User')
})
it('should fallback to userId when displayName is empty', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', displayName: '' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-name').text()).toBe('testuser')
})
})
describe('layout', () => {
it('should apply column layout by default', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-info-component').classes()).not.toContain('layout-inline')
})
it('should apply inline layout when specified', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', layout: 'inline' },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-info-component').classes()).toContain('layout-inline')
})
})
describe('deleted user', () => {
it('should apply deleted-user class for deleted users', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', isDeleted: true },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-name').classes()).toContain('deleted-user')
})
it('should not be clickable when user is deleted', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', isDeleted: true, clickable: true },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.user-name').classes()).not.toContain('clickable')
})
})
describe('roles', () => {
it('should display admin role', () => {
const adminRole = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', roles: [adminRole] },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.role-badge-mock').exists()).toBe(true)
expect(wrapper.find('.role-badge-mock').text()).toBe('Admin')
})
it('should display custom roles', () => {
const customRole = createMockRole({ id: 10, name: 'VIP', roleType: 'custom' })
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', roles: [customRole] },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.role-badge-mock').text()).toBe('VIP')
})
it('should hide roles when showRoles is false', () => {
const adminRole = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', roles: [adminRole], showRoles: false },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.role-badge-mock').exists()).toBe(false)
})
it('should not display default role', () => {
const defaultRole = createMockRole({ id: 3, name: 'User', roleType: 'default' })
const wrapper = mount(UserInfo, {
props: { userId: 'testuser', roles: [defaultRole] },
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.role-badge-mock').exists()).toBe(false)
})
})
describe('slots', () => {
it('should render meta slot content', () => {
const wrapper = mount(UserInfo, {
props: { userId: 'testuser' },
slots: {
meta: '<span class="test-meta">Meta content</span>',
},
global: { mocks: { $router: { push: mockPush } } },
})
expect(wrapper.find('.test-meta').exists()).toBe(true)
})
})
})

129
src/test-mocks.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { Category, Post, Role, Thread, User } from '@/types'
// ============================================================================
// MODEL FACTORIES - Safe to use in tests (not in vi.mock factories)
// ============================================================================
/**
* Create a mock Role object with sensible defaults.
*
* @param overrides - Partial Role object to override defaults
*
* @example
* const role = createMockRole({ name: 'Admin', roleType: 'admin' })
*/
export function createMockRole(overrides: Partial<Role> = {}): Role {
return {
id: 100,
name: 'Test Role',
description: null,
colorLight: null,
colorDark: null,
canAccessAdminTools: false,
canEditRoles: false,
canEditCategories: false,
isSystemRole: false,
roleType: 'custom',
createdAt: Date.now(),
...overrides,
}
}
/**
* Create a mock User object with sensible defaults.
*
* @param overrides - Partial User object to override defaults
*
* @example
* const user = createMockUser({ userId: 'john', displayName: 'John Doe' })
*/
export function createMockUser(overrides: Partial<User> = {}): User {
return {
userId: 'testuser',
displayName: 'Test User',
isDeleted: false,
roles: [],
signature: null,
signatureRaw: null,
...overrides,
}
}
/**
* Create a mock Thread object with sensible defaults.
*
* @param overrides - Partial Thread object to override defaults
*
* @example
* const thread = createMockThread({ title: 'My Thread', isPinned: true })
*/
export function createMockThread(overrides: Partial<Thread> = {}): Thread {
return {
id: 1,
categoryId: 1,
authorId: 'testuser',
title: 'Test Thread',
slug: 'test-thread',
viewCount: 100,
postCount: 10,
lastPostId: null,
isLocked: false,
isPinned: false,
isHidden: false,
createdAt: Date.now() / 1000,
updatedAt: Date.now() / 1000,
author: createMockUser(),
...overrides,
}
}
/**
* Create a mock Post object with sensible defaults.
*
* @param overrides - Partial Post object to override defaults
*
* @example
* const post = createMockPost({ content: '<p>Hello world</p>' })
*/
export function createMockPost(overrides: Partial<Post> = {}): Post {
return {
id: 1,
threadId: 1,
authorId: 'testuser',
content: '<p>This is a test post content.</p>',
contentRaw: 'This is a test post content.',
isEdited: false,
isFirstPost: false,
editedAt: null,
createdAt: Date.now() / 1000,
updatedAt: Date.now() / 1000,
threadTitle: 'Test Thread',
threadSlug: 'test-thread',
author: createMockUser(),
...overrides,
}
}
/**
* Create a mock Category object with sensible defaults.
*
* @param overrides - Partial Category object to override defaults
*
* @example
* const category = createMockCategory({ name: 'General Discussion' })
*/
export function createMockCategory(overrides: Partial<Category> = {}): Category {
return {
id: 1,
headerId: 1,
name: 'Test Category',
description: 'Test description',
slug: 'test-category',
sortOrder: 0,
threadCount: 10,
postCount: 50,
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
}
}

135
src/test-setup.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Vitest global setup file.
*
* This file sets up global mocks that are commonly used across tests.
* These mocks are applied automatically to all test files.
*/
import { vi } from 'vitest'
// Mock @nextcloud/l10n globally
vi.mock('@nextcloud/l10n', () => ({
t: (_app: string, text: string, vars?: Record<string, unknown>) => {
if (vars) {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
text,
)
}
return text
},
n: (
_app: string,
singular: string,
plural: string,
count: number,
vars?: Record<string, unknown>,
) => {
let result = count === 1 ? singular : plural
if (vars) {
result = Object.entries(vars).reduce(
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
result,
)
}
return result
},
}))
// Mock @nextcloud/router globally
vi.mock('@nextcloud/router', () => ({
generateUrl: (path: string) => path,
}))
// Mock @nextcloud/vue/functions/isDarkTheme globally (defaults to light theme)
vi.mock('@nextcloud/vue/functions/isDarkTheme', () => ({
isDarkTheme: false,
}))
// Mock @nextcloud/vue components globally
vi.mock('@nextcloud/vue/components/NcDateTime', () => ({
default: {
name: 'NcDateTime',
template: '<span class="nc-datetime" :data-timestamp="timestamp" />',
props: ['timestamp'],
},
}))
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button :disabled="disabled" :href="href" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: ['variant', 'disabled', 'ariaLabel', 'title', 'href'],
},
}))
vi.mock('@nextcloud/vue/components/NcAvatar', () => ({
default: {
name: 'NcAvatar',
template: '<div class="nc-avatar-mock" :data-user="user" :data-size="size"></div>',
props: ['user', 'displayName', 'size'],
},
}))
vi.mock('@nextcloud/vue/components/NcEmptyContent', () => ({
default: {
name: 'NcEmptyContent',
template:
'<div class="nc-empty-content"><slot name="icon" /><span class="title">{{ title }}</span><span class="description">{{ description }}</span><slot name="action" /></div>',
props: ['title', 'description'],
},
}))
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
default: {
name: 'NcDialog',
template: '<div class="nc-dialog" v-if="open"><slot /><slot name="actions" /></div>',
props: ['name', 'open', 'size'],
},
}))
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({
default: {
name: 'NcLoadingIcon',
template: '<span class="nc-loading-icon" />',
props: ['size'],
},
}))
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
default: {
name: 'NcTextField',
template:
'<input class="nc-text-field" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :disabled="disabled" :placeholder="placeholder" />',
props: ['modelValue', 'label', 'placeholder', 'disabled', 'required', 'type'],
emits: ['update:modelValue'],
},
}))
vi.mock('@nextcloud/vue/components/NcTextArea', () => ({
default: {
name: 'NcTextArea',
template:
'<textarea class="nc-text-area" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :disabled="disabled" :placeholder="placeholder" />',
props: ['modelValue', 'label', 'placeholder', 'disabled', 'rows'],
emits: ['update:modelValue'],
},
}))
vi.mock('@nextcloud/vue/components/NcSelect', () => ({
default: {
name: 'NcSelect',
template:
'<select class="nc-select" :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /></select>',
props: ['modelValue', 'options', 'placeholder', 'label', 'trackBy', 'clearable'],
emits: ['update:modelValue'],
},
}))
vi.mock('@nextcloud/vue/components/NcNoteCard', () => ({
default: {
name: 'NcNoteCard',
template: '<div class="nc-note-card" :data-type="type"><slot /></div>',
props: ['type'],
},
}))

60
src/test-utils.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Test utilities and mock factories for Nextcloud dependencies.
*
* Usage in test files:
*
* import { vi } from 'vitest'
* import { createIconMock, createComponentMock } from '@/test-utils'
*
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
* vi.mock('@/components/UserInfo', () => createComponentMock('UserInfo', { ... }))
*/
/**
* Create a mock for an icon component from vue-material-design-icons.
*
* @param name - Component name (e.g., 'CheckIcon')
* @param className - Optional CSS class (defaults to kebab-case of name)
* @returns Mock factory object for vi.mock()
*
* @example
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
* // Creates: <span class="check-icon" data-icon="CheckIcon" />
*/
export function createIconMock(name: string, className?: string) {
const cssClass = className ?? name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
return {
default: {
name,
template: `<span class="${cssClass}" data-icon="${name}" />`,
props: ['size'],
},
}
}
/**
* Create a mock for a Vue component.
*
* @param name - Component name
* @param options - Optional template and props configuration
* @returns Mock factory object for vi.mock()
*
* @example
* vi.mock('@/components/UserInfo', () => createComponentMock('UserInfo', {
* template: '<div class="user-info-mock"><slot name="meta" /></div>',
* props: ['userId', 'displayName'],
* }))
*/
export function createComponentMock(
name: string,
options: { template?: string; props?: string[] } = {},
) {
const className = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
return {
default: {
name,
template: options.template ?? `<div class="${className}-mock" />`,
props: options.props ?? [],
},
}
}

View File

@@ -14,5 +14,6 @@ export default defineConfig({
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true,
setupFiles: ['src/test-setup.ts'],
},
})