Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ async function deleteAccount() {
aria-controls="email-settings-form"
@click="showEmail = !showEmail"
/>
<form v-show="showEmail" id="email-settings-form" class="mt-4 space-y-4" @submit.prevent="saveEmail">
<form v-show="showEmail" id="email-settings-form" novalidate class="mt-4 space-y-4" @submit.prevent="saveEmail">
<UFormField label="Email address" name="email" :error="emailMissingAddress ? 'Add a valid email address, or turn off both emails.' : undefined">
<UInput
id="account-digest-email"
Expand Down
2 changes: 1 addition & 1 deletion layers/identity/app/pages/onboarding/email.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
</p>
<UButton v-if="accountError" label="Retry" color="neutral" variant="outline" class="mt-4 min-h-11" :loading="accountStatus === 'pending'" @click="retryAccount()" />
</div>
<form v-else id="email-onboarding-form" class="mt-6 space-y-4" @submit.prevent="finish">
<form v-else id="email-onboarding-form" novalidate class="mt-6 space-y-4" @submit.prevent="finish">
<div>
<label for="email" class="font-mono text-sm text-muted">Email address</label>
<input
Expand Down
99 changes: 99 additions & 0 deletions test/unit/email-form-submit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import Account from '../../layers/identity/app/pages/me/index.vue'
import OnboardingEmail from '../../layers/identity/app/pages/onboarding/email.vue'

const mocks = vi.hoisted(() => ({ 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<unknown> }) => ({
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()
}
})
})