Skip to content

Commit 640c934

Browse files
committed
test(dialog): unit-test the sharing dialog
Cover the Share client and its mutation delegation, the property field (rendering, debounced persistence, native validity, datetime), the inline toggle field, recipient-to-model mapping, the outcome summary helper and the share panel behavior (presets, permission toggles, recipient sync, summary and folder-upload notes). Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent 932b4d9 commit 640c934

6 files changed

Lines changed: 875 additions & 0 deletions

File tree

lib/dialog/api/share.spec.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
import type { INode } from '@nextcloud/files'
6+
import type { SharingShare } from '../types/api.ts'
7+
import type { Share } from './share.ts'
8+
9+
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
import { SOURCE_TYPE_NODE } from '../constants.ts'
11+
import { createShare, getShare, searchRecipients } from './share.ts'
12+
import * as client from './sharing.ts'
13+
14+
vi.mock('./sharing.ts', () => ({
15+
createShare: vi.fn(),
16+
getShare: vi.fn(),
17+
addShareSource: vi.fn(),
18+
removeShareSource: vi.fn(),
19+
addShareRecipient: vi.fn(),
20+
removeShareRecipient: vi.fn(),
21+
updateShareRecipientSecret: vi.fn(),
22+
updateShareProperty: vi.fn(),
23+
updateSharePermission: vi.fn(),
24+
selectSharePermissionPreset: vi.fn(),
25+
updateShareState: vi.fn(),
26+
searchRecipients: vi.fn(),
27+
deleteShare: vi.fn(),
28+
}))
29+
30+
const mocked = vi.mocked(client)
31+
32+
/**
33+
* Build a minimal share schema for testing.
34+
*
35+
* @param overrides Fields to override on the default schema
36+
*/
37+
function share(overrides: Partial<SharingShare> = {}): SharingShare {
38+
return {
39+
id: 'abc',
40+
owner: { user_id: 'alice', instance: null, display_name: 'Alice', icon: { svg: '' } },
41+
last_updated: 0,
42+
state: 'draft',
43+
sources: [],
44+
recipients: [],
45+
properties: [],
46+
permissions: [],
47+
permission_preset: null,
48+
...overrides,
49+
}
50+
}
51+
52+
/**
53+
* Obtain a Share instance wrapping the given schema, via the public factory.
54+
*
55+
* @param data The schema the instance should wrap
56+
*/
57+
async function makeShare(data: SharingShare = share()): Promise<Share> {
58+
mocked.getShare.mockResolvedValueOnce(data)
59+
return getShare(data.id)
60+
}
61+
62+
beforeEach(() => {
63+
vi.clearAllMocks()
64+
})
65+
66+
describe('Share', () => {
67+
it('createShare() wraps the created draft', async () => {
68+
mocked.createShare.mockResolvedValue(share({ id: 'new' }))
69+
const instance = await createShare()
70+
expect(mocked.createShare).toHaveBeenCalledOnce()
71+
expect(instance.id).toBe('new')
72+
expect(instance.state).toBe('draft')
73+
})
74+
75+
it('getShare() fetches an existing share', async () => {
76+
mocked.getShare.mockResolvedValue(share({ id: 'x' }))
77+
const instance = await getShare('x', 'secret', { password: 'p' })
78+
expect(mocked.getShare).toHaveBeenCalledWith('x', 'secret', { password: 'p' })
79+
expect(instance.id).toBe('x')
80+
})
81+
82+
it('exposes the schema through getters', async () => {
83+
const data = share({
84+
state: 'active',
85+
permission_preset: 'preset-a',
86+
sources: [{ class: 'S', value: '1', display_name: 'file', icon: null }],
87+
})
88+
const instance = await makeShare(data)
89+
expect(instance.data).toBe(data)
90+
expect(instance.state).toBe('active')
91+
expect(instance.permissionPreset).toBe('preset-a')
92+
expect(instance.sources).toHaveLength(1)
93+
})
94+
95+
it('mutations pass the id to the client and re-sync the data', async () => {
96+
const instance = await makeShare(share({ id: 'abc' }))
97+
const updated = share({ id: 'abc', state: 'active' })
98+
mocked.updateShareState.mockResolvedValue(updated)
99+
100+
const result = await instance.setState('active')
101+
102+
expect(mocked.updateShareState).toHaveBeenCalledWith('abc', 'active')
103+
expect(result).toBe(instance) // returns this for chaining
104+
expect(instance.data).toBe(updated) // internal data replaced
105+
expect(instance.state).toBe('active')
106+
})
107+
108+
it('setProperty forwards the class and value', async () => {
109+
const instance = await makeShare()
110+
mocked.updateShareProperty.mockResolvedValue(share())
111+
await instance.setProperty('P', 'v')
112+
expect(mocked.updateShareProperty).toHaveBeenCalledWith('abc', 'P', 'v')
113+
})
114+
115+
it('setPermission forwards the class and enabled flag', async () => {
116+
const instance = await makeShare()
117+
mocked.updateSharePermission.mockResolvedValue(share())
118+
await instance.setPermission('C', false)
119+
expect(mocked.updateSharePermission).toHaveBeenCalledWith('abc', 'C', false)
120+
})
121+
122+
it('addSource forwards the class and value', async () => {
123+
const instance = await makeShare()
124+
mocked.addShareSource.mockResolvedValue(share())
125+
await instance.addSource('S', '1')
126+
expect(mocked.addShareSource).toHaveBeenCalledWith('abc', 'S', '1')
127+
})
128+
129+
it('addNode maps the node to the node source type', async () => {
130+
const instance = await makeShare()
131+
mocked.addShareSource.mockResolvedValue(share())
132+
await instance.addNode({ fileid: 42 } as unknown as INode)
133+
expect(mocked.addShareSource).toHaveBeenCalledWith('abc', SOURCE_TYPE_NODE, '42')
134+
})
135+
136+
it('removeSource forwards the class and value', async () => {
137+
const instance = await makeShare()
138+
mocked.removeShareSource.mockResolvedValue(share())
139+
await instance.removeSource('S', '1')
140+
expect(mocked.removeShareSource).toHaveBeenCalledWith('abc', 'S', '1')
141+
})
142+
143+
it('addRecipient forwards class, value and instance', async () => {
144+
const instance = await makeShare()
145+
mocked.addShareRecipient.mockResolvedValue(share())
146+
await instance.addRecipient('R', 'bob', 'https://remote.example')
147+
expect(mocked.addShareRecipient).toHaveBeenCalledWith('abc', 'R', 'bob', 'https://remote.example')
148+
})
149+
150+
it('removeRecipient forwards class, value and instance', async () => {
151+
const instance = await makeShare()
152+
mocked.removeShareRecipient.mockResolvedValue(share())
153+
await instance.removeRecipient('R', 'bob')
154+
expect(mocked.removeShareRecipient).toHaveBeenCalledWith('abc', 'R', 'bob', undefined)
155+
})
156+
157+
it('setRecipientSecret forwards the secret', async () => {
158+
const instance = await makeShare()
159+
mocked.updateShareRecipientSecret.mockResolvedValue(share())
160+
await instance.setRecipientSecret('R', 'bob', 'sEcret')
161+
expect(mocked.updateShareRecipientSecret).toHaveBeenCalledWith('abc', 'R', 'bob', 'sEcret', undefined)
162+
})
163+
164+
it('selectPreset forwards the preset class', async () => {
165+
const instance = await makeShare()
166+
mocked.selectSharePermissionPreset.mockResolvedValue(share())
167+
await instance.selectPreset('preset-a')
168+
expect(mocked.selectSharePermissionPreset).toHaveBeenCalledWith('abc', 'preset-a')
169+
})
170+
171+
it('refresh re-fetches the share by id', async () => {
172+
const instance = await makeShare(share({ id: 'abc' }))
173+
const fresh = share({ id: 'abc', state: 'active' })
174+
mocked.getShare.mockResolvedValueOnce(fresh)
175+
await instance.refresh()
176+
expect(mocked.getShare).toHaveBeenLastCalledWith('abc')
177+
expect(instance.data).toBe(fresh)
178+
})
179+
180+
it('activate() sets the state to active', async () => {
181+
const instance = await makeShare()
182+
mocked.updateShareState.mockResolvedValue(share({ state: 'active' }))
183+
await instance.activate()
184+
expect(mocked.updateShareState).toHaveBeenCalledWith('abc', 'active')
185+
})
186+
187+
it('delete() removes the share and returns nothing', async () => {
188+
const instance = await makeShare()
189+
mocked.deleteShare.mockResolvedValue()
190+
await expect(instance.delete()).resolves.toBeUndefined()
191+
expect(mocked.deleteShare).toHaveBeenCalledWith('abc')
192+
})
193+
194+
it('searchRecipients delegates to the client', async () => {
195+
mocked.searchRecipients.mockResolvedValue([])
196+
await searchRecipients('bob', 'UserType', 5, 2)
197+
expect(mocked.searchRecipients).toHaveBeenCalledWith('bob', 'UserType', 5, 2)
198+
})
199+
})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
import type { VueWrapper } from '@vue/test-utils'
6+
7+
import { mount } from '@vue/test-utils'
8+
import { describe, expect, it } from 'vitest'
9+
import { h, nextTick } from 'vue'
10+
import InlineToggleField from './InlineToggleField.vue'
11+
12+
const INACTIVE = '.inline-toggle-field__slot--inactive'
13+
const SLOT = '.inline-toggle-field__slot'
14+
const TOGGLE_INPUT = '.inline-toggle-field__toggle input'
15+
16+
/**
17+
* Mount with a focusable input in the default slot.
18+
*
19+
* @param props Component props
20+
*/
21+
function mountField(props: { modelValue: boolean, label?: string, longText?: boolean }): VueWrapper {
22+
return mount(InlineToggleField, {
23+
props: { label: 'Note', ...props },
24+
slots: {
25+
default: (slotProps: { inputId: string }) => h('input', { id: slotProps.inputId, class: 'slot-input' }),
26+
},
27+
attachTo: document.body,
28+
})
29+
}
30+
31+
describe('InlineToggleField', () => {
32+
it('renders the slot content and the toggle switch', () => {
33+
const wrapper = mountField({ modelValue: true })
34+
expect(wrapper.find('.slot-input').exists()).toBe(true)
35+
expect(wrapper.find(TOGGLE_INPUT).exists()).toBe(true)
36+
})
37+
38+
it('marks the slot inactive when disabled', () => {
39+
const wrapper = mountField({ modelValue: false })
40+
expect(wrapper.find(INACTIVE).exists()).toBe(true)
41+
})
42+
43+
it('does not mark the slot inactive when enabled', () => {
44+
const wrapper = mountField({ modelValue: true })
45+
expect(wrapper.find(INACTIVE).exists()).toBe(false)
46+
})
47+
48+
it('emits update:modelValue(true) when toggled on', async () => {
49+
const wrapper = mountField({ modelValue: false })
50+
await wrapper.find(TOGGLE_INPUT).setValue(true)
51+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
52+
})
53+
54+
it('emits update:modelValue(false) when toggled off', async () => {
55+
const wrapper = mountField({ modelValue: true })
56+
await wrapper.find(TOGGLE_INPUT).setValue(false)
57+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
58+
})
59+
60+
it('enables by clicking the inactive slot', async () => {
61+
const wrapper = mountField({ modelValue: false })
62+
await wrapper.find(SLOT).trigger('click')
63+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
64+
})
65+
66+
it('does not emit when clicking the active slot', async () => {
67+
const wrapper = mountField({ modelValue: true })
68+
await wrapper.find(SLOT).trigger('click')
69+
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
70+
})
71+
72+
it('enables when clicking a field element inside the inactive slot', async () => {
73+
const wrapper = mountField({ modelValue: false })
74+
// A click on the field bubbles to the slot handler and enables it
75+
await wrapper.find('.slot-input').trigger('click')
76+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
77+
})
78+
79+
// Note: with a *disabled* inner field, the click reaching the slot relies on
80+
// the `pointer-events: none` reroute, which happy-dom does not simulate (a
81+
// disabled control fires no click). That path is covered by browser tests.
82+
83+
it('does not emit when clicking a field element inside the active slot', async () => {
84+
const wrapper = mountField({ modelValue: true })
85+
await wrapper.find('.slot-input').trigger('click')
86+
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
87+
})
88+
89+
it('focuses the first focusable slot element after enabling', async () => {
90+
const wrapper = mountField({ modelValue: false })
91+
await wrapper.find(TOGGLE_INPUT).setValue(true)
92+
await nextTick()
93+
expect(document.activeElement).toBe(wrapper.find('.slot-input').element)
94+
})
95+
96+
it('does not throw when enabling with no focusable slot element', async () => {
97+
const wrapper = mount(InlineToggleField, {
98+
props: { label: 'Note', modelValue: false },
99+
slots: { default: () => h('span', 'just text') },
100+
attachTo: document.body,
101+
})
102+
await expect(wrapper.find(TOGGLE_INPUT).setValue(true)).resolves.not.toThrow()
103+
})
104+
105+
it('adds the long-text class on the toggle when longText is set', () => {
106+
const wrapper = mountField({ modelValue: true, longText: true })
107+
expect(wrapper.find('.inline-toggle-field__toggle--long-text').exists()).toBe(true)
108+
})
109+
110+
it('exposes the group aria-label and wires the slot input id', () => {
111+
const wrapper = mountField({ modelValue: true, label: 'Expiration' })
112+
expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe('Expiration')
113+
const slotId = wrapper.find('.slot-input').attributes('id')
114+
expect(slotId).toBeTruthy()
115+
expect(wrapper.find(TOGGLE_INPUT).attributes('aria-controls')).toBe(slotId)
116+
})
117+
})

0 commit comments

Comments
 (0)