diff --git a/frontend/src/test/components/ManageImageSheet.test.ts b/frontend/src/test/components/ManageImageSheet.test.ts
index 2167235..3055d65 100644
--- a/frontend/src/test/components/ManageImageSheet.test.ts
+++ b/frontend/src/test/components/ManageImageSheet.test.ts
@@ -58,6 +58,47 @@ function mountSheet(opts: { image: Image | null; devices: Device[] }) {
describe('ManageImageSheet', () => {
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', () => {
const w = mountSheet({
image: makeImage(),
@@ -141,4 +182,74 @@ describe('ManageImageSheet', () => {
await w.find('.manage__toggle').trigger('click')
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: '
',
+ props: ['modelValue', 'label'],
+ emits: ['update:modelValue'],
+ },
+ BaseButton: { template: '' },
+ },
+ },
+ })
+ await w.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
+ expect(w.emitted('update:modelValue')).toEqual([[false]])
+ })
+
})
diff --git a/frontend/src/test/components/StickerTray.test.ts b/frontend/src/test/components/StickerTray.test.ts
index d41d546..0c2dc6e 100644
--- a/frontend/src/test/components/StickerTray.test.ts
+++ b/frontend/src/test/components/StickerTray.test.ts
@@ -76,4 +76,104 @@ describe('StickerTray (emoji-keyboard picker)', () => {
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', 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 .
+ 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()
+ })
})
diff --git a/frontend/src/test/views/SettingsView.test.ts b/frontend/src/test/views/SettingsView.test.ts
index ce8ac7f..e4963ff 100644
--- a/frontend/src/test/views/SettingsView.test.ts
+++ b/frontend/src/test/views/SettingsView.test.ts
@@ -1,17 +1,43 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { mount } from '@vue/test-utils'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import SettingsView from '@/views/SettingsView.vue'
import { useAuthStore } from '@/stores/auth'
import { THEMES } from '@/composables/useTheme'
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
+ 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', () => {
beforeEach(() => {
setActivePinia(createPinia())
__resetPwaInstallForTests()
})
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+ })
+
it('renders one swatch per theme and the user email', () => {
const auth = useAuthStore()
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')
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 }) => 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)
+ })
})