From f6321412aa13087ebab70f29449915fdb849f836 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Thu, 14 May 2026 16:25:05 -0400 Subject: [PATCH] test(frontend): cover ManageImageSheet debounce, StickerTray, PWA-install paths 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) --- .../test/components/ManageImageSheet.test.ts | 111 +++++ .../src/test/components/StickerTray.test.ts | 100 +++++ frontend/src/test/views/SettingsView.test.ts | 378 +++++++++++++++++- 3 files changed, 587 insertions(+), 2 deletions(-) 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) + }) })