// My account sections plugins contribute (phase 19.7, D-04 part 1). // Fixtures are neutral acme.* data; no application names appear in // framework tests. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import { toasts } from '../../src/state/useToasts' import { langFixture } from '../fixtures/typed' import { API, mountApp, profile, requestsTo, resetState, type Route } from '../helpers' const CONTRIBUTIONS = `${API}/account/contributions` const OPEN = `${CONTRIBUTIONS}/site_link/actions/open` const strings = { 'backend::lang.form.action_stale': { other: 'This action no longer applies.' }, 'backend::lang.form.action_done': { other: 'Action completed.' }, } const envelope = (data: unknown) => ({ body: { data, meta: {} } }) const sections = [ { name: 'site_link', label: 'Site account', description: 'Link the site account you sign in with.', status: 'Not linked', actions: [{ name: 'open', label: 'Link account', confirm: '', danger: false }], }, { name: 'newsletter', label: 'Newsletter digest', description: '', status: 'Weekly', actions: [{ name: 'stop', label: 'Stop the digest', confirm: 'Stop the weekly digest?', danger: true }], }, ] async function openAccount(routes: Record = {}) { return mountApp( '/account', { [`GET ${API}/auth/me`]: envelope(profile.data), [`GET ${API}/account/tokens`]: envelope([]), [`GET ${CONTRIBUTIONS}`]: envelope(sections), ...routes, }, { attach: true }, ) } beforeEach(() => { resetState() setBundle({ ...langFixture.data, ...strings }, 'en') }) afterEach(() => { document.body.innerHTML = '' vi.restoreAllMocks() }) enableAutoUnmount(afterEach) describe('My account contributions', () => { it('renders one card per section with its label, description, status and actions', async () => { const { wrapper } = await openAccount() const cards = wrapper.findAll('[data-account-contribution]') expect(cards.map((card) => card.attributes('data-account-contribution'))).toEqual(['site_link', 'newsletter']) expect(cards[0]!.find('h2').text()).toBe('Site account') expect(cards[0]!.text()).toContain('Link the site account you sign in with.') expect(wrapper.findAll('[data-account-contribution-status]').map((status) => status.text())).toEqual(['Not linked', 'Weekly']) expect(cards[1]!.find('[data-account-contribution-action="stop"]').text()).toBe('Stop the digest') // Text, never HTML. expect(wrapper.find('[data-account-contribution]').html()).not.toContain('v-html') }) it('renders no card when no plugin contributes', async () => { const { wrapper } = await openAccount({ [`GET ${CONTRIBUTIONS}`]: envelope([]) }) expect(wrapper.findAll('[data-account-contribution]')).toHaveLength(0) expect(wrapper.find('[data-api-tokens]').exists()).toBe(true) }) it('an action without a confirmation posts at once, shows the toast, opens and renders the URL and reloads the list', async () => { const opened = vi.spyOn(window, 'open').mockImplementation(() => null) const { wrapper, calls } = await openAccount({ [`POST ${OPEN}`]: envelope({ message: 'Open the site to finish linking.', url: 'https://blog.example.com/link-admin?code=abc' }), }) await wrapper.find('[data-account-contribution-action="open"]').trigger('click') await flushPromises() expect(requestsTo(calls, 'POST', OPEN)).toHaveLength(1) expect(requestsTo(calls, 'POST', OPEN)[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Open the site to finish linking.', 'success']]) expect(opened).toHaveBeenCalledWith('https://blog.example.com/link-admin?code=abc', '_blank', 'noopener') const link = wrapper.find('[data-account-contribution-url]') expect(link.attributes('href')).toBe('https://blog.example.com/link-admin?code=abc') expect(link.attributes('target')).toBe('_blank') expect(link.attributes('rel')).toBe('noopener noreferrer') expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(2) expect(document.body.querySelector('[data-confirm-dialog]')).toBeNull() }) it('a danger action asks first and posts on confirm', async () => { const STOP = `${CONTRIBUTIONS}/newsletter/actions/stop` const { wrapper, calls } = await openAccount({ [`POST ${STOP}`]: envelope({ message: 'Stopped.', url: '' }) }) await wrapper.find('[data-account-contribution-action="stop"]').trigger('click') await flushPromises() const dialog = document.body.querySelector('[data-confirm-dialog]') expect(dialog?.textContent).toContain('Stop the weekly digest?') expect(requestsTo(calls, 'POST', STOP)).toHaveLength(0) dialog!.querySelector('[data-action="confirm"]')!.click() await flushPromises() expect(requestsTo(calls, 'POST', STOP)).toHaveLength(1) expect(toasts.value.map((toast) => toast.text)).toEqual(['Stopped.']) expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false) }) it('409 shows the stale toast and refetches the list', async () => { const { wrapper, calls } = await openAccount({ [`POST ${OPEN}`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } }, }) await wrapper.find('[data-account-contribution-action="open"]').trigger('click') await flushPromises() expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This action no longer applies.', 'danger']]) expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(2) }) })