feat: article editor core
This commit is contained in:
1 parent
d493a181e1
commit
a4734b2c91
55 files changed
+4186
-496
No files matched your search
@@ -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
@@ -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
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user