diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index 6eba5840..c0f6a712 100644 --- a/layers/identity/app/pages/me/index.vue +++ b/layers/identity/app/pages/me/index.vue @@ -433,7 +433,7 @@ async function deleteAccount() { aria-controls="email-settings-form" @click="showEmail = !showEmail" /> -
+ - +
({ execute: vi.fn(), query: vi.fn(), navigate: vi.fn(), session: vi.fn() })) +const account = ref({ + id: 1, + login: 'email-review', + name: 'Email review', + email: 'review@example.com', + digest_email: 'review@example.com', + email_opt_in: true, + weekly_opt_in: true, + likes_public: false, + repo_indexing: false, + timezone: 'UTC', + stars_synced_at: null, + onboarded_at: null, +}) + +mockNuxtImport('useNuxtRpcQuery', () => (operation: { path: string }) => ({ + data: operation.path === '/api/me' ? account : ref({ items: [] }), + status: ref('success'), + error: ref(), + refresh: vi.fn(), +})) +mockNuxtImport('useFetch', () => () => ({ data: ref({ items: [] }), status: ref('success'), error: ref(), refresh: vi.fn() })) +mockNuxtImport('useNuxtRpc', () => () => ({ execute: mocks.execute, query: mocks.query })) +mockNuxtImport('useAuth', () => () => ({ fetchSession: mocks.session })) +mockNuxtImport('navigateTo', () => mocks.navigate) +mockNuxtImport('useActionFailure', () => () => () => vi.fn()) +mockNuxtImport('useNuxtMutation', () => (options: { mutation: (body?: unknown) => Promise }) => ({ + pending: ref(false), + mutateSafe: async (body?: unknown) => ({ _tag: 'ok', data: await options.mutation(body) }), +})) + +const screens = [ + { name: 'account settings', component: Account, form: '#email-settings-form' }, + { name: 'onboarding', component: OnboardingEmail, form: '#email-onboarding-form' }, +] + +beforeEach(() => { + vi.clearAllMocks() + mocks.execute.mockResolvedValue({ ok: true }) + mocks.query.mockResolvedValue({ ...account.value }) +}) + +describe.each(screens)('$name email submission', ({ component, form: selector }) => { + async function mountForm() { + const wrapper = await mountSuspended(component, { attachTo: document.body }) + if (component === Account) + await wrapper.get('button[aria-controls="email-settings-form"]').trigger('click') + return { wrapper, form: wrapper.get(selector) } + } + + it('submits an opt-out even when the address is malformed', async () => { + const { wrapper, form } = await mountForm() + try { + await form.get('input[type="email"]').setValue('broken@') + for (const checkbox of form.findAll('input[type="checkbox"]')) + await checkbox.setValue(false) + + // Use native submission. A synthetic submit event skips browser validation. + ;(form.element as HTMLFormElement).requestSubmit() + await flushPromises() + + expect(mocks.execute).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/me/email' }), { + digest_email: 'broken@', + email_opt_in: false, + weekly_opt_in: false, + }) + if (component === OnboardingEmail) + expect(mocks.navigate).toHaveBeenCalledWith('/me?welcome=1') + } + finally { + wrapper.unmount() + } + }) + + it.each([0, 1])('rejects a malformed address while email choice %i stays enabled', async (enabledChoice) => { + const { wrapper, form } = await mountForm() + try { + await form.get('input[type="email"]').setValue('broken@') + for (const [index, checkbox] of form.findAll('input[type="checkbox"]').entries()) + await checkbox.setValue(index === enabledChoice) + + ;(form.element as HTMLFormElement).requestSubmit() + await flushPromises() + + expect(mocks.execute).not.toHaveBeenCalled() + expect(form.text()).toContain('turn off both emails') + } + finally { + wrapper.unmount() + } + }) +})