feat(SPA): profile page, display name and the new account menu
This commit is contained in:
1 parent
55525c06d6
commit
b0f7b7892b
29 files changed
+3166
-469
No files matched your search
@@ -7,6 +7,7 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import { useArticles, type Article } from '@/composables/useArticles'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { accountName } from '@/lib/accountName'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
@@ -37,8 +38,8 @@ function greetingForHour(hour: number) {
|
||||
|
||||
const greeting = computed(() => {
|
||||
const timeOfDayGreeting = greetingForHour(new Date().getHours())
|
||||
const username = session.session?.username
|
||||
return username ? `${timeOfDayGreeting}, ${username}` : timeOfDayGreeting
|
||||
const name = accountName(session.session?.username ?? '', session.session?.displayName)
|
||||
return name ? `${timeOfDayGreeting}, ${name}` : timeOfDayGreeting
|
||||
})
|
||||
|
||||
const subtitle = computed(() =>
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ProfileCard from '@/components/ProfileCard.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
|
||||
const DISPLAY_NAME_MAX = 60
|
||||
const DISPLAY_NAME_LIMIT_BODY = 'Display name must be 60 characters or fewer.'
|
||||
const DISPLAY_NAME_LIMIT_COPY = 'Use 60 characters or fewer.'
|
||||
const SAVE_FAILED_COPY =
|
||||
"Couldn't save your display name. Your text is still here. Try again in a moment."
|
||||
const RESET_FALLBACK_SECONDS = 300
|
||||
const PASSWORD_MASK = '••••••••••••'
|
||||
const SKELETON_CARDS = [
|
||||
{ titleWidth: 'w-30', inputs: 1 },
|
||||
{ titleWidth: 'w-22.5', inputs: 2 },
|
||||
{ titleWidth: 'w-25', inputs: 1 },
|
||||
]
|
||||
|
||||
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const { request } = useApi()
|
||||
const retryAfter = useRetryAfter()
|
||||
|
||||
const account = ref<Session | null>(session.session)
|
||||
const field = ref('')
|
||||
const saving = ref(false)
|
||||
const saveFailed = ref(false)
|
||||
const serverLimitError = ref(false)
|
||||
const resetState = ref<ResetState>('idle')
|
||||
const inputRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
|
||||
|
||||
watch(
|
||||
() => session.session,
|
||||
(value) => {
|
||||
if (value) {
|
||||
account.value = value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
account,
|
||||
(value, previous) => {
|
||||
if (value && !previous) {
|
||||
field.value = value.displayName ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(field, () => {
|
||||
serverLimitError.value = false
|
||||
})
|
||||
|
||||
watch(retryAfter.remaining, (value) => {
|
||||
if (value === 0 && resetState.value === 'limited') {
|
||||
resetState.value = 'idle'
|
||||
}
|
||||
})
|
||||
|
||||
const username = computed(() => account.value?.username ?? '')
|
||||
const email = computed(() => account.value?.email ?? '')
|
||||
const savedName = computed(() => account.value?.displayName ?? '')
|
||||
const draft = computed(() => field.value.trim())
|
||||
const count = computed(() => Array.from(draft.value).length)
|
||||
const dirty = computed(() => draft.value !== savedName.value)
|
||||
const tooLong = computed(() => count.value > DISPLAY_NAME_MAX)
|
||||
const showFieldError = computed(() => tooLong.value || serverLimitError.value)
|
||||
const saveEnabled = computed(() => dirty.value && !showFieldError.value)
|
||||
const showUnsaved = computed(() => dirty.value && !saving.value)
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (showUnsaved.value) {
|
||||
return 'Unsaved changes'
|
||||
}
|
||||
return savedName.value
|
||||
? `You appear as “${savedName.value}”.`
|
||||
: `You appear as “${username.value}”, your username.`
|
||||
})
|
||||
|
||||
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
|
||||
const readOnlyUi = { base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base' }
|
||||
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
|
||||
|
||||
const resetNotes = {
|
||||
sent: {
|
||||
color: 'success',
|
||||
icon: 'i-lucide-mail-check',
|
||||
title: 'Reset link sent',
|
||||
},
|
||||
limited: {
|
||||
color: 'warning',
|
||||
icon: 'i-lucide-clock-alert',
|
||||
title: 'Too many requests',
|
||||
description: "You've asked for several links recently. Wait a few minutes, then try again.",
|
||||
},
|
||||
error: {
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't send the link",
|
||||
description: 'Check your connection and try again.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const resetNote = computed(() => {
|
||||
if (resetState.value === 'sent') {
|
||||
return {
|
||||
...resetNotes.sent,
|
||||
description: `Check ${email.value}. The link works once, within 1 hour.`,
|
||||
}
|
||||
}
|
||||
if (resetState.value === 'limited' || resetState.value === 'error') {
|
||||
return resetNotes[resetState.value]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const resetButtonLabel = computed(() => {
|
||||
if (resetState.value === 'sending') {
|
||||
return 'Sending…'
|
||||
}
|
||||
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
|
||||
})
|
||||
|
||||
const resetBusy = computed(() => resetState.value === 'sending')
|
||||
const resetDisabled = computed(() => resetBusy.value || resetState.value === 'limited')
|
||||
|
||||
function focusField() {
|
||||
nextTick(() => inputRef.value?.inputRef?.focus())
|
||||
}
|
||||
|
||||
function announceSaved(displayName: string) {
|
||||
toast.add({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: displayName ? 'Display name saved' : 'Display name removed',
|
||||
description: displayName
|
||||
? `You now appear as ${displayName} across RenewCMS and in emails.`
|
||||
: `You now appear as ${username.value}, your username.`,
|
||||
})
|
||||
}
|
||||
|
||||
function isHandledGlobally(error: unknown) {
|
||||
return error instanceof ApiError && (error.status === 401 || error.status === 403)
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (saving.value || !saveEnabled.value) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
saveFailed.value = false
|
||||
serverLimitError.value = false
|
||||
try {
|
||||
const updated = await request<{ display_name?: string | null }>('/v1/user/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: draft.value }),
|
||||
})
|
||||
const displayName = updated.display_name ?? ''
|
||||
session.setDisplayName(displayName)
|
||||
field.value = displayName
|
||||
announceSaved(displayName)
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.status === 400 &&
|
||||
error.body === DISPLAY_NAME_LIMIT_BODY
|
||||
) {
|
||||
serverLimitError.value = true
|
||||
} else {
|
||||
saveFailed.value = true
|
||||
}
|
||||
focusField()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendReset() {
|
||||
if (resetDisabled.value) {
|
||||
return
|
||||
}
|
||||
resetState.value = 'sending'
|
||||
try {
|
||||
await request('/v1/auth/password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
})
|
||||
resetState.value = 'sent'
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 429) {
|
||||
retryAfter.start(error.retryAfter || RESET_FALLBACK_SECONDS)
|
||||
resetState.value = 'limited'
|
||||
return
|
||||
}
|
||||
resetState.value = isHandledGlobally(error) ? 'idle' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (authRedirectPending.value || !dirty.value) {
|
||||
return true
|
||||
}
|
||||
const keepEditing = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description: "Your display name change hasn't been saved.",
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
},
|
||||
})
|
||||
.open()
|
||||
return !keepEditing
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell render-while-checking>
|
||||
<div class="flex max-w-164 flex-col gap-5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
|
||||
<p class="text-muted">Your account on this RenewCMS workspace.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="(card, cardIndex) in SKELETON_CARDS"
|
||||
:key="cardIndex"
|
||||
data-testid="profile-skeleton-card"
|
||||
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<USkeleton class="h-3.5 rounded-none bg-accented" :class="card.titleWidth" />
|
||||
<USkeleton
|
||||
v-for="inputIndex in card.inputs"
|
||||
:key="inputIndex"
|
||||
class="h-10 rounded-none bg-accented"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<UForm :state="{ displayName: field }" @submit="onSave">
|
||||
<ProfileCard
|
||||
title="Display name"
|
||||
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
|
||||
>
|
||||
<UAlert
|
||||
v-if="saveFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="SAVE_FAILED_COPY"
|
||||
:close="false"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
/>
|
||||
|
||||
<UFormField name="displayName" :error="showFieldError">
|
||||
<template #label>
|
||||
Display name <span class="font-normal text-muted">(optional)</span>
|
||||
</template>
|
||||
<UInput
|
||||
ref="inputRef"
|
||||
v-model="field"
|
||||
placeholder="e.g. Marta Alves"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
class="w-full"
|
||||
:ui="inputUi"
|
||||
@keydown.enter.prevent="onSave"
|
||||
/>
|
||||
<div class="mt-1.5 flex items-start gap-3">
|
||||
<span
|
||||
v-if="showFieldError"
|
||||
data-testid="display-name-error"
|
||||
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
|
||||
{{ DISPLAY_NAME_LIMIT_COPY }}
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
<CharacterCounter
|
||||
data-testid="display-name-counter"
|
||||
variant="field"
|
||||
:value="count"
|
||||
:max="DISPLAY_NAME_MAX"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<template #footer>
|
||||
<span
|
||||
data-testid="display-name-status"
|
||||
class="min-w-0 flex-1 text-xs/[18px]"
|
||||
:class="showUnsaved ? 'text-warning' : 'text-muted'"
|
||||
>{{ statusText }}</span
|
||||
>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="saving"
|
||||
:disabled="!saveEnabled && !saving"
|
||||
class="h-11 shrink-0 px-3.5 lg:h-8"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</UButton>
|
||||
</template>
|
||||
</ProfileCard>
|
||||
</UForm>
|
||||
|
||||
<ProfileCard
|
||||
title="Account"
|
||||
description="Set when your account was created. You log in with your username, even after adding a display name."
|
||||
>
|
||||
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<UFormField label="Username">
|
||||
<UInput
|
||||
:model-value="username"
|
||||
disabled
|
||||
trailing-icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Email">
|
||||
<UInput
|
||||
:model-value="email"
|
||||
disabled
|
||||
trailing-icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</ProfileCard>
|
||||
|
||||
<ProfileCard
|
||||
title="Password"
|
||||
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
|
||||
>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<UFormField label="Password" class="min-w-0 flex-1">
|
||||
<UInput
|
||||
:model-value="PASSWORD_MASK"
|
||||
disabled
|
||||
trailing-icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="resetBusy ? undefined : 'i-lucide-mail'"
|
||||
:loading="resetBusy"
|
||||
:disabled="resetDisabled"
|
||||
class="h-11 justify-center border border-accented bg-default px-3.5 whitespace-nowrap text-highlighted ring-0 lg:h-10"
|
||||
@click="onSendReset"
|
||||
>
|
||||
{{ resetButtonLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
<UAlert
|
||||
v-if="resetNote"
|
||||
data-testid="reset-note"
|
||||
role="status"
|
||||
variant="soft"
|
||||
:color="resetNote.color"
|
||||
:icon="resetNote.icon"
|
||||
:title="resetNote.title"
|
||||
:description="resetNote.description"
|
||||
:close="false"
|
||||
/>
|
||||
</ProfileCard>
|
||||
</template>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -379,6 +379,58 @@ describe('The greeting is identical across every load state at a fixed hour', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The greeting names the person by display name, else username', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ displayName: '', name: 'marta' },
|
||||
{ displayName: 'Marta Alves', name: 'Marta Alves' },
|
||||
])('reads the name "$name" for display name "$displayName"', async ({ displayName, name }) => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/article/': () => mockResponse(200, sixArticles),
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const wrapper = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="greeting"]').text()).toBe(`Good morning, ${name}`)
|
||||
})
|
||||
|
||||
it('shows a display name saved after the page was mounted without a reload', async () => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName: '' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/article/': () => mockResponse(200, sixArticles),
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const wrapper = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
|
||||
session.setDisplayName('Marta Alves')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="greeting"]').text()).toBe('Good morning, Marta Alves')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The greeting handles a missing username', () => {
|
||||
it('renders the plain greeting without a stray comma when the session has no username', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -6,11 +6,7 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import PendingView from '../PendingView.vue'
|
||||
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
return `${method} ${url}`
|
||||
}
|
||||
|
||||
function fetchRouter(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
) {
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
@@ -40,17 +34,15 @@ function fetchRouter(
|
||||
function createOverlayMock() {
|
||||
let props: Record<string, unknown> | null = null
|
||||
let resolveOpen: ((value: unknown) => void) | null = null
|
||||
const create = vi.fn(
|
||||
(_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||
props = options.props
|
||||
return {
|
||||
open: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||
props = options.props
|
||||
return {
|
||||
open: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
}),
|
||||
}
|
||||
})
|
||||
return {
|
||||
create,
|
||||
getProps: () => props,
|
||||
@@ -148,24 +140,16 @@ describe('Logging out', () => {
|
||||
await findButton(wrapper, 'Log out')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(
|
||||
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
expect(
|
||||
(
|
||||
findButton(wrapper, 'Cancel registration')
|
||||
?.element as HTMLButtonElement
|
||||
).disabled,
|
||||
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
|
||||
resolveLogout?.()
|
||||
await flushPromises()
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/login'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,9 +229,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
|
||||
overlayMock.resolveOpen(true)
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/register'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
@@ -275,7 +257,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
|
||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
||||
await props.action()
|
||||
|
||||
expect(session.session).toEqual({ verified: true })
|
||||
expect(session.session).toMatchObject({ verified: true, displayName: '' })
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
|
||||
overlayMock.resolveOpen(true)
|
||||
@@ -291,12 +273,9 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
|
||||
})
|
||||
expect(router.currentRoute.value.path).not.toBe('/home')
|
||||
|
||||
await vi.waitFor(
|
||||
() => expect(router.currentRoute.value.path).toBe('/home'),
|
||||
{
|
||||
timeout: 2000,
|
||||
},
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||
timeout: 2000,
|
||||
})
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
pushSpy.mockRestore()
|
||||
@@ -310,23 +289,20 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])(
|
||||
'rejects the action instead of navigating for $name',
|
||||
async ({ handler }) => {
|
||||
const { wrapper } = await mountPending({
|
||||
'DELETE /v1/auth/register': handler,
|
||||
})
|
||||
])('rejects the action instead of navigating for $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountPending({
|
||||
'DELETE /v1/auth/register': handler,
|
||||
})
|
||||
|
||||
await findButton(wrapper, 'Cancel registration')?.trigger('click')
|
||||
await flushPromises()
|
||||
await findButton(wrapper, 'Cancel registration')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const props = overlayMock.getProps() as {
|
||||
action: () => Promise<void>
|
||||
}
|
||||
await expect(props.action()).rejects.toThrow()
|
||||
const props = overlayMock.getProps() as {
|
||||
action: () => Promise<void>
|
||||
}
|
||||
await expect(props.action()).rejects.toThrow()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,872 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import App from '@/App.vue'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
let overlayMock: ReturnType<typeof createOverlayMock>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ add: toastAdd }),
|
||||
useOverlay: () => overlayMock,
|
||||
}
|
||||
})
|
||||
|
||||
function createOverlayMock() {
|
||||
let props: Record<string, unknown> | null = null
|
||||
let resolveOpen: ((value: unknown) => void) | null = null
|
||||
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||
props = options.props
|
||||
return {
|
||||
open: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
}),
|
||||
}
|
||||
})
|
||||
return {
|
||||
create,
|
||||
getProps: () => props,
|
||||
resolveOpen: (value: unknown) => resolveOpen?.(value),
|
||||
}
|
||||
}
|
||||
|
||||
const MARTA: Session = {
|
||||
id: 1,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: '',
|
||||
}
|
||||
|
||||
const DISPLAY_NAME_PATH = '/v1/user/me'
|
||||
const RESET_PATH = '/v1/auth/password-reset'
|
||||
const LOGOUT_PATH = '/v1/auth/logout'
|
||||
const LIMIT_BODY = 'Display name must be 60 characters or fewer.'
|
||||
const SAVE_FAILED_COPY =
|
||||
"Couldn't save your display name. Your text is still here. Try again in a moment."
|
||||
|
||||
type Handler = (init?: RequestInit) => Promise<unknown>
|
||||
|
||||
interface Mounted {
|
||||
wrapper: VueWrapper
|
||||
request: ReturnType<typeof vi.fn>
|
||||
getMe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function deferred<T = unknown>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
session?: Session | null
|
||||
status?: 'idle' | 'ready'
|
||||
getMe?: () => Promise<Session>
|
||||
handlers?: Record<string, Handler>
|
||||
path?: string
|
||||
}
|
||||
|
||||
let mountedWrapper: VueWrapper | null = null
|
||||
|
||||
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
|
||||
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
const store = useSessionStore()
|
||||
store.session = session ? { ...session } : null
|
||||
store.status = status
|
||||
const request = vi.fn((requestPath: string, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? 'GET'} ${requestPath}`
|
||||
const handler = handlers[key]
|
||||
if (handler) {
|
||||
return handler(init)
|
||||
}
|
||||
if (requestPath === LOGOUT_PATH) {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
return Promise.resolve([])
|
||||
})
|
||||
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
const wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
mountedWrapper = wrapper
|
||||
await flushPromises()
|
||||
return { wrapper, request, getMe }
|
||||
}
|
||||
|
||||
function displayNameField() {
|
||||
return document.body.querySelector('input[placeholder="e.g. Marta Alves"]') as HTMLInputElement
|
||||
}
|
||||
|
||||
async function typeInto(value: string) {
|
||||
const input = displayNameField()
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function pressEnter() {
|
||||
displayNameField().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
function buttonByText(pattern: RegExp) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find((button) =>
|
||||
pattern.test(button.textContent?.trim() ?? ''),
|
||||
) as HTMLButtonElement | undefined
|
||||
}
|
||||
|
||||
function saveButton() {
|
||||
return buttonByText(/^(Save|Saving…)$/)!
|
||||
}
|
||||
|
||||
function resetButton() {
|
||||
return buttonByText(/^(Send reset link|Sending…|Send again)$/)!
|
||||
}
|
||||
|
||||
function byTestId(id: string) {
|
||||
return document.body.querySelector(`[data-testid="${id}"]`) as HTMLElement | null
|
||||
}
|
||||
|
||||
function counter() {
|
||||
return byTestId('display-name-counter')!
|
||||
}
|
||||
|
||||
function footer() {
|
||||
return byTestId('display-name-status')!
|
||||
}
|
||||
|
||||
function isEnabled(button: HTMLButtonElement) {
|
||||
return !button.disabled
|
||||
}
|
||||
|
||||
async function clickSave() {
|
||||
saveButton().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function clickReset() {
|
||||
resetButton().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function patchBodies(request: ReturnType<typeof vi.fn>) {
|
||||
return request.mock.calls
|
||||
.filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
}
|
||||
|
||||
function callsTo(request: ReturnType<typeof vi.fn>, path: string, method?: string) {
|
||||
return request.mock.calls.filter(
|
||||
([requestPath, init]) =>
|
||||
requestPath === path &&
|
||||
(method === undefined || (init as RequestInit)?.method === method),
|
||||
)
|
||||
}
|
||||
|
||||
function chip() {
|
||||
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
}
|
||||
|
||||
function activate(element: HTMLElement) {
|
||||
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}
|
||||
|
||||
function savedResponse(displayName: string) {
|
||||
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
|
||||
}
|
||||
|
||||
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
|
||||
const SERVER_ERROR = () => Promise.reject(new ApiError(500))
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
overlayMock = createOverlayMock()
|
||||
localStorage.clear()
|
||||
authRedirectPending.value = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
mountedWrapper?.unmount()
|
||||
mountedWrapper = null
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Route guards for /profile', () => {
|
||||
it('renders ProfilePage for a resolved, verified session', async () => {
|
||||
await mountApp()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
||||
})
|
||||
|
||||
it('redirects a resolved, unverified session to /register/pending', async () => {
|
||||
await mountApp({ session: { ...MARTA, verified: false } })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||
})
|
||||
|
||||
it('redirects a 401 from /auth/me to /login?redirect=/profile&reason=expired', async () => {
|
||||
await mountApp({
|
||||
session: null,
|
||||
status: 'idle',
|
||||
getMe: () => Promise.reject(new ApiError(401)),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default: the usual first visit with no display name', () => {
|
||||
it('shows the header, the empty field, the account details and the reset button', async () => {
|
||||
const { request } = await mountApp()
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
|
||||
'Profile',
|
||||
)
|
||||
expect(document.querySelector('h1')?.textContent?.trim()).toBe('Profile')
|
||||
expect(text).toContain('Your account on this RenewCMS workspace.')
|
||||
expect(displayNameField().value).toBe('')
|
||||
expect(displayNameField().getAttribute('placeholder')).toBe('e.g. Marta Alves')
|
||||
expect(counter().textContent?.trim()).toBe('0 / 60')
|
||||
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
expect(text).toContain('Display name (optional)')
|
||||
|
||||
const accountInputs = Array.from(
|
||||
document.body.querySelectorAll<HTMLInputElement>('input:disabled'),
|
||||
)
|
||||
expect(accountInputs.map((input) => input.value)).toEqual([
|
||||
'marta',
|
||||
'marta@renew.example',
|
||||
'••••••••••••',
|
||||
])
|
||||
expect(text).toContain(
|
||||
"To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.",
|
||||
)
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks none of the four navigation items as the current page', async () => {
|
||||
await mountApp()
|
||||
|
||||
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(4)
|
||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||
})
|
||||
|
||||
it('places the three cards in order Display name, Account, Password', async () => {
|
||||
await mountApp()
|
||||
|
||||
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
|
||||
el.textContent?.trim(),
|
||||
)
|
||||
expect(titles).toEqual(['Display name', 'Account', 'Password'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading: opened by URL before /auth/me has returned', () => {
|
||||
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
|
||||
const pending = deferred<Session>()
|
||||
await mountApp({
|
||||
session: null,
|
||||
status: 'idle',
|
||||
getMe: () => pending.promise,
|
||||
})
|
||||
|
||||
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
|
||||
'Profile',
|
||||
)
|
||||
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
||||
const busy = document.body.querySelector('[aria-busy="true"]')!
|
||||
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
|
||||
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
|
||||
expect(displayNameField()).toBeNull()
|
||||
|
||||
pending.resolve({ ...MARTA })
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(displayNameField()).not.toBeNull()
|
||||
expect(counter().textContent?.trim()).toBe('0 / 60')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Opened from the menu, the cached user renders at once', () => {
|
||||
it('navigates from /home through the account menu with no skeleton and no second /auth/me', async () => {
|
||||
const { getMe } = await mountApp({ path: '/home' })
|
||||
|
||||
chip().click()
|
||||
await flushPromises()
|
||||
const profileItem = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Profile')!
|
||||
activate(profileItem)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(displayNameField()).not.toBeNull()
|
||||
expect(getMe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editing: typing turns Save on', () => {
|
||||
it('shows the count, Unsaved changes in the warning color and an enabled Save, sending nothing', async () => {
|
||||
const { request } = await mountApp()
|
||||
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
expect(counter().textContent?.trim()).toBe('11 / 60')
|
||||
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
||||
expect(footer().classList.contains('text-warning')).toBe(true)
|
||||
expect(isEnabled(saveButton())).toBe(true)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The counter counts Unicode characters after trimming', () => {
|
||||
it.each([
|
||||
['"Marta Alves"', 'Marta Alves', 11, 'text-muted', true],
|
||||
['" Marta Alves "', ' Marta Alves ', 11, 'text-muted', true],
|
||||
['60 repetitions of "a"', 'a'.repeat(60), 60, 'text-muted', true],
|
||||
['61 repetitions of "a"', 'a'.repeat(61), 61, 'text-error', false],
|
||||
['60 repetitions of an emoji', '😀'.repeat(60), 60, 'text-muted', true],
|
||||
['61 repetitions of an emoji', '😀'.repeat(61), 61, 'text-error', false],
|
||||
['"Cafe" followed by U+0301', 'Café', 5, 'text-muted', true],
|
||||
['5 spaces, 60 "a", 5 spaces', ` ${'a'.repeat(60)} `, 60, 'text-muted', true],
|
||||
])('%s counts %i, %s, Save enabled: %s', async (_label, value, count, colorClass, enabled) => {
|
||||
await mountApp()
|
||||
|
||||
await typeInto(value)
|
||||
|
||||
expect(counter().textContent?.trim()).toBe(`${count} / 60`)
|
||||
expect(counter().classList.contains(colorClass)).toBe(true)
|
||||
expect(isEnabled(saveButton())).toBe(enabled)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Too long: over 60 characters shows a field error and keeps Save off', () => {
|
||||
it('shows the error, ignores Enter and recovers once shortened', async () => {
|
||||
const { request } = await mountApp()
|
||||
|
||||
await typeInto('Marta Alves, Head of Renovations, Brussels and Wallonia office')
|
||||
|
||||
expect(counter().textContent?.trim()).toBe('62 / 60')
|
||||
expect(counter().classList.contains('text-error')).toBe(true)
|
||||
const error = byTestId('display-name-error')!
|
||||
expect(error.textContent).toContain('Use 60 characters or fewer.')
|
||||
expect(error.querySelector('svg, [class*="i-lucide-circle-alert"]')).not.toBeNull()
|
||||
expect(displayNameField().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
||||
expect(footer().classList.contains('text-warning')).toBe(true)
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
|
||||
pressEnter()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toHaveLength(0)
|
||||
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
expect(byTestId('display-name-error')).toBeNull()
|
||||
expect(counter().textContent?.trim()).toBe('11 / 60')
|
||||
expect(isEnabled(saveButton())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dirty is measured on the trimmed draft against the saved name', () => {
|
||||
it.each([
|
||||
['', ' ', false, 'You appear as “marta”, your username.'],
|
||||
['Marta Alves', 'Marta Alves', false, 'You appear as “Marta Alves”.'],
|
||||
['Marta Alves', 'Marta Alves ', false, 'You appear as “Marta Alves”.'],
|
||||
['Marta Alves', '', true, 'Unsaved changes'],
|
||||
['Marta Alves', 'Marta Alves', true, 'Unsaved changes'],
|
||||
])(
|
||||
'saved %j and typed %j gives Save enabled: %s and %s',
|
||||
async (saved, typed, enabled, text) => {
|
||||
await mountApp({ session: { ...MARTA, displayName: saved } })
|
||||
|
||||
await typeInto(typed)
|
||||
|
||||
expect(isEnabled(saveButton())).toBe(enabled)
|
||||
expect(footer().textContent?.trim()).toBe(text)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Enter saves when Save is enabled', () => {
|
||||
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
pressEnter()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
|
||||
})
|
||||
|
||||
it('does nothing when the draft is not dirty', async () => {
|
||||
const { request } = await mountApp()
|
||||
|
||||
pressEnter()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving: the field is disabled and a second submit is ignored', () => {
|
||||
it('sends the trimmed value, disables and dims the field, and ignores Enter and a second click', async () => {
|
||||
const pending = deferred()
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => pending.promise },
|
||||
})
|
||||
await typeInto(' Marta Alves ')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
|
||||
expect(displayNameField().disabled).toBe(true)
|
||||
expect(saveButton().textContent?.trim()).toBe('Saving…')
|
||||
expect(saveButton().querySelector('[class*="i-lucide-loader-circle"], svg')).not.toBeNull()
|
||||
expect(footer().textContent?.trim()).not.toBe('Unsaved changes')
|
||||
|
||||
pressEnter()
|
||||
saveButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
|
||||
it('replaces the session name, syncs the field, and raises the success toast', async () => {
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(useSessionStore().session?.displayName).toBe('Marta Alves')
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(displayNameField().disabled).toBe(false)
|
||||
expect(counter().textContent?.trim()).toBe('11 / 60')
|
||||
expect(footer().textContent?.trim()).toBe('You appear as “Marta Alves”.')
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Display name saved',
|
||||
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
|
||||
})
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens no leave confirmation when navigating away afterwards', async () => {
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
|
||||
await router.push('/users')
|
||||
await flushPromises()
|
||||
|
||||
expect(overlayMock.create).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/users')
|
||||
})
|
||||
})
|
||||
|
||||
describe("The server's value wins over what was typed", () => {
|
||||
it('uses the response display_name in the field, the chip and the toast', async () => {
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto(' Marta Alves ')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
expect(toastAdd.mock.calls[0]![0].description).toContain('Marta Alves across RenewCMS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clearing the display name goes back to the username', () => {
|
||||
it('sends an empty display_name and shows the username with the removed toast', async () => {
|
||||
const { request } = await mountApp({
|
||||
session: { ...MARTA, displayName: 'Marta Alves' },
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
|
||||
})
|
||||
await typeInto('')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(patchBodies(request)).toEqual([{ display_name: '' }])
|
||||
expect(chip().textContent).toContain('marta')
|
||||
expect(chip().textContent).not.toContain('Marta Alves')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Display name removed',
|
||||
description: 'You now appear as marta, your username.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed: a 500 or a network failure keeps the text and offers a retry', () => {
|
||||
it.each([
|
||||
['a 500 response', SERVER_ERROR],
|
||||
['a network failure', NETWORK_ERROR],
|
||||
])(
|
||||
'after %s shows the alert, keeps the value and retries with the same body',
|
||||
async (_label, failure) => {
|
||||
const attempts = [failure, () => savedResponse('Marta Alves')]
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(alert.textContent).toContain(SAVE_FAILED_COPY)
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(displayNameField().disabled).toBe(false)
|
||||
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
||||
expect(isEnabled(saveButton())).toBe(true)
|
||||
expect(useSessionStore().session?.displayName).toBe('')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(patchBodies(request)).toEqual([
|
||||
{ display_name: 'Marta Alves' },
|
||||
{ display_name: 'Marta Alves' },
|
||||
])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('hides the alert while the retry is in flight', async () => {
|
||||
const retry = deferred()
|
||||
const attempts = [SERVER_ERROR, () => retry.promise]
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
expect(document.body.querySelector('[role="alert"]')).not.toBeNull()
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(saveButton().textContent?.trim()).toBe('Saving…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A 400 from the API', () => {
|
||||
it('shows the field error and disables Save for the display-name message', async () => {
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
|
||||
Promise.reject(new ApiError(400, undefined, LIMIT_BODY)),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(byTestId('display-name-error')?.textContent).toContain('Use 60 characters or fewer.')
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the Save failed alert and keeps the text for any other 400', async () => {
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
|
||||
Promise.reject(new ApiError(400, undefined, 'invalid request body')),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
SAVE_FAILED_COPY,
|
||||
)
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(byTestId('display-name-error')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved changes asks for confirmation', () => {
|
||||
it('opens a neutral ConfirmDialog, stays on Keep editing and leaves on Discard changes', async () => {
|
||||
await mountApp()
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
const first = router.push('/users')
|
||||
await flushPromises()
|
||||
|
||||
const props = overlayMock.getProps()!
|
||||
expect(props.title).toBe('Leave without saving?')
|
||||
expect(props.description).toBe("Your display name change hasn't been saved.")
|
||||
expect(props.confirmLabel).toBe('Keep editing')
|
||||
expect(props.cancelLabel).toBe('Discard changes')
|
||||
expect(props.tone).toBe('neutral')
|
||||
|
||||
overlayMock.resolveOpen(true)
|
||||
await first
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
|
||||
const second = router.push('/users')
|
||||
await flushPromises()
|
||||
overlayMock.resolveOpen(false)
|
||||
await second
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/users')
|
||||
expect(useSessionStore().session?.displayName).toBe('')
|
||||
})
|
||||
|
||||
it('opens nothing when nothing is unsaved', async () => {
|
||||
await mountApp()
|
||||
|
||||
await router.push('/users')
|
||||
await flushPromises()
|
||||
|
||||
expect(overlayMock.create).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/users')
|
||||
})
|
||||
|
||||
it('opens the dialog when Log out is used with a dirty draft', async () => {
|
||||
const { request } = await mountApp()
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
chip().click()
|
||||
await flushPromises()
|
||||
const logOut = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Log out')!
|
||||
activate(logOut)
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo(request, LOGOUT_PATH, 'POST')).toHaveLength(1)
|
||||
expect(overlayMock.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('redirects without the dialog when a 401 while saving is handled globally', async () => {
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => {
|
||||
authRedirectPending.value = true
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: '/profile', reason: 'expired' },
|
||||
})
|
||||
return Promise.reject(new ApiError(401))
|
||||
},
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(overlayMock.create).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
|
||||
expect(document.body.textContent).not.toContain(SAVE_FAILED_COPY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Home greeting follows a display name saved on /profile', () => {
|
||||
it('shows the saved name on /home without a reload', async () => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="greeting"]')?.textContent?.trim()).toBe(
|
||||
'Good morning, Marta Alves',
|
||||
)
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset: sending, sent and Send again', () => {
|
||||
it('posts the session email with no confirmation and shows Sending… with no note', async () => {
|
||||
const pending = deferred()
|
||||
const { request } = await mountApp({
|
||||
session: { ...MARTA, displayName: 'Marta Alves' },
|
||||
handlers: { [`POST ${RESET_PATH}`]: () => pending.promise },
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
const [call] = callsTo(request, RESET_PATH, 'POST')
|
||||
expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({
|
||||
email: 'marta@renew.example',
|
||||
})
|
||||
expect(resetButton().textContent?.trim()).toBe('Sending…')
|
||||
expect(resetButton().disabled).toBe(true)
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
})
|
||||
|
||||
it('shows the sent note, sends again with the note hidden meanwhile, and resets on remount', async () => {
|
||||
const second = deferred()
|
||||
const responses = [() => Promise.resolve({ message: 'ok' }), () => second.promise]
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
expect(resetButton().textContent?.trim()).toBe('Send again')
|
||||
expect(resetButton().disabled).toBe(false)
|
||||
const note = byTestId('reset-note')!
|
||||
expect(note.getAttribute('role')).toBe('status')
|
||||
expect(note.textContent).toContain('Reset link sent')
|
||||
expect(note.textContent).toContain(
|
||||
'Check marta@renew.example. The link works once, within 1 hour.',
|
||||
)
|
||||
|
||||
await clickReset()
|
||||
|
||||
expect(callsTo(request, RESET_PATH, 'POST')).toHaveLength(2)
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
|
||||
second.resolve({ message: 'ok' })
|
||||
await flushPromises()
|
||||
|
||||
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
|
||||
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
await router.push('/profile')
|
||||
await flushPromises()
|
||||
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset: too many requests disables the button for the Retry-After window', () => {
|
||||
it.each([
|
||||
['a Retry-After of 42', 42, 42],
|
||||
['a Retry-After of 300', 300, 300],
|
||||
['no Retry-After header', undefined, 300],
|
||||
['a Retry-After of 0', 0, 300],
|
||||
])(
|
||||
'with %s the button is enabled again after %i seconds',
|
||||
async (_label, retryAfter, seconds) => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`POST ${RESET_PATH}`]: () => Promise.reject(new ApiError(429, retryAfter)),
|
||||
},
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
const note = byTestId('reset-note')!
|
||||
expect(note.getAttribute('role')).toBe('status')
|
||||
expect(note.textContent).toContain('Too many requests')
|
||||
expect(note.textContent).toContain(
|
||||
"You've asked for several links recently. Wait a few minutes, then try again.",
|
||||
)
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(resetButton().disabled).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime((seconds - 1) * 1000)
|
||||
await flushPromises()
|
||||
|
||||
expect(resetButton().disabled).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
await flushPromises()
|
||||
|
||||
expect(resetButton().disabled).toBe(false)
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Reset: failed leaves the button pressable', () => {
|
||||
it.each([
|
||||
['a 500 response', SERVER_ERROR],
|
||||
['a network failure', NETWORK_ERROR],
|
||||
])(
|
||||
'after %s shows the error note and recovers on a successful retry',
|
||||
async (_label, failure) => {
|
||||
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
|
||||
await mountApp({
|
||||
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
const note = byTestId('reset-note')!
|
||||
expect(note.getAttribute('role')).toBe('status')
|
||||
expect(note.textContent).toContain("Couldn't send the link")
|
||||
expect(note.textContent).toContain('Check your connection and try again.')
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(resetButton().disabled).toBe(false)
|
||||
|
||||
await clickReset()
|
||||
|
||||
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
|
||||
expect(resetButton().textContent?.trim()).toBe('Send again')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -288,6 +288,32 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display names in the Users list', () => {
|
||||
it('shows each display name with its username beside it and the chip with the session name', async () => {
|
||||
const fixture = defaultFixture().map((item) =>
|
||||
item.kind === 'user' && item.username === 'marta'
|
||||
? { ...item, display_name: 'Marta Alves' }
|
||||
: item,
|
||||
)
|
||||
await mountPage({ list: () => Promise.resolve(fixture) })
|
||||
useSessionStore().session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
}
|
||||
await flushPromises()
|
||||
|
||||
expect(rowTexts().slice(2)).toEqual([
|
||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
||||
'jonas Accepted 2 Apr 2026',
|
||||
'Marta Alves marta you Accepted 12 Mar 2026',
|
||||
])
|
||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||
expect(footer.textContent).toContain('Marta Alves')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading — skeleton rows after 150 ms', () => {
|
||||
it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
@@ -6,11 +6,7 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import VerifyView from '../VerifyView.vue'
|
||||
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
return `${method} ${url}`
|
||||
}
|
||||
|
||||
function fetchRouter(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
) {
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
@@ -81,16 +75,11 @@ afterEach(async () => {
|
||||
describe('Verifying — the code submits automatically on load', () => {
|
||||
it('shows the full-card spinner and calls verify once with the code', async () => {
|
||||
const verify = vi.fn(() => new Promise(() => {}))
|
||||
const { wrapper, fetchMock } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
'POST /v1/auth/verify': verify,
|
||||
},
|
||||
)
|
||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': verify,
|
||||
})
|
||||
|
||||
expect(wrapper.find('[role="status"]').text()).toContain(
|
||||
'Verifying your account…',
|
||||
)
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
|
||||
expect(verify).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/auth/verify',
|
||||
@@ -119,16 +108,13 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
||||
description: 'Welcome to RenewCMS.',
|
||||
})
|
||||
|
||||
await vi.waitFor(
|
||||
() => expect(router.currentRoute.value.path).toBe('/home'),
|
||||
{
|
||||
timeout: 2000,
|
||||
},
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||
timeout: 2000,
|
||||
})
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
|
||||
const session = useSessionStore()
|
||||
expect(session.session).toEqual({ verified: true })
|
||||
expect(session.session).toMatchObject({ verified: true, displayName: '' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,9 +138,7 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Cancel your registration and start over to get a new link.',
|
||||
)
|
||||
expect(
|
||||
findButton(wrapper, 'Cancel registration and start over'),
|
||||
).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Log out')).toBeTruthy()
|
||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
@@ -165,27 +149,19 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
|
||||
describe('Cancel registration and start over — success', () => {
|
||||
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
|
||||
const deleteRegister = vi.fn(() => mockResponse(200))
|
||||
const { wrapper, fetchMock } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
'POST /v1/auth/verify': () => mockResponse(403),
|
||||
'DELETE /v1/auth/register': deleteRegister,
|
||||
},
|
||||
)
|
||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(403),
|
||||
'DELETE /v1/auth/register': deleteRegister,
|
||||
})
|
||||
|
||||
await findButton(
|
||||
wrapper,
|
||||
'Cancel registration and start over',
|
||||
)?.trigger('click')
|
||||
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/auth/register',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/register'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
@@ -206,38 +182,25 @@ describe('Cancel registration and start over — failure shows an alert, not a r
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])(
|
||||
'shows the failure alert and keeps the panel for $name',
|
||||
async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
'POST /v1/auth/verify': () => mockResponse(403),
|
||||
'DELETE /v1/auth/register': handler,
|
||||
},
|
||||
)
|
||||
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(403),
|
||||
'DELETE /v1/auth/register': handler,
|
||||
})
|
||||
|
||||
await findButton(
|
||||
wrapper,
|
||||
'Cancel registration and start over',
|
||||
)?.trigger('click')
|
||||
await flushPromises()
|
||||
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain("Couldn't cancel registration")
|
||||
expect(wrapper.text()).toContain(
|
||||
'Something went wrong on the server. Try again in a moment.',
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
"This verification link doesn't work",
|
||||
)
|
||||
expect(
|
||||
findButton(wrapper, 'Cancel registration and start over'),
|
||||
).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Log out')).toBeTruthy()
|
||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
expect(wrapper.text()).toContain("Couldn't cancel registration")
|
||||
expect(wrapper.text()).toContain(
|
||||
'Something went wrong on the server. Try again in a moment.',
|
||||
)
|
||||
expect(wrapper.text()).toContain("This verification link doesn't work")
|
||||
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Log out')).toBeTruthy()
|
||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Log out from the Invalid-or-expired state', () => {
|
||||
@@ -254,9 +217,7 @@ describe('Log out from the Invalid-or-expired state', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/login'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,15 +231,9 @@ describe('No session — the inline log-in form replaces the spinner', () => {
|
||||
expect(wrapper.text()).toContain(
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
|
||||
)
|
||||
expect(
|
||||
wrapper.find('input[placeholder="Your username"]').exists(),
|
||||
).toBe(true)
|
||||
expect(
|
||||
wrapper.find('input[autocomplete="current-password"]').exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe(
|
||||
'Log in and verify',
|
||||
)
|
||||
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
|
||||
expect(wrapper.text()).not.toContain('Forgot password?')
|
||||
expect(wrapper.text()).not.toContain('First time here?')
|
||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||
@@ -310,26 +265,17 @@ describe('No session — successful login retries verification with the same cod
|
||||
const verify = vi.fn()
|
||||
verify.mockResolvedValueOnce(mockResponse(401))
|
||||
verify.mockImplementationOnce(() => new Promise(() => {}))
|
||||
const { wrapper, fetchMock } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
'POST /v1/auth/verify': verify,
|
||||
'POST /v1/auth/login': () => mockResponse(200),
|
||||
},
|
||||
)
|
||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': verify,
|
||||
'POST /v1/auth/login': () => mockResponse(200),
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('limewash2026')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[role="status"]').text()).toContain(
|
||||
'Verifying your account…',
|
||||
)
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/auth/verify',
|
||||
expect.objectContaining({
|
||||
@@ -349,24 +295,14 @@ describe('No session — wrong credentials during the inline login', () => {
|
||||
'POST /v1/auth/login': () => mockResponse(403),
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('wrong-password')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
"That username and password don't match",
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check both and try again. Passwords are case-sensitive.',
|
||||
)
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
expect(wrapper.text()).toContain("That username and password don't match")
|
||||
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect((passwordInput.element as HTMLInputElement).value).toBe('')
|
||||
expect(passwordInput.element).toBe(document.activeElement)
|
||||
expect(verify).toHaveBeenCalledTimes(1)
|
||||
@@ -378,23 +314,16 @@ describe('No session — too many login attempts', () => {
|
||||
vi.useFakeTimers()
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(401),
|
||||
'POST /v1/auth/login': () =>
|
||||
mockResponse(429, null, { 'Retry-After': '30' }),
|
||||
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('limewash2026')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Too many attempts')
|
||||
expect(wrapper.text()).toContain(
|
||||
'For your security, wait a moment before trying again.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.text()).toBe('Try again in 0:30')
|
||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||
@@ -402,9 +331,7 @@ describe('No session — too many login attempts', () => {
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe(
|
||||
'Log in and verify',
|
||||
)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -415,50 +342,31 @@ describe('No session — server or network error during the inline login', () =>
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])(
|
||||
'shows the network/server alert and keeps the fields for $name',
|
||||
async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
'POST /v1/auth/verify': () => mockResponse(401),
|
||||
'POST /v1/auth/login': handler,
|
||||
},
|
||||
)
|
||||
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(401),
|
||||
'POST /v1/auth/login': handler,
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check your connection and try again.',
|
||||
)
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
).toBe('marta')
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[autocomplete="current-password"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
).toBe('limewash2026')
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement
|
||||
).disabled,
|
||||
).toBe(false)
|
||||
},
|
||||
)
|
||||
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
|
||||
expect(wrapper.text()).toContain('Check your connection and try again.')
|
||||
expect(
|
||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
|
||||
).toBe('marta')
|
||||
expect(
|
||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('limewash2026')
|
||||
expect(
|
||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Missing code — no request is made', () => {
|
||||
@@ -475,8 +383,6 @@ describe('Missing code — no request is made', () => {
|
||||
expect(action).toBeTruthy()
|
||||
await action?.trigger('click')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/login'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user