feat: article editor core

This commit is contained in:
fsylvain committed 2026-09-27 02:11:38 +02:00
1 parent d493a181e1
commit a4734b2c91
55 files changed
+4186 -496

No files matched your search

+358 -2
View File
@@ -1,9 +1,365 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ErrorView from '@/components/ErrorView.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
import ArticleDetails from '@/components/ArticleDetails.vue'
import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft'
const SKELETON_DELAY_MS = 150
const BODY_MAX_LENGTH = 10000
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const overlay = useOverlay()
const toast = useToast()
const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId)
const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false)
const article = ref<Article | null>(null)
const previewOn = ref(false)
const detailsOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadArticle() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const result = await request<Article>(`/v1/article/${articleId}`)
article.value = result
draft.load(result.body, new Date(result.updated_at))
loadState.value = 'loaded'
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
loadState.value = 'error'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
const bodyLength = computed(() => draft.body.value.length)
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
const saveEnabled = computed(
() =>
draft.state.value === 'dirty' &&
!overLimit.value &&
loadState.value === 'loaded',
)
const publishDisabled = computed(
() => overLimit.value || draft.state.value === 'offline',
)
async function runSave() {
const outcome = await draft.save()
if (outcome === 'not-found') {
toast.add({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'That article no longer exists.',
})
}
}
async function performSave() {
if (!saveEnabled.value || !article.value) {
return
}
await runSave()
}
function retrySave() {
if (!article.value) {
return
}
runSave()
}
function goBack() {
router.push('/article')
}
function onKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
performSave()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
loadArticle()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
clearSkeletonTimer()
})
onBeforeRouteLeave(async () => {
if (authRedirectPending.value || draft.state.value !== 'dirty') {
return true
}
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
},
})
.open()
return !confirmed
})
</script>
<template>
<DashboardShell>
<p>Editor placeholder.</p>
<DashboardShell mobile-title="Edit article" full-bleed>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[
{ label: 'Articles', to: '/article' },
{ label: article?.title ?? '' },
]"
color="neutral"
class="hidden lg:block"
/>
</template>
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
<ErrorView
v-else-if="loadState === 'error'"
kind="error"
@retry="loadArticle"
/>
<div
v-else-if="loadState === 'loading' && showSkeleton"
aria-busy="true"
class="flex min-h-0 flex-1 flex-col lg:flex-row"
>
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
@click="goBack"
/>
<USkeleton
class="h-5 w-80 rounded-none"
data-testid="skeleton-title"
/>
<USkeleton class="h-5.5 w-15 rounded-none" />
</div>
<div
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
data-testid="skeleton-content"
>
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-11/12 rounded-none" />
<USkeleton class="h-3.5 w-4/5 rounded-none" />
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-3/4 rounded-none" />
</div>
</div>
<div
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
data-testid="skeleton-details"
>
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-3/5 rounded-none" />
</div>
</div>
<div
v-else-if="loadState === 'loaded' && article"
class="flex min-h-0 flex-1 flex-col"
>
<div
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
@click="goBack"
/>
<div
class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5"
>
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
title="The title can't be changed after creation."
>
{{ article.title }}
</h1>
<ArticleStatusBadge :online="article.is_online" size="lg" />
</div>
<SaveStatus
:state="draft.state.value"
:saved-at="draft.savedAt.value"
@retry="retrySave"
/>
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
label="Preview"
:ui="{
root: 'items-center gap-2',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-3.5',
wrapper: 'ms-0',
label: 'text-sm font-normal text-toned',
}"
/>
<UButton
data-testid="save-button"
icon="i-lucide-save"
color="neutral"
variant="outline"
class="h-8 gap-1.5 px-3"
:disabled="!saveEnabled"
@click="performSave"
>
Save
</UButton>
<PublishControl
class="h-8 gap-1.5 px-3"
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:disabled="publishDisabled"
/>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="More actions"
/>
</div>
</div>
<div v-if="previewOn" class="flex min-h-0 flex-1">
<div
class="min-h-0 flex-1 overflow-y-auto bg-muted px-12 py-10"
data-testid="article-preview"
>
<div class="article-content mx-auto">
<h2 class="text-2xl font-semibold text-highlighted">
{{ article.title }}
</h2>
<div v-html="draft.body.value" />
</div>
</div>
<div
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
>
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
/>
</div>
</div>
<ArticleEditor
v-else
class="min-h-0 flex-1"
:model-value="draft.body.value"
:article-id="articleId"
@update:model-value="draft.edit"
>
<template #footer>
<CharacterCounter
variant="footer"
:value="bodyLength"
:max="BODY_MAX_LENGTH"
/>
</template>
<template #aside>
<div class="h-full border-l border-default bg-default p-5">
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
/>
</div>
</template>
</ArticleEditor>
<div
class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden"
>
<UButton
icon="i-lucide-panel-right"
color="neutral"
variant="outline"
class="size-11 justify-center"
aria-label="Details"
@click="detailsOpen = true"
/>
<UButton
data-testid="save-button-mobile"
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center"
:disabled="!saveEnabled"
@click="performSave"
>
Save
</UButton>
<PublishControl
class="h-11 flex-1 justify-center"
hide-icon
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:disabled="publishDisabled"
/>
</div>
</div>
</DashboardShell>
</template>
+60 -20
View File
@@ -60,7 +60,11 @@ const statTiles = computed(() => [
const recentArticles = computed(() =>
[...articles.value]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.slice(0, RECENT_LIST_SIZE),
)
@@ -115,7 +119,10 @@ onMounted(loadArticles)
<div class="flex max-w-244 flex-col gap-5 lg:gap-7">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1">
<h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
<h1
data-testid="greeting"
class="text-2xl font-semibold text-highlighted"
>
{{ greeting }}
</h1>
<p class="text-base/5 text-muted">{{ subtitle }}</p>
@@ -129,7 +136,12 @@ onMounted(loadArticles)
</UButton>
</div>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
<template
v-if="
(loadState === 'loading' && showSkeleton) ||
loadState === 'default'
"
>
<div class="grid grid-cols-3 gap-3">
<div
v-for="tile in statTiles"
@@ -138,13 +150,18 @@ onMounted(loadArticles)
class="flex flex-col gap-1 border border-default bg-default p-4"
>
<template v-if="loadState === 'loading'">
<USkeleton class="h-7 w-10 rounded-none bg-accented" />
<USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
<USkeleton
class="h-7 w-10 rounded-none bg-accented"
/>
<USkeleton
class="h-3 w-17.5 rounded-none bg-accented"
/>
</template>
<template v-else>
<span class="text-2xl font-semibold text-highlighted">{{
tile.value
}}</span>
<span
class="text-2xl font-semibold text-highlighted"
>{{ tile.value }}</span
>
<span class="text-muted">{{ tile.label }}</span>
</template>
</div>
@@ -152,8 +169,13 @@ onMounted(loadArticles)
<section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
<RouterLink to="/article" class="text-toned underline underline-offset-3">
<h2 class="text-lg font-semibold text-highlighted">
Recently updated
</h2>
<RouterLink
to="/article"
class="text-toned underline underline-offset-3"
>
All articles
</RouterLink>
</div>
@@ -167,21 +189,35 @@ onMounted(loadArticles)
:key="n"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
>
<USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
<USkeleton
class="h-3 max-w-[60%] flex-1 rounded-none bg-accented"
/>
<USkeleton
class="h-5 w-14 rounded-none bg-accented"
/>
</div>
</div>
<ul v-else data-testid="recent-list" class="border border-default bg-default">
<ul
v-else
data-testid="recent-list"
class="border border-default bg-default"
>
<li v-for="article in recentArticles" :key="article.id">
<RouterLink
:to="`/article/${article.id}/edit`"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
>
<span class="min-w-0 flex-1 truncate font-medium text-highlighted">
<span
class="min-w-0 flex-1 truncate font-medium text-highlighted"
>
{{ article.title }}
</span>
<ArticleStatusBadge :online="article.is_online" />
<span class="hidden w-27.5 text-right text-muted lg:inline">
<ArticleStatusBadge
:online="article.is_online"
/>
<span
class="hidden w-27.5 text-right text-muted lg:inline"
>
{{ formatRelativeDate(article.updated_at) }}
</span>
</RouterLink>
@@ -242,17 +278,21 @@ onMounted(loadArticles)
</template>
</UAlert>
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
<div
class="flex items-start gap-3.5 border border-default bg-default p-4"
>
<div
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
>
<UIcon name="i-lucide-plug" class="size-4.5" />
</div>
<div class="flex flex-1 flex-col gap-1">
<h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
<h2 class="text-base/5 font-semibold text-highlighted">
Connect your website
</h2>
<p class="text-pretty text-muted">
Your site reads published articles from RenewCMS. Share this guide with
whoever builds it.
Your site reads published articles from RenewCMS. Share
this guide with whoever builds it.
</p>
<RouterLink
to="/integration"
+40 -12
View File
@@ -63,7 +63,8 @@ const passwordError = ref('')
const submitting = ref(false)
const passwordShown = ref(false)
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
const initialReason =
typeof route.query.reason === 'string' ? route.query.reason : null
const alert = ref<AlertState | null>(
initialReason === 'expired'
@@ -73,7 +74,9 @@ const alert = ref<AlertState | null>(
: null,
)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
const alertRole = computed(() =>
alert.value?.tone === 'error' ? 'alert' : 'status',
)
const submitLabel = computed(() => {
if (submitting.value) {
@@ -85,7 +88,9 @@ const submitLabel = computed(() => {
return 'Log in'
})
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
const submitDisabled = computed(
() => submitting.value || retryAfter.active.value,
)
async function focusField(selector: string) {
await nextTick()
@@ -112,15 +117,18 @@ function focusFirstInvalid() {
}
function validate() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
async function afterLoginSuccess() {
await session.retry()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
const redirect =
typeof route.query.redirect === 'string' ? route.query.redirect : null
if (redirect) {
await router.push(redirect)
return
@@ -166,7 +174,10 @@ async function onSubmit() {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
await afterLoginSuccess()
} catch (error) {
@@ -182,7 +193,9 @@ onMounted(async () => {
}
try {
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
const status = await api.request<{ open: boolean }>(
'/v1/auth/registration',
)
registrationOpen.value = status.open
} catch {
registrationOpen.value = false
@@ -191,7 +204,10 @@ onMounted(async () => {
</script>
<template>
<AuthShell title="Log in" description="Welcome back. Log in to manage your articles.">
<AuthShell
title="Log in"
description="Welcome back. Log in to manage your articles."
>
<template v-if="alert" #notice>
<UAlert
:color="alert.tone"
@@ -205,7 +221,11 @@ onMounted(async () => {
</template>
<template #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UForm
:state="{ username, password }"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
label="Username"
name="username"
@@ -275,13 +295,21 @@ onMounted(async () => {
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+3 -3
View File
@@ -139,9 +139,9 @@ const actions = computed(() => [
:actions="actions"
>
<p class="text-sm text-pretty text-muted">
Opened it on another device? That works too; you'll log in there first. Can't find
it? Check your spam folder. If the link has expired, cancel your registration and
start again.
Opened it on another device? That works too; you'll log in there
first. Can't find it? Check your spam folder. If the link has
expired, cancel your registration and start again.
</p>
</ResultPanel>
</AuthShell>
+35 -10
View File
@@ -24,12 +24,16 @@ const passwordShown = ref(false)
const registrationClosed = ref(false)
const serverErrorAlert = ref(false)
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
const usernameValid = computed(
() => username.value.length >= 3 && username.value.length <= 20,
)
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const passwordValid = computed(() => password.value.length >= 8)
const usernameError = computed(() =>
usernameTouched.value && !usernameValid.value ? 'Use 3 to 20 characters.' : '',
usernameTouched.value && !usernameValid.value
? 'Use 3 to 20 characters.'
: '',
)
const emailError = computed(() =>
@@ -38,9 +42,13 @@ const emailError = computed(() =>
: '',
)
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const passwordRules = computed(() => [
{ label: 'At least 8 characters', met: passwordValid.value },
])
const formValid = computed(() => usernameValid.value && emailValid.value && passwordValid.value)
const formValid = computed(
() => usernameValid.value && emailValid.value && passwordValid.value,
)
function touchAll() {
usernameTouched.value = true
@@ -85,7 +93,9 @@ async function onSubmit() {
}
}
const pageTitle = computed(() => (registrationClosed.value ? '' : 'Create the owner account'))
const pageTitle = computed(() =>
registrationClosed.value ? '' : 'Create the owner account',
)
const pageDescription = computed(() =>
registrationClosed.value
@@ -142,7 +152,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</span>
</template>
<template v-else #help>
Letters and numbers, 3 to 20 characters. You'll use it to log in.
Letters and numbers, 3 to 20 characters. You'll use it
to log in.
</template>
<template #hint>
<span
@@ -184,7 +195,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
{{ error }}
</span>
</template>
<template v-else #help> We'll send a verification link here. </template>
<template v-else #help>
We'll send a verification link here.
</template>
<UInput
v-model="email"
type="email"
@@ -201,7 +214,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<UFormField
label="Password"
name="password"
:size="controlSize"
>
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
@@ -225,13 +242,21 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+5 -1
View File
@@ -10,5 +10,9 @@ function onRetry() {
</script>
<template>
<ErrorView v-if="session.status === 'error'" kind="error" @retry="onRetry" />
<ErrorView
v-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
</template>
+36 -10
View File
@@ -70,7 +70,9 @@ const passwordShown = ref(false)
const loginSubmitting = ref(false)
const loginAlert = ref<AlertState | null>(null)
const shellTitle = computed(() => (state.value === 'noSession' ? 'Log in to finish verifying' : ''))
const shellTitle = computed(() =>
state.value === 'noSession' ? 'Log in to finish verifying' : '',
)
const shellDescription = computed(() =>
state.value === 'noSession'
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
@@ -80,7 +82,9 @@ const shellDescription = computed(() =>
const usernameError = ref('')
const passwordError = ref('')
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
const loginSubmitDisabled = computed(
() => loginSubmitting.value || retryAfter.active.value,
)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
@@ -95,7 +99,11 @@ const loginSubmitLabel = computed(() => {
async function focusPassword() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
document
.querySelector<HTMLInputElement>(
'input[autocomplete="current-password"]',
)
?.focus()
}
async function performVerify(code: string) {
@@ -152,8 +160,10 @@ function handleLoginError(error: unknown) {
}
function validateLogin() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
@@ -170,7 +180,10 @@ async function onLoginSubmit() {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
const code = typeof route.query.c === 'string' ? route.query.c : ''
await retryVerifying(code)
@@ -281,8 +294,13 @@ onMounted(() => {
role="status"
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">Verifying your account…</p>
<UIcon
name="i-lucide-loader-2"
class="size-8 animate-spin text-highlighted"
/>
<p class="text-xl/7 font-semibold text-highlighted">
Verifying your account…
</p>
<p class="text-base text-muted">This only takes a moment.</p>
</div>
@@ -384,13 +402,21 @@ onMounted(() => {
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+709
View File
@@ -0,0 +1,709 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
function articleFixture(overrides: Partial<Article> = {}): Article {
return {
id: 12,
title: 'How we price a bathroom renovation',
body: 'a'.repeat(4318),
images: [
{ id: 1, url: '/images/1.png' },
{ id: 2, url: '/images/2.png' },
{ id: 3, url: '/images/3.png' },
],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
...overrides,
}
}
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),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountEditor(
handlers: Record<string, () => Response | Promise<Response>>,
path = '/article/12/edit',
) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function saveButton(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.find('[data-testid="save-button"]')
}
function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findComponent(ArticleEditor).props('modelValue')
}
async function typeBody(
wrapper: Awaited<ReturnType<typeof mount>>,
body: string,
) {
await wrapper
.findComponent(ArticleEditor)
.vm.$emit('update:modelValue', body)
await flushPromises()
}
function paragraph(inner: string) {
return `<p>${inner}</p>`
}
function paragraphOfLength(totalLength: number) {
return paragraph('a'.repeat(totalLength - 7))
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(FIXED_NOW))
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
sessionStorage.clear()
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get')
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
onLineSpy.mockRestore()
await router.push('/login')
})
describe('Skeletons appear only after 150 ms, never for a fast response', () => {
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
let resolveGet: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
new Promise((resolve) => {
resolveGet = () =>
resolve(mockResponse(200, articleFixture()))
}),
})
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(
true,
)
resolveGet?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
describe('Saved — a freshly loaded article, draft or online', () => {
it.each([
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
{ is_online: true, badge: 'Online', publishLabel: 'Unpublish' },
])(
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
expect(wrapper.text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.text()).toContain(badge)
expect(wrapper.text()).toContain('Saved at 11:02')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect(wrapper.text()).toContain(publishLabel)
expect(wrapper.text()).toContain('4 318 / 10 000')
},
)
})
describe('Unsaved changes — any edit marks the article dirty', () => {
it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.text()).toContain('Save and publish')
})
})
describe('Saving — Save sends the PATCH and disables itself while in flight', () => {
it('sends the edited body and shows Saving…', async () => {
let patchBody: string | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
patchBody = init?.body as string
return new Promise(() => {})
}
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
})
})
describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
it('sends the same PATCH on Ctrl+S', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
let patchBody: string | null = null
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
patchBody = init?.body as string
return new Promise(() => {})
}
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', ctrlKey: true }),
)
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
})
})
describe('A successful save returns to the Saved state with the new time', () => {
it('shows Saved at the current time and disables Save again', async () => {
vi.setSystemTime(new Date('2026-09-26T11:30:00Z'))
let resolvePatch: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () =>
new Promise((resolve) => {
resolvePatch = () =>
resolve(
mockResponse(
200,
articleFixture({
body: paragraphOfLength(4320),
}),
),
)
}),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
await flushPromises()
resolvePatch?.()
await flushPromises()
expect(wrapper.text()).toContain('Saved at 11:30')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
})
})
describe('SaveStatus switches to a relative time after an hour', () => {
it('reads "Saved n min ago" once more than an hour has passed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
expect(wrapper.text()).toContain('Saved at 11:02')
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
await vi.advanceTimersByTimeAsync(20000)
await flushPromises()
expect(wrapper.text()).toContain('Saved 90 min ago')
})
})
describe('Save failed — a 500 or a network failure keeps the changes and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Save failed with Retry for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': handler,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(
wrapper
.findAll('button')
.some((button) => button.text() === 'Retry'),
).toBe(true)
expect(articleEditorBody(wrapper)).toBe(
paragraph('Revised opening paragraph.'),
)
})
})
describe('Retry resends the same body and can recover', () => {
it('resends the same PATCH and shows Saved at the current time', async () => {
const patchCall = vi
.fn()
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(
mockResponse(
200,
articleFixture({
body: paragraph('Revised opening paragraph.'),
}),
),
)
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': patchCall,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
await flushPromises()
const retryButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Retry')
await retryButton?.trigger('click')
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toMatch(/Saved at \d{2}:\d{2}/)
})
})
describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => {
it('shows the toast and never reads Save failed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(404),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
title: 'That article no longer exists.',
}),
)
expect(wrapper.text()).not.toContain('Save failed')
})
})
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
it('shows the warning icon and keeps Save enabled', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(9240))
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
})
})
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(10214))
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
const publishButton = wrapper
.findAll('button')
.find(
(button) =>
button.text() === 'Publish' ||
button.text() === 'Save and publish',
)
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(
true,
)
await typeBody(wrapper, paragraphOfLength(9000))
expect(wrapper.text()).not.toContain('characters over')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
})
})
describe('Preview shows the rendered title and body without the editing surface', () => {
it('replaces the editor with a read-only rendering, then restores it unchanged', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ body: '<p>Some body</p>' })),
})
const previewSwitch = wrapper.find('button[role="switch"]')
await previewSwitch.trigger('click')
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
expect(
wrapper.find('[data-testid="article-preview"]').text(),
).toContain('How we price a bathroom renovation')
expect(
wrapper.find('[data-testid="article-preview"]').html(),
).toContain('Some body')
await previewSwitch.trigger('click')
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(true)
expect(articleEditorBody(wrapper)).toBe('<p>Some body</p>')
})
})
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
it('shows the offline SaveStatus and re-enables Save once back online', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(wrapper.text()).toContain('Waiting for connection')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.text()).toContain('Unsaved changes')
onLineSpy.mockRestore()
})
})
describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => {
it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
await flushPromises()
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Leave without saving?')
expect(props.description).toBe(
'Your changes to "How we price a bathroom renovation" haven\'t been saved.',
)
expect(props.cancelLabel).toBe('Discard changes')
expect(props.confirmLabel).toBe('Keep editing')
expect(props.dismissResolvesTo).toBe('confirm')
overlayMock.resolveOpen(false)
await navigation
expect(router.currentRoute.value.path).toBe('/article')
})
})
describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => {
it('keeps the user on the editor with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
await flushPromises()
overlayMock.resolveOpen(true)
await navigation
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
})
})
describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => {
it('keeps the user on /article/12/edit with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const navigation = router.push('/article')
await flushPromises()
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.dismissResolvesTo).toBe('confirm')
overlayMock.resolveOpen(true)
await navigation
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
})
})
describe('Leaving a clean article never prompts', () => {
it('navigates directly with no ConfirmDialog', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
await articlesLink?.trigger('click')
await flushPromises()
expect(overlayMock.getProps()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article')
})
})
describe('Session expired mid-save keeps the draft and restores it after logging back in', () => {
it('mirrors the body to sessionStorage and restores it on the next mount', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401),
})
const body = paragraph(
'Paragraph added just before the session expired.',
)
await typeBody(wrapper, body)
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe(
'/article/12/edit',
)
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/12': () =>
mockResponse(
200,
articleFixture({
body: 'Last saved body without that paragraph.',
}),
),
}) as unknown as typeof fetch
await router.push('/article/12/edit')
const secondWrapper = mount(RootComponent, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
await flushPromises()
expect(articleEditorBody(secondWrapper)).toBe(body)
expect(secondWrapper.text()).toContain('Unsaved changes')
secondWrapper.unmount()
})
})
describe('An unknown or deleted article id replaces the editor with an error state', () => {
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(
{
'GET /v1/article/9999': () => mockResponse(404),
},
'/article/9999/edit',
)
expect(wrapper.text()).toContain("This article doesn't exist.")
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Publish')
const allArticlesButton = wrapper
.findAll('button')
.find((button) => button.text() === 'All articles')
await allArticlesButton?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
})
+25 -8
View File
@@ -7,7 +7,11 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.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,
@@ -21,7 +25,9 @@ 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]
@@ -91,7 +97,9 @@ const sixArticles: Article[] = [
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountHome(handlers: Record<string, () => Response | Promise<Response>>) {
async function mountHome(
handlers: Record<string, () => Response | Promise<Response>>,
) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
@@ -149,7 +157,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
'GET /v1/article/': () =>
Promise.resolve(mockResponse(200, sixArticles)),
})
await vi.advanceTimersByTimeAsync(0)
@@ -166,7 +175,9 @@ describe('Default — the list loaded with a mix of online and draft articles',
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
const rows = wrapper.find('[data-testid="recent-list"]').findAll('li')
expect(rows).toHaveLength(5)
@@ -185,7 +196,9 @@ describe('Default — the list loaded with a mix of online and draft articles',
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('drafts')
const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide')
const guideLink = wrapper
.findAll('a')
.find((a) => a.text() === 'Open the guide')
expect(guideLink?.attributes('href')).toBe('/integration')
await findButton(wrapper, 'New article')?.trigger('click')
@@ -226,7 +239,9 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
'GET /v1/article/': handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
expect(wrapper.text()).toContain('Good')
const alert = wrapper.find('[role="alert"]')
expect(alert.exists()).toBe(true)
@@ -267,7 +282,9 @@ describe('The list is fetched exactly once per navigation', () => {
expect(listCall).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('6')
expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5)
expect(
wrapper.find('[data-testid="recent-list"]').findAll('li'),
).toHaveLength(5)
})
})
+96 -37
View File
@@ -5,7 +5,11 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import LoginView from '../LoginView.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,
@@ -19,7 +23,9 @@ 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]
@@ -59,7 +65,9 @@ async function fillAndSubmit(
password: string,
) {
await wrapper.find('input[placeholder="Your username"]').setValue(username)
await wrapper.find('input[autocomplete="current-password"]').setValue(password)
await wrapper
.find('input[autocomplete="current-password"]')
.setValue(password)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
@@ -73,16 +81,21 @@ afterEach(async () => {
describe('Default — registration is closed', () => {
it('renders the default state with no create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: false }),
'GET /v1/auth/registration': () =>
mockResponse(200, { open: false }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
@@ -93,7 +106,8 @@ describe('Default — registration is closed', () => {
describe('Fresh install — registration is open', () => {
it('renders the same fields plus the create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: true }),
'GET /v1/auth/registration': () =>
mockResponse(200, { open: true }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
@@ -129,18 +143,26 @@ describe('Submitting — fields and button are disabled while the request is in
}),
})
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('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
).toBe(true)
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.disabled,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Logging in…')
@@ -159,13 +181,19 @@ describe('Wrong credentials — the password is cleared and focused', () => {
await fillAndSubmit(wrapper, 'marta', 'wrong-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.')
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 usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('marta')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
@@ -179,20 +207,28 @@ describe('Too many attempts — the button is disabled with a live countdown', (
it('counts down from the Retry-After value and re-enables at zero', async () => {
vi.useFakeTimers()
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '42' }),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '42' }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash')
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.',
)
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe('limewash')
const button = wrapper.find('button[type="submit"]')
@@ -200,8 +236,10 @@ describe('Too many attempts — the button is disabled with a live countdown', (
expect((button.element as HTMLButtonElement).disabled).toBe(true)
const fields = () => [
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement,
]
expect(fields().map((field) => field.disabled)).toEqual([true, true])
@@ -209,9 +247,10 @@ describe('Too many attempts — the button is disabled with a live countdown', (
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect(
(wrapper.find('button[type="submit"]').element as HTMLButtonElement)
.disabled,
).toBe(false)
expect(fields().map((field) => field.disabled)).toEqual([false, false])
vi.useRealTimers()
})
@@ -219,14 +258,19 @@ describe('Too many attempts — the button is disabled with a live countdown', (
describe('Session expired — redirected from a 401 elsewhere', () => {
it('shows the session-expired alert and focuses the password field', async () => {
const wrapper = await mountLogin('/login?redirect=/article/12/edit&reason=expired', {})
const wrapper = await mountLogin(
'/login?redirect=/article/12/edit&reason=expired',
{},
)
expect(wrapper.text()).toContain('Your session expired')
expect(wrapper.text()).toContain(
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(passwordInput.element).toBe(document.activeElement)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
})
@@ -247,7 +291,11 @@ describe('Password changed — arrived from a successful reset', () => {
describe('Server or network error — the fields keep their values', () => {
it.each([
{ name: 'a 500 response', password: 'limewash2026', handler: () => mockResponse(500) },
{
name: 'a 500 response',
password: 'limewash2026',
handler: () => mockResponse(500),
},
{
name: 'a network failure',
password: 'limewash2026',
@@ -270,20 +318,28 @@ describe('Server or network error — the fields keep their values', () => {
expect(login).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe(password)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Log in')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
false,
)
},
)
})
@@ -302,12 +358,15 @@ describe('A successful log in navigates to the right destination', () => {
async ({ path, verified, destination }) => {
const wrapper = await mountLogin(path, {
'POST /v1/auth/login': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: verified }),
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: verified }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash2026')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe(destination),
)
},
)
})
+55 -29
View File
@@ -6,7 +6,11 @@ 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,
@@ -20,7 +24,9 @@ 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]
@@ -34,15 +40,17 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
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,
@@ -140,16 +148,24 @@ 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, 'Cancel registration')?.element as HTMLButtonElement).disabled,
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
.disabled,
).toBe(true)
expect(
(
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'),
)
})
})
@@ -229,7 +245,9 @@ 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',
@@ -273,9 +291,12 @@ 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()
@@ -289,18 +310,23 @@ 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()
},
)
})
+113 -54
View File
@@ -6,7 +6,11 @@ import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue'
import RegisterView from '../RegisterView.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,
@@ -20,7 +24,9 @@ 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]
@@ -33,7 +39,9 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountRegister(handlers: Record<string, () => Response | Promise<Response>> = {}) {
async function mountRegister(
handlers: Record<string, () => Response | Promise<Response>> = {},
) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
@@ -61,7 +69,9 @@ function passwordInput(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[autocomplete="new-password"]')
}
async function fillValidForm(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
async function fillValidForm(
wrapper: Awaited<ReturnType<typeof mountRegister>>,
) {
await usernameInput(wrapper).setValue('marta')
await emailInput(wrapper).setValue('marta@renew.example')
await passwordInput(wrapper).setValue('limewash2026')
@@ -82,15 +92,21 @@ describe('Default — a fresh, empty form', () => {
"This creates the single admin account for this site. Once it's verified, registration closes.",
)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe(
'',
)
expect(wrapper.text()).toContain('0 / 20')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
'',
)
expect(wrapper.text()).toContain('At least 8 characters')
expect(wrapper.find('button[type="submit"]').text()).toBe('Create account')
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Create account',
)
expect(wrapper.text()).toContain('Already set up?')
const footerLink = wrapper.find('a[href="/login"]')
expect(footerLink.exists()).toBe(true)
@@ -116,14 +132,18 @@ describe('Validation errors — checked on blur and on submit', () => {
expect(wrapper.text()).toContain('2 / 20')
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).toContain(
'Enter a full email address, like name@example.com.',
)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(register).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).toContain(
'Enter a full email address, like name@example.com.',
)
})
})
@@ -136,9 +156,11 @@ describe('Filled, valid — every rule is met', () => {
const toggle = wrapper.find('button[aria-label="Show password"]')
await toggle.trigger('click')
expect(wrapper.find('button[aria-label="Hide password"]').attributes('aria-pressed')).toBe(
'true',
)
expect(
wrapper
.find('button[aria-label="Hide password"]')
.attributes('aria-pressed'),
).toBe('true')
expect(wrapper.text()).toContain('5 / 20')
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.')
@@ -146,11 +168,17 @@ describe('Filled, valid — every rule is met', () => {
"Letters and numbers, 3 to 20 characters. You'll use it to log in.",
)
expect(wrapper.text()).not.toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).not.toContain(
'Enter a full email address, like name@example.com.',
)
expect(wrapper.text()).toContain("We'll send a verification link here.")
expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe('text')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe(
'text',
)
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
'limewash2026',
)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
@@ -172,9 +200,15 @@ describe('Submitting — fields and button are disabled while the request is in
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect(
(usernameInput(wrapper).element as HTMLInputElement).disabled,
).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(
true,
)
expect(
(passwordInput(wrapper).element as HTMLInputElement).disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Creating account…')
@@ -221,7 +255,9 @@ describe('Registration closed — the form is replaced by a ResultPanel', () =>
expect(action.text()).toBe('Go to log in')
await action.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
@@ -232,44 +268,61 @@ describe('Server or network error — the form is kept and re-enabled', () => {
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the error alert and keeps the values for $name', async ({ handler }) => {
const register = vi.fn(handler)
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': register,
})
])(
'shows the error alert and keeps the values for $name',
async ({ handler }) => {
const register = vi.fn(handler)
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': register,
})
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await flushPromises()
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
username: 'marta',
email: 'marta@renew.example',
password: 'limewash2026',
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
username: 'marta',
email: 'marta@renew.example',
password: 'limewash2026',
}),
}),
}),
)
expect(wrapper.text()).toContain("Couldn't create the account")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
)
expect(wrapper.text()).toContain("Couldn't create the account")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect(
(usernameInput(wrapper).element as HTMLInputElement).value,
).toBe('marta')
expect(
(emailInput(wrapper).element as HTMLInputElement).value,
).toBe('marta@renew.example')
expect(
(passwordInput(wrapper).element as HTMLInputElement).value,
).toBe('limewash2026')
expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect(
(usernameInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
expect(
(emailInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
expect(
(passwordInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
false,
)
},
)
})
describe('A successful registration goes straight to the pending page', () => {
@@ -282,7 +335,9 @@ describe('A successful registration goes straight to the pending page', () => {
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
@@ -307,8 +362,12 @@ describe('A successful registration goes straight to the pending page', () => {
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe('marta@renew.example')
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe(
'marta@renew.example',
)
})
})
+184 -81
View File
@@ -6,7 +6,11 @@ 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,
@@ -20,7 +24,9 @@ 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]
@@ -75,15 +81,23 @@ 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',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ code: 'abc123' }),
}),
)
})
})
@@ -105,9 +119,12 @@ 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()
@@ -135,7 +152,9 @@ 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')
@@ -146,19 +165,27 @@ 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',
@@ -170,31 +197,47 @@ describe('Cancel registration and start over — success', () => {
describe('Cancel registration and start over — failure shows an alert, not a redirect', () => {
it.each([
{ name: 'a 403 (already verified elsewhere)', handler: () => mockResponse(403) },
{
name: 'a 403 (already verified elsewhere)',
handler: () => mockResponse(403),
},
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
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', () => {
@@ -211,7 +254,9 @@ 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'),
)
})
})
@@ -225,9 +270,15 @@ 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')
@@ -259,20 +310,32 @@ 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({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ code: 'abc123' }),
}),
)
expect(verify).toHaveBeenCalledTimes(2)
})
@@ -286,14 +349,24 @@ 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)
@@ -305,16 +378,23 @@ 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)
@@ -322,7 +402,9 @@ 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',
)
})
})
@@ -333,31 +415,50 @@ 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', () => {
@@ -374,6 +475,8 @@ 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'),
)
})
})