- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07), TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06), TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01, A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01), TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress (B-IN-04); test seam WrapStore - pact TestFrontendTokenScopes, towel TestClientIP, cabana TestTokenScopeNameParity - cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the Postgres TestAccountTokenRevokeEvent (B-WR-04) and TestResolveBackendTokenRemote (B-IN-04) - vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01) - check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS, DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
224 lines
11 KiB
TypeScript
224 lines
11 KiB
TypeScript
// 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.myaccount.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<string, Route> = {}) {
|
|
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<HTMLElement>('[data-confirm-dialog]')
|
|
expect(dialog?.textContent).toContain('Stop the weekly digest?')
|
|
expect(requestsTo(calls, 'POST', STOP)).toHaveLength(0)
|
|
dialog!.querySelector<HTMLButtonElement>('[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)
|
|
})
|
|
|
|
it("403 shows the server's message as a danger toast and keeps the list", async () => {
|
|
const { wrapper, calls } = await openAccount({
|
|
[`POST ${OPEN}`]: { status: 403, body: { error: { code: 'forbidden', message: 'You may not link this account.', details: {} } } },
|
|
})
|
|
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
|
await flushPromises()
|
|
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['You may not link this account.', 'danger']])
|
|
expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(1)
|
|
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
|
|
})
|
|
|
|
it('a failed list load renders no card and leaves the rest of My account', async () => {
|
|
const { wrapper } = await openAccount({
|
|
[`GET ${CONTRIBUTIONS}`]: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } },
|
|
})
|
|
expect(wrapper.findAll('[data-account-contribution]')).toHaveLength(0)
|
|
expect(wrapper.find('[data-api-tokens]').exists()).toBe(true)
|
|
})
|
|
|
|
// Plan 07 hardening (B-WR-03, B-WR-06, B-IN-01).
|
|
|
|
it('a javascript: URL answer renders no link and never opens a window', async () => {
|
|
const opened = vi.spyOn(window, 'open').mockImplementation(() => null)
|
|
const { wrapper } = await openAccount({
|
|
[`POST ${OPEN}`]: envelope({ message: 'Done.', url: 'javascript:alert(1)' }),
|
|
})
|
|
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
|
await flushPromises()
|
|
expect(opened).not.toHaveBeenCalled()
|
|
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
|
|
expect(wrapper.html()).not.toContain('javascript:')
|
|
expect(toasts.value.map((toast) => toast.text)).toEqual(['Done.'])
|
|
})
|
|
|
|
it('a section with error: true shows the section error text', async () => {
|
|
setBundle({ ...langFixture.data, ...strings, 'backend::lang.myaccount.section_error': { other: 'This section could not be loaded.' } }, 'en')
|
|
const { wrapper } = await openAccount({
|
|
[`GET ${CONTRIBUTIONS}`]: envelope([{ ...sections[0], status: '', error: true }, sections[1]]),
|
|
})
|
|
const cards = wrapper.findAll('[data-account-contribution]')
|
|
expect(cards[0]!.find('[data-account-contribution-error]').text()).toBe('This section could not be loaded.')
|
|
expect(cards[0]!.find('[data-account-contribution-status]').exists()).toBe(false)
|
|
expect(cards[1]!.find('[data-account-contribution-error]').exists()).toBe(false)
|
|
expect(cards[1]!.find('[data-account-contribution-status]').text()).toBe('Weekly')
|
|
})
|
|
|
|
it('a 500 on the list reload shows the error toast and keeps the earlier sections', async () => {
|
|
setBundle({ ...langFixture.data, ...strings, 'backend::lang.form.error_generic': { other: 'Something went wrong.' } }, 'en')
|
|
let loads = 0
|
|
const { wrapper } = await openAccount({
|
|
[`GET ${CONTRIBUTIONS}`]: () => {
|
|
loads++
|
|
return loads === 1
|
|
? envelope(sections)
|
|
: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } }
|
|
},
|
|
[`POST ${OPEN}`]: envelope({ message: 'Done.', url: '' }),
|
|
})
|
|
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
|
await flushPromises()
|
|
expect(loads).toBe(2)
|
|
expect(wrapper.findAll('[data-account-contribution]').map((card) => card.attributes('data-account-contribution'))).toEqual([
|
|
'site_link',
|
|
'newsletter',
|
|
])
|
|
// Newest toast first.
|
|
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([
|
|
['Something went wrong.', 'danger'],
|
|
['Done.', 'success'],
|
|
])
|
|
})
|
|
|
|
it('the confirm and stale texts are the My account ones, not the record form ones', async () => {
|
|
setBundle(
|
|
{
|
|
...langFixture.data,
|
|
...strings,
|
|
'backend::lang.myaccount.action_confirm': { other: 'Run “:action”?' },
|
|
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
|
|
'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' },
|
|
},
|
|
'en',
|
|
)
|
|
const STOP = `${CONTRIBUTIONS}/newsletter/actions/stop`
|
|
const { wrapper } = await openAccount({
|
|
[`GET ${CONTRIBUTIONS}`]: envelope([sections[0], { ...sections[1], actions: [{ ...sections[1]!.actions[0], confirm: '' }] }]),
|
|
[`POST ${STOP}`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } },
|
|
})
|
|
await wrapper.find('[data-account-contribution-action="stop"]').trigger('click')
|
|
await flushPromises()
|
|
const dialog = document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
|
expect(dialog?.textContent).toContain('Run “Stop the digest”?')
|
|
expect(dialog?.textContent).not.toContain('on this record')
|
|
dialog!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
|
await flushPromises()
|
|
expect(toasts.value.map((toast) => toast.text)).toEqual(['This action no longer applies.'])
|
|
})
|
|
})
|