test(frontend): cover ManageImageSheet debounce, StickerTray, PWA-install paths
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Add a test for ManageImageSheet's 200ms pendingApproval lock-release (prevents the toggle becoming permanently disabled on a single tap), expand SettingsView coverage to exercise the beforeinstallprompt event path through usePwaInstall (accepted + dismissed outcomes), and add a first pass of StickerTray tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,47 @@ function mountSheet(opts: { image: Image | null; devices: Device[] }) {
|
|||||||
describe('ManageImageSheet', () => {
|
describe('ManageImageSheet', () => {
|
||||||
beforeEach(() => { vi.clearAllMocks() })
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('releases pendingApproval after the 200ms debounce so the toggle is tappable again', async () => {
|
||||||
|
// The component sets a brief pending lock to avoid double-fire on a
|
||||||
|
// fast double-tap; the timer that clears it is its own function and
|
||||||
|
// we need to advance time to exercise it.
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const w = mountSheet({
|
||||||
|
image: makeImage({ id: 7, approvedDeviceIds: [] }),
|
||||||
|
devices: [makeDevice({ id: 4 })],
|
||||||
|
})
|
||||||
|
const toggle = w.find('.manage__toggle')
|
||||||
|
await toggle.trigger('click')
|
||||||
|
// Immediately after the click the toggle is disabled (pending).
|
||||||
|
expect((toggle.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
// Advance past the debounce and let the watcher flush.
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect((toggle.element as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('releases pendingLock after the 200ms debounce so the lock pill is tappable again', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const w = mountSheet({
|
||||||
|
image: makeImage({ id: 7, approvedDeviceIds: [4] }),
|
||||||
|
devices: [makeDevice({ id: 4 })],
|
||||||
|
})
|
||||||
|
const lock = w.find('.manage__lock')
|
||||||
|
await lock.trigger('click')
|
||||||
|
expect((lock.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect((lock.element as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('renders a row per device', () => {
|
it('renders a row per device', () => {
|
||||||
const w = mountSheet({
|
const w = mountSheet({
|
||||||
image: makeImage(),
|
image: makeImage(),
|
||||||
@@ -141,4 +182,74 @@ describe('ManageImageSheet', () => {
|
|||||||
await w.find('.manage__toggle').trigger('click')
|
await w.find('.manage__toggle').trigger('click')
|
||||||
expect(w.emitted('approval')).toBeUndefined()
|
expect(w.emitted('approval')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue=false when the Done button is clicked', async () => {
|
||||||
|
// The Done button is the user's primary way to close the sheet without
|
||||||
|
// toggling anything else — line 73 in the template.
|
||||||
|
const w = mountSheet({
|
||||||
|
image: makeImage(),
|
||||||
|
devices: [makeDevice({ id: 1 })],
|
||||||
|
})
|
||||||
|
await w.find('.manage__done').trigger('click')
|
||||||
|
expect(w.emitted('update:modelValue')).toBeTruthy()
|
||||||
|
expect(w.emitted('update:modelValue')![0]).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onLockClick early-returns on a stale-DOM click after image becomes null (defensive)', async () => {
|
||||||
|
// The lock button is gated by v-if on isApproved(), which in turn reads
|
||||||
|
// props.image. In normal use these early-return guards are unreachable.
|
||||||
|
// We exercise them by capturing the live DOM node, asynchronously
|
||||||
|
// nulling the image prop (Vue tears the node out on next tick), and
|
||||||
|
// dispatching click on the detached node — its listener still runs and
|
||||||
|
// sees the now-null props.image.
|
||||||
|
const w = mountSheet({
|
||||||
|
image: makeImage({ id: 7, approvedDeviceIds: [4] }),
|
||||||
|
devices: [makeDevice({ id: 4 })],
|
||||||
|
})
|
||||||
|
const lockEl = w.find('.manage__lock').element as HTMLButtonElement
|
||||||
|
expect(lockEl).toBeTruthy()
|
||||||
|
await w.setProps({ image: null })
|
||||||
|
// Node is now detached from the live tree; firing click on it still runs
|
||||||
|
// the handler bound by Vue at mount time, which now reads props.image
|
||||||
|
// as null and bails out at the first guard.
|
||||||
|
lockEl.dispatchEvent(new Event('click'))
|
||||||
|
expect(w.emitted('lock')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onLockClick early-returns on a stale-DOM click after device loses approval (defensive)', async () => {
|
||||||
|
// Same trick for the second guard. props.image stays, but the device
|
||||||
|
// now has no approval — isApproved() returns false and we exit.
|
||||||
|
const image = makeImage({ id: 7, approvedDeviceIds: [4] })
|
||||||
|
const w = mountSheet({
|
||||||
|
image,
|
||||||
|
devices: [makeDevice({ id: 4 })],
|
||||||
|
})
|
||||||
|
const lockEl = w.find('.manage__lock').element as HTMLButtonElement
|
||||||
|
expect(lockEl).toBeTruthy()
|
||||||
|
await w.setProps({ image: { ...image, approvedDeviceIds: [] } })
|
||||||
|
lockEl.dispatchEvent(new Event('click'))
|
||||||
|
expect(w.emitted('lock')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards update:modelValue from the underlying BaseBottomSheet', async () => {
|
||||||
|
// BaseBottomSheet emits update:modelValue (e.g. backdrop tap, drag-down);
|
||||||
|
// we need to re-emit so the parent's v-model stays in sync.
|
||||||
|
const w = mount(ManageImageSheet, {
|
||||||
|
props: { modelValue: true, image: makeImage(), devices: [makeDevice({ id: 1 })] },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
BaseBottomSheet: {
|
||||||
|
name: 'BaseBottomSheet',
|
||||||
|
template: '<div><slot/></div>',
|
||||||
|
props: ['modelValue', 'label'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
},
|
||||||
|
BaseButton: { template: '<button><slot/></button>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await w.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
|
||||||
|
expect(w.emitted('update:modelValue')).toEqual([[false]])
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,4 +76,104 @@ describe('StickerTray (emoji-keyboard picker)', () => {
|
|||||||
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
|
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
|
||||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('re-emits pick from a recent emoji chip without re-typing it', async () => {
|
||||||
|
// First mount: seed a recent emoji via the input.
|
||||||
|
const first = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const input = first.find('.sticker-tray__emoji-input')
|
||||||
|
;(input.element as HTMLInputElement).value = '🦄'
|
||||||
|
await input.trigger('input')
|
||||||
|
|
||||||
|
// Second mount picks up the persisted recent — clicking the chip should
|
||||||
|
// re-emit pick with the original emoji (covers pickRecent's emoji branch).
|
||||||
|
const second = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const recentChip = second.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === '🦄')!
|
||||||
|
expect(recentChip).toBeTruthy()
|
||||||
|
await recentChip.trigger('click')
|
||||||
|
const events = second.emitted('pick')
|
||||||
|
expect(events).toBeTruthy()
|
||||||
|
expect(events![0][0]).toEqual({ emoji: '🦄' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-emits pick from a recent custom-sticker chip with the original imageAsset', async () => {
|
||||||
|
// Seed by picking a custom sticker, then remount and click its recent chip.
|
||||||
|
const first = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const santa = first.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
|
||||||
|
await santa.trigger('click')
|
||||||
|
|
||||||
|
const second = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
// The first chip in the Recent row should be the just-picked Santa hat.
|
||||||
|
const recentSection = second.findAll('.sticker-tray__section')[0]
|
||||||
|
const recentChip = recentSection.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
|
||||||
|
expect(recentChip).toBeTruthy()
|
||||||
|
await recentChip.trigger('click')
|
||||||
|
const events = second.emitted('pick')
|
||||||
|
expect(events).toBeTruthy()
|
||||||
|
// First emit on the second mount should be the recent re-pick.
|
||||||
|
expect(events![0][0]).toEqual({ imageAsset: 'santa-hat' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats malformed stored recents as empty (JSON.parse throws)', async () => {
|
||||||
|
// The catch branch in loadRecents — protects against a corrupted entry
|
||||||
|
// wedging the tray on subsequent loads.
|
||||||
|
localStorage.setItem('pf.stickerTray.recents', '{not valid json')
|
||||||
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
// No Recent heading should render when recents resolved to [].
|
||||||
|
const headings = wrapper.findAll('.sticker-tray__heading').map(h => h.text())
|
||||||
|
expect(headings).not.toContain('Recent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops persisted image recents whose asset id is no longer known', async () => {
|
||||||
|
// Simulates an old build's recent referring to a sticker that has since
|
||||||
|
// been removed; the filter in loadRecents should strip it so we don't
|
||||||
|
// render a broken <img>.
|
||||||
|
localStorage.setItem('pf.stickerTray.recents', JSON.stringify([
|
||||||
|
{ key: 'image:long-gone', kind: 'image', label: 'Gone', imageAsset: 'long-gone' },
|
||||||
|
{ key: 'image:santa-hat', kind: 'image', label: 'Santa hat', imageAsset: 'santa-hat' },
|
||||||
|
]))
|
||||||
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
// Only the still-valid recent should appear in the Recent section.
|
||||||
|
const recentSection = wrapper.findAll('.sticker-tray__section')[0]
|
||||||
|
const recentChips = recentSection.findAll('.sticker-tray__chip')
|
||||||
|
expect(recentChips.length).toBe(1)
|
||||||
|
expect(recentChips[0].attributes('aria-label')).toBe('Santa hat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the [...input].pop() fallback when Intl.Segmenter is unavailable', async () => {
|
||||||
|
// Older Safari / very old Android browsers don't have Intl.Segmenter;
|
||||||
|
// the spread+pop fallback still has to extract the right grapheme.
|
||||||
|
const original = (Intl as any).Segmenter
|
||||||
|
// Drop Segmenter from the Intl namespace for this test.
|
||||||
|
delete (Intl as any).Segmenter
|
||||||
|
try {
|
||||||
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const input = wrapper.find('.sticker-tray__emoji-input')
|
||||||
|
;(input.element as HTMLInputElement).value = '🌟'
|
||||||
|
await input.trigger('input')
|
||||||
|
const events = wrapper.emitted('pick')
|
||||||
|
expect(events).toBeTruthy()
|
||||||
|
expect(events![0][0]).toEqual({ emoji: '🌟' })
|
||||||
|
} finally {
|
||||||
|
;(Intl as any).Segmenter = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an empty input event (defensive — no grapheme, no pick)', async () => {
|
||||||
|
// firstGrapheme returns null for an empty string; onEmojiInput should
|
||||||
|
// bail out before emitting. Without this, every keystroke that clears
|
||||||
|
// the field would emit a noisy pick.
|
||||||
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const input = wrapper.find('.sticker-tray__emoji-input')
|
||||||
|
;(input.element as HTMLInputElement).value = ''
|
||||||
|
await input.trigger('input')
|
||||||
|
expect(wrapper.emitted('pick')).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import SettingsView from '@/views/SettingsView.vue'
|
import SettingsView from '@/views/SettingsView.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { THEMES } from '@/composables/useTheme'
|
import { THEMES } from '@/composables/useTheme'
|
||||||
import { __resetPwaInstallForTests } from '@/composables/usePwaInstall'
|
import { __resetPwaInstallForTests } from '@/composables/usePwaInstall'
|
||||||
|
|
||||||
|
// Fires a synthetic `beforeinstallprompt` so the usePwaInstall singleton
|
||||||
|
// caches an event and exposes canPromptInstall=true. The composable
|
||||||
|
// registers its listener on first import (registerOnce), but
|
||||||
|
// __resetPwaInstallForTests flips `registered` back to false so the next
|
||||||
|
// usePwaInstall() call re-registers — meaning every mount in this file
|
||||||
|
// re-arms the listener for that test.
|
||||||
|
function fireBeforeInstallPrompt(opts: { accepted?: boolean } = {}) {
|
||||||
|
const { accepted = true } = opts
|
||||||
|
const ev = new Event('beforeinstallprompt') as Event & {
|
||||||
|
prompt: () => Promise<void>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
|
||||||
|
}
|
||||||
|
ev.prompt = vi.fn().mockResolvedValue(undefined)
|
||||||
|
ev.userChoice = Promise.resolve({
|
||||||
|
outcome: accepted ? 'accepted' : 'dismissed',
|
||||||
|
platform: 'web',
|
||||||
|
})
|
||||||
|
window.dispatchEvent(ev)
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
describe('SettingsView', () => {
|
describe('SettingsView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
__resetPwaInstallForTests()
|
__resetPwaInstallForTests()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders one swatch per theme and the user email', () => {
|
it('renders one swatch per theme and the user email', () => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
@@ -92,4 +118,352 @@ describe('SettingsView', () => {
|
|||||||
await wrapper.find('.install-modal__close').trigger('click')
|
await wrapper.find('.install-modal__close').trigger('click')
|
||||||
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clicking the iOS-instructions backdrop closes the modal', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__install').trigger('click')
|
||||||
|
expect(wrapper.find('.install-modal').exists()).toBe(true)
|
||||||
|
|
||||||
|
// @click.self requires the event target to actually be the backdrop.
|
||||||
|
const backdrop = wrapper.find('.install-modal').element as HTMLElement
|
||||||
|
const ev = new MouseEvent('click', { bubbles: true })
|
||||||
|
Object.defineProperty(ev, 'target', { value: backdrop, configurable: true })
|
||||||
|
backdrop.dispatchEvent(ev)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Install: native-prompt branch ──────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows the native Install button when a beforeinstallprompt event is cached', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
fireBeforeInstallPrompt()
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.settings__install').text()).toBe('Install pictureFrame')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking the native Install button invokes prompt() and resolves accepted', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const ev = fireBeforeInstallPrompt({ accepted: true })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.settings__install').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(ev.prompt).toHaveBeenCalled()
|
||||||
|
// Accepted path → the iOS modal should NOT open.
|
||||||
|
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the iOS modal when the native install is dismissed and event is gone', async () => {
|
||||||
|
// This covers lines 187-192: install() returns false (user dismissed) AND
|
||||||
|
// canPromptInstall has flipped false (event consumed) → showIosInstructions.
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
fireBeforeInstallPrompt({ accepted: false })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.settings__install').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.install-modal').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.install-modal__title').text()).toMatch(/home screen/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Change password modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('opens the change-password modal when the Change password action is clicked', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
|
||||||
|
expect(wrapper.find('.pw-form').exists()).toBe(false)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
expect(wrapper.find('.pw-form').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#pw-modal-title').text()).toBe('Change password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a mismatch error when confirm differs from new password', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('oldpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('different')
|
||||||
|
|
||||||
|
// Mismatch surfaces an inline error and aria-invalid on the confirm input.
|
||||||
|
expect(wrapper.text()).toContain("Passwords don't match")
|
||||||
|
expect(inputs[2].attributes('aria-invalid')).toBe('true')
|
||||||
|
|
||||||
|
// Submit button is disabled while the mismatch stands.
|
||||||
|
const submit = wrapper.find('button[type="submit"]')
|
||||||
|
expect((submit.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not submit when fields are empty (button disabled)', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
|
||||||
|
const submit = wrapper.find('button[type="submit"]')
|
||||||
|
expect((submit.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitPasswordChange is a no-op while in mismatch state (form.submit early return)', async () => {
|
||||||
|
// Hits the `if (pwConfirmMismatch.value) return` guard at the top of
|
||||||
|
// submitPasswordChange. Even if the form gets force-submitted with a
|
||||||
|
// mismatch, no fetch fires.
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('oldpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('different')
|
||||||
|
|
||||||
|
// The submit button is disabled while in mismatch state, so trigger the
|
||||||
|
// form's submit event directly to exercise the early-return guard.
|
||||||
|
await wrapper.find('form.pw-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits the new password, shows success, clears fields, and auto-closes', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ status: 204, json: () => Promise.resolve({}) })
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('oldpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('newpassword1')
|
||||||
|
|
||||||
|
await wrapper.find('form.pw-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/user/password',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword: 'oldpw',
|
||||||
|
newPassword: 'newpassword1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// Success message renders.
|
||||||
|
expect(wrapper.find('.pw-form__success').exists()).toBe(true)
|
||||||
|
|
||||||
|
// The 1500ms auto-close timer fires → resetPasswordForm + closePasswordModal.
|
||||||
|
vi.advanceTimersByTime(1500)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.pw-form').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the server-provided error message on a 4xx response', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
status: 400,
|
||||||
|
json: () => Promise.resolve({ error: 'Current password is wrong.' }),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('badpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('newpassword1')
|
||||||
|
|
||||||
|
await wrapper.find('form.pw-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.pw-form__error').text()).toBe('Current password is wrong.')
|
||||||
|
// Modal stays open so the user can retry.
|
||||||
|
expect(wrapper.find('.pw-form').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a generic error when the server returns no error message', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
status: 500,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('oldpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('newpassword1')
|
||||||
|
|
||||||
|
await wrapper.find('form.pw-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.pw-form__error').text()).toBe('Could not update password.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the generic error when the response body is unparseable JSON', async () => {
|
||||||
|
// res.json() rejecting exercises the `.catch(() => ({}))` branch.
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
status: 400,
|
||||||
|
json: () => Promise.reject(new Error('not json')),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('oldpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('newpassword1')
|
||||||
|
|
||||||
|
await wrapper.find('form.pw-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.pw-form__error').text()).toBe('Could not update password.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a network-error message when fetch rejects', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down'))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('oldpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('newpassword1')
|
||||||
|
|
||||||
|
await wrapper.find('form.pw-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.pw-form__error').text()).toBe('Network error. Try again.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles the submit button into a saving state during the request', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
// Pending fetch keeps pwSubmitting=true so the saving label is visible.
|
||||||
|
let resolveFetch!: (v: { status: number; json: () => Promise<unknown> }) => void
|
||||||
|
const fetchMock = vi.fn(() => new Promise(res => { resolveFetch = res }))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('oldpw')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('newpassword1')
|
||||||
|
|
||||||
|
wrapper.find('form.pw-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const submit = wrapper.find('button[type="submit"]')
|
||||||
|
expect(submit.text()).toBe('Saving…')
|
||||||
|
expect((submit.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
|
||||||
|
// Resolve so the finally{} branch runs and pwSubmitting flips back.
|
||||||
|
resolveFetch({ status: 204, json: () => Promise.resolve({}) })
|
||||||
|
await flushPromises()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the modal via the × button and clears entered fields (resetPasswordForm)', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
|
||||||
|
const inputs = wrapper.findAll('.pw-form__input')
|
||||||
|
await inputs[0].setValue('something')
|
||||||
|
await inputs[1].setValue('newpassword1')
|
||||||
|
await inputs[2].setValue('newpassword1')
|
||||||
|
|
||||||
|
// Click the modal's close (×) button.
|
||||||
|
const closeBtns = wrapper.findAll('.install-modal__close')
|
||||||
|
// The password modal is the first install-modal in the DOM order
|
||||||
|
// (it's rendered before the iOS modal in the template).
|
||||||
|
await closeBtns[0].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.pw-form').exists()).toBe(false)
|
||||||
|
|
||||||
|
// Reopen — fields should be empty again because resetPasswordForm ran.
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
const reopenedInputs = wrapper.findAll('.pw-form__input')
|
||||||
|
expect((reopenedInputs[0].element as HTMLInputElement).value).toBe('')
|
||||||
|
expect((reopenedInputs[1].element as HTMLInputElement).value).toBe('')
|
||||||
|
expect((reopenedInputs[2].element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the iPhone-specific copy in the install modal when the UA is iOS', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
|
||||||
|
// usePwaInstall calls detectIOS() per-component-instance from
|
||||||
|
// navigator.userAgent, so spoofing the UA before mount picks the iOS branch.
|
||||||
|
const realUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent')
|
||||||
|
Object.defineProperty(navigator, 'userAgent', {
|
||||||
|
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile Safari',
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__install').trigger('click')
|
||||||
|
expect(wrapper.find('.install-modal__title').text()).toBe('Add to your iPhone home screen')
|
||||||
|
|
||||||
|
const modalText = wrapper.find('.install-modal').text()
|
||||||
|
// The iOS-only "Share icon" step renders; the non-iOS three-dots step does not.
|
||||||
|
expect(modalText).toMatch(/Share/)
|
||||||
|
expect(modalText).not.toMatch(/three dots/)
|
||||||
|
// The "or Install app" suffix (v-if="!isIOS") is NOT inside the modal on iOS.
|
||||||
|
expect(modalText).not.toMatch(/or Install app/)
|
||||||
|
} finally {
|
||||||
|
if (realUA) Object.defineProperty(navigator, 'userAgent', realUA)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the password modal when the backdrop is clicked', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__action-link').trigger('click')
|
||||||
|
expect(wrapper.find('.pw-form').exists()).toBe(true)
|
||||||
|
|
||||||
|
// First install-modal in the DOM is the password modal.
|
||||||
|
const backdrop = wrapper.findAll('.install-modal')[0].element as HTMLElement
|
||||||
|
const ev = new MouseEvent('click', { bubbles: true })
|
||||||
|
Object.defineProperty(ev, 'target', { value: backdrop, configurable: true })
|
||||||
|
backdrop.dispatchEvent(ev)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.pw-form').exists()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user