feat: register T0

This commit is contained in:
fsylvain committed 2026-09-26 02:48:16 +02:00
1 parent a5873f8886
commit 3c3f7c6194
9 files changed
+920 -4

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
Feature: Register screen
As the first person to set up a fresh RenewCMS install
I want a /register screen that creates the single owner account via POST /v1/auth/register,
validates the same rules the API enforces before submitting, and tells me plainly when
someone already owns this site or the server failed
So that I can create my account once, land on the verification-pending page, and never see a
confusing or silent failure
Background:
Given /register is a public route (meta.auth "public") rendered as RegisterPage inside
AuthShell, per docs/design/routes.md
And RegisterPage only calls POST /v1/auth/register; it never calls GET /v1/auth/registration
And the form has a Username field (placeholder "e.g. marta", a live "N / 20" character
counter, help text "Letters and numbers, 3 to 20 characters. You'll use it to log in."), an
Email field (placeholder "you@example.com", help text "We'll send a verification link
here."), and a Password field (a password input with a show/hide toggle and a live
checklist item "At least 8 characters" that is unmet/error until the rule is satisfied, then
turns met/green)
And client-side validation runs on blur of each field and again on submit, before any request
is sent, using the same rules as the API: Username must be 3 to 20 characters (the API has
no character-class constraint beyond length, so the client checks length only, matching the
help text's length claim but not attempting to enforce "letters and numbers" client-side),
Email must be a well-formed email address, and Password must be at least 8 characters
And a failing Username shows "Use 3 to 20 characters."; a failing Email shows "Enter a full
email address, like name@example.com."; a failing Password leaves the checklist item in its
unmet/error state
And a valid submit calls POST /v1/auth/register with { username, email, password }
And on a 200 response, the jwt cookie is set by the server and the app navigates to
/register/pending without re-resolving the session first
And a 403 response means someone already verified an owner account (user.ErrRegistrationClosed
on the backend) and the whole form is replaced by a ResultPanel
And a 500 response, or a request that never reaches the server, is surfaced as a server/network
error; the fields keep whatever the user typed and the form re-enables
And a 400 response is not expected once client-side validation passes, since the client checks
the same rules as the API; it is out of scope for this slice
Scenario: Default — a fresh, empty form
When the user navigates to /register
Then the heading is "Create the owner account"
And the description is "This creates the single admin account for this site. Once it's
verified, registration closes."
And the Username field shows placeholder "e.g. marta" with no value and counter "0 / 20"
And the Email field shows placeholder "you@example.com" with no value
And the Password field shows no value, with a show/hide toggle and the checklist item
"At least 8 characters" in its unmet state
And the submit button reads "Create account"
And the footer reads "Already set up?" with a "Log in" link to /login
Scenario: Validation errors — checked on blur and on submit
Given the user is on /register
When the user types "ma" into Username and blurs the field
Then the Username counter reads "2 / 20" in its error state and the error "Use 3 to 20
characters." is shown
When the user types "marta@renew" into Email and blurs the field
Then the Email field shows the error "Enter a full email address, like name@example.com."
When the user types "lime" into Password and blurs the field
Then the checklist item "At least 8 characters" stays in its error state
When the user submits with these same values
Then POST /v1/auth/register is never called
And all three errors from above are still shown
Scenario: Filled, valid — every rule is met
Given the user is on /register
When the user types "marta" into Username, "marta@renew.example" into Email, and
"limewash2026" into Password, and reveals the password with the show/hide toggle
Then the Username counter reads "5 / 20" with no error, and the help text "Letters and
numbers, 3 to 20 characters. You'll use it to log in." is shown
And the Email field shows no error, with the help text "We'll send a verification link here."
And the Password field shows "limewash2026" as plain text and the checklist item "At least 8
characters" turns to its met/green state
And the submit button reads "Create account" and is enabled
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register has not yet resolved
Then the Username, Email and Password fields are disabled
And the submit button reads "Creating account…" and shows a loading state
Scenario: Registration closed — the form is replaced by a ResultPanel
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register resolves 403
Then the form, its fields and the footer are no longer shown
And a ResultPanel with tone "info" and icon "user-x" is shown instead, with title "This site
already has an owner" and text "Log in instead."
And its single action is a primary button "Go to log in"
When the user activates "Go to log in"
Then the app navigates to /login
Scenario Outline: Server or network error — the form is kept and re-enabled
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register <failure>
Then an error alert reads "Couldn't create the account" with description "Something went
wrong on the server. Try again in a moment."
And the Username field still shows "marta", the Email field still shows "marta@renew.example",
and the Password field still shows "limewash2026"
And the Username, Email and Password fields are re-enabled
And the submit button reads "Create account" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: A successful registration goes straight to the pending page
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register resolves 200 with a jwt cookie
Then the app navigates to /register/pending
+73
View File
@@ -0,0 +1,73 @@
# QA: Register screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/register` only — RegisterPage never calls
`GET /v1/auth/registration`); no real backend, no httptest server. Mount RegisterPage inside
AuthShell with a test router.
1. **Default state** — mount at /register; assert heading "Create the owner account", description
"This creates the single admin account for this site. Once it's verified, registration
closes.", Username placeholder "e.g. marta" with no value and counter "0 / 20", Email
placeholder "you@example.com" with no value, Password with no value and the checklist item
"At least 8 characters" in its unmet state, submit button "Create account", and the footer
"Already set up? Log in" linking to /login.
2. **Validation errors state** — mount at /register; set Username to "ma" and blur; assert counter
"2 / 20" in error and the error "Use 3 to 20 characters."; set Email to "marta@renew" and blur;
assert the error "Enter a full email address, like name@example.com."; set Password to "lime"
and blur; assert the checklist item stays in its error state; submit; assert the mocked
`POST /v1/auth/register` was never called and all three errors are still shown.
3. **Filled, valid state** — set Username "marta", Email "marta@renew.example", Password
"limewash2026"; toggle password visibility on; assert counter "5 / 20" with no error and the
help text restored, no Email error, Password rendered as plain text, the checklist item in its
met/green state, and the submit button enabled and reading "Create account".
4. **Submitting state** — set Username "marta", Email "marta@renew.example", Password
"limewash2026"; mock `POST /v1/auth/register` with an unresolved promise; submit; assert
Username, Email and Password are disabled and the submit button reads "Creating account…" with
a loading indicator.
5. **Registration closed state** — set the same valid values; mock `POST /v1/auth/register`
resolving 403; submit; assert the form and footer are no longer rendered; assert a ResultPanel
with title "This site already has an owner", text "Log in instead.", and a single primary
action "Go to log in"; click it and assert the router's current path is /login.
6. **Server or network error state** — two sub-cases: (a) a 500 response, (b) a rejected fetch
(network error). In each, set Username "marta", Email "marta@renew.example", Password
"limewash2026", submit, and assert an error alert with title "Couldn't create the account" and
description "Something went wrong on the server. Try again in a moment."; assert Username,
Email and Password still show their values; assert the three fields are re-enabled; assert the
submit button reads "Create account" and is no longer loading.
7. **Successful registration navigates to pending** — mock `POST /v1/auth/register` resolving 200;
set the same valid values; submit; assert the router's current path is /register/pending, and
that no `GET /v1/auth/me` call was made by RegisterPage itself.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Register". Run
`task visual:check -- register` for each state and compare against the board frame.
- [ ] Default state: heading, description, Username with counter and help text, Email with help
text, Password with show/hide toggle and the unmet checklist item, "Create account" button,
"Already set up? Log in" footer — matches the board exactly.
- [ ] Validation errors state: Username counter and error, Email error, and the Password checklist
item's error styling all render inline per components.md.
- [ ] Filled, valid state: the checklist item's met/green transition and the revealed password
text render correctly.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Registration closed state: the ResultPanel (info tone, user-x icon tile, title, text, single
primary "Go to log in" action) fully replaces the form and footer.
- [ ] Server error state: error alert (circle-x icon, no close button per the Log in screen's
precedent) above the heading, fields retaining their values, form re-enabled.
- [ ] Keyboard/focus: Tab order is Username → Email → Password → show/hide toggle → submit; when
the Registration closed ResultPanel appears, focus moves to its title and it is
`role="status"`; the server error alert is `role="alert"` and does not steal focus from
wherever the user was; the checklist item's live state change is not visually jarring
(no layout shift) as it flips from unmet to met.
## Manual verification
- **Real POST /v1/auth/register against a running server** — Vitest only mocks `useApi`; confirm
the real 403 (`user.ErrRegistrationClosed`) and a real 200 round-trip with the actual jwt cookie
against `task dev` with a live SQLite file, since this slice's automated tests never exercise the
actual Go handler, the `CreateUser` use case, or cookie issuance.
- **Real navigation to /register/pending after a live registration** — confirm the route guard
reads the freshly set cookie correctly, since Vitest never resolves a real session against
GET /v1/auth/me on that navigation.
+8 -2
View File
@@ -60,6 +60,9 @@ const colorModeSelectUi = { trailing: 'hidden' }
watch(
() => props.title,
(title) => {
if (!title) {
return
}
document.title = `${title} · RenewCMS`
},
{ immediate: true },
@@ -67,7 +70,10 @@ watch(
watch(
() => props.title,
() => {
(title) => {
if (!title) {
return
}
titleEl.value?.focus()
},
)
@@ -86,7 +92,7 @@ watch(
<AppLogo :size="32" />
</template>
<template #title>
<template v-if="title" #title>
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
interface PasswordRule {
label: string
met: boolean
}
const props = defineProps<{
rules: PasswordRule[]
showErrors: boolean
}>()
function itemClass(rule: PasswordRule) {
if (rule.met) {
return 'text-success'
}
return props.showErrors ? 'text-error' : 'text-toned'
}
function itemIcon(rule: PasswordRule) {
if (rule.met) {
return 'i-lucide-circle-check'
}
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
}
const items = computed(() =>
props.rules.map((rule) => ({
...rule,
classes: itemClass(rule),
icon: itemIcon(rule),
})),
)
</script>
<template>
<ul class="flex flex-col gap-1">
<li
v-for="item in items"
:key="item.label"
:class="['flex items-center gap-1.5 text-sm', item.classes]"
>
<UIcon :name="item.icon" class="shrink-0" />
{{ item.label }}
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
</ul>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
interface ResultPanelAction {
label: string
color?: 'primary' | 'neutral'
variant?: 'solid' | 'soft' | 'outline' | 'ghost'
to?: string
onClick?: () => void
}
const props = defineProps<{
tone: 'success' | 'error' | 'info'
icon: string
title: string
description: string
actions: ResultPanelAction[]
}>()
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
success: 'bg-success/10 text-success',
error: 'bg-error/10 text-error',
info: 'bg-info/10 text-info',
}
const tileClass = computed(() => toneTileClass[props.tone])
const titleEl = ref<HTMLHeadingElement | null>(null)
onMounted(async () => {
await nextTick()
titleEl.value?.focus()
})
</script>
<template>
<div :role="role" class="flex flex-col gap-4">
<div :class="['flex h-11 w-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="h-6 w-6" />
</div>
<div class="flex flex-col gap-1.5">
<h1 ref="titleEl" tabindex="-1" class="text-xl font-semibold leading-7 outline-none">
{{ title }}
</h1>
<p class="text-toned text-sm">{{ description }}</p>
</div>
<div class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
:key="action.label"
block
size="lg"
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:to="action.to"
@click="action.onClick"
>
{{ action.label }}
</UButton>
</div>
<slot />
</div>
</template>
@@ -29,6 +29,28 @@ describe('AuthShell renders its title as the page heading and sets the document
})
})
describe('AuthShell with an empty title renders no heading and leaves document.title untouched', () => {
it('mounts directly with an empty title without rendering an h1 or touching document.title', () => {
document.title = 'Existing title · RenewCMS'
const wrapper = mountShell({ title: '' })
expect(wrapper.find('h1').exists()).toBe(false)
expect(document.title).toBe('Existing title · RenewCMS')
})
it('leaves document.title untouched and does not crash when the title changes to empty', async () => {
const wrapper = mountShell({ title: 'Create the owner account' })
expect(document.title).toBe('Create the owner account · RenewCMS')
await wrapper.setProps({ title: '' })
expect(wrapper.find('h1').exists()).toBe(false)
expect(document.title).toBe('Create the owner account · RenewCMS')
})
})
describe('AuthShell renders its notice and footer slots', () => {
it('renders the notice slot as-is inside the card, after the title, and the footer slot outside the card', () => {
const wrapper = mountShell(
+239 -2
View File
@@ -1,9 +1,246 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const router = useRouter()
const api = useApi()
const username = ref('')
const email = ref('')
const password = ref('')
const usernameTouched = ref(false)
const emailTouched = ref(false)
const passwordTouched = ref(false)
const submitting = ref(false)
const passwordShown = ref(false)
const registrationClosed = ref(false)
const serverErrorAlert = ref(false)
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.' : '',
)
const emailError = computed(() =>
emailTouched.value && !emailValid.value
? 'Enter a full email address, like name@example.com.'
: '',
)
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const formValid = computed(() => usernameValid.value && emailValid.value && passwordValid.value)
function touchAll() {
usernameTouched.value = true
emailTouched.value = true
passwordTouched.value = true
}
function goToLogin() {
router.push('/login')
}
async function onSubmit() {
touchAll()
if (!formValid.value) {
return
}
serverErrorAlert.value = false
submitting.value = true
try {
await api.request('/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
email: email.value,
password: password.value,
}),
})
await router.push('/register/pending')
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
registrationClosed.value = true
document.title = 'This site already has an owner · RenewCMS'
return
}
serverErrorAlert.value = true
} finally {
submitting.value = false
}
}
const pageTitle = computed(() => (registrationClosed.value ? '' : 'Create the owner account'))
const pageDescription = computed(() =>
registrationClosed.value
? undefined
: "This creates the single admin account for this site. Once it's verified, registration closes.",
)
const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</script>
<template>
<AuthShell title="Create your account">
<p>Register placeholder.</p>
<AuthShell :title="pageTitle" :description="pageDescription">
<template v-if="serverErrorAlert" #notice>
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't create the account"
description="Something went wrong on the server. Try again in a moment."
variant="soft"
:close="false"
role="alert"
/>
</template>
<template v-if="registrationClosed" #default>
<ResultPanel
tone="info"
icon="i-lucide-user-x"
title="This site already has an owner"
description="Log in instead."
:actions="closedPanelActions"
/>
</template>
<template v-else #default="{ controlSize, controlClass }">
<UForm
:state="{ username, email, password }"
class="flex flex-col gap-4"
@submit="onSubmit"
>
<UFormField
label="Username"
name="username"
:error="usernameError || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
{{ error }}
</span>
</template>
<template v-else #help>
Letters and numbers, 3 to 20 characters. You'll use it to log in.
</template>
<template #hint>
<span :class="usernameError ? 'text-error' : 'text-toned'">
{{ username.length }} / 20
</span>
</template>
<UInput
v-model="username"
placeholder="e.g. marta"
autocomplete="username"
:disabled="submitting"
variant="outline"
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
@blur="usernameTouched = true"
/>
</UFormField>
<UFormField
label="Email"
name="email"
:error="emailError || undefined"
:size="controlSize"
>
<template v-if="emailError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
{{ error }}
</span>
</template>
<template v-else #help> We'll send a verification link here. </template>
<UInput
v-model="email"
type="email"
placeholder="you@example.com"
autocomplete="email"
:disabled="submitting"
variant="outline"
color="primary"
:highlight="!!emailError"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
@blur="emailTouched = true"
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
:show-errors="passwordTouched"
/>
</template>
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="new-password"
:disabled="submitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
@blur="passwordTouched = true"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitting"
>
{{ submitting ? 'Creating account…' : 'Create account' }}
</UButton>
</UForm>
</template>
<template v-if="!registrationClosed" #footer>
<p>
Already set up?
<RouterLink to="/login" class="text-primary">Log in</RouterLink>
</p>
</template>
</AuthShell>
</template>
@@ -0,0 +1,299 @@
import { afterEach, 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 ResultPanel from '@/components/ResultPanel.vue'
import RegisterView from '../RegisterView.vue'
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()
})
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountRegister(handlers: Record<string, () => Response | Promise<Response>> = {}) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
await router.push('/register')
const wrapper = mount(RegisterView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
function usernameInput(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[placeholder="e.g. marta"]')
}
function emailInput(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[placeholder="you@example.com"]')
}
function passwordInput(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[autocomplete="new-password"]')
}
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')
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('Default — a fresh, empty form', () => {
it('renders the empty state', async () => {
const { wrapper } = await mountRegister()
expect(wrapper.find('h1').text()).toBe('Create the owner account')
expect(wrapper.text()).toContain(
"This creates the single admin account for this site. Once it's verified, registration closes.",
)
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(wrapper.text()).toContain('At least 8 characters')
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)
expect(footerLink.text()).toBe('Log in')
})
})
describe('Validation errors — checked on blur and on submit', () => {
it('shows the three errors and never calls the register endpoint', async () => {
const register = vi.fn(() => mockResponse(200))
const { wrapper } = await mountRegister({
'POST /v1/auth/register': register,
})
await usernameInput(wrapper).setValue('ma')
await usernameInput(wrapper).trigger('blur')
await emailInput(wrapper).setValue('marta@renew')
await emailInput(wrapper).trigger('blur')
await passwordInput(wrapper).setValue('lime')
await passwordInput(wrapper).trigger('blur')
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.')
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.')
})
})
describe('Filled, valid — every rule is met', () => {
it('shows no errors and a green checklist item', async () => {
const { wrapper } = await mountRegister()
await fillValidForm(wrapper)
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.text()).toContain('5 / 20')
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain(
"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()).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')
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
})
describe('Submitting — fields and button are disabled while the request is in flight', () => {
it('disables the fields and shows the loading label', async () => {
let resolveRegister: (() => void) | null = null
const { wrapper } = await mountRegister({
'POST /v1/auth/register': () =>
new Promise((resolve) => {
resolveRegister = () => resolve(mockResponse(200))
}),
})
await fillValidForm(wrapper)
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)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Creating account…')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
resolveRegister?.()
await flushPromises()
})
})
describe('Registration closed — the form is replaced by a ResultPanel', () => {
it('shows the panel and navigates to /login when activated', async () => {
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': () => mockResponse(403),
})
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(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('This site already has an owner')
expect(wrapper.text()).toContain('Log in instead.')
expect(wrapper.text()).not.toContain('Already set up?')
expect(document.title).toBe('This site already has an owner · RenewCMS')
const resultPanel = wrapper.findComponent(ResultPanel)
expect(resultPanel.props('tone')).toBe('info')
expect(resultPanel.props('icon')).toBe('i-lucide-user-x')
const action = wrapper.find('button')
expect(action.text()).toBe('Go to log in')
await action.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Server or network error — the form is kept and re-enabled', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
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,
})
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(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).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)
})
})
describe('A successful registration goes straight to the pending page', () => {
it('navigates directly to /register/pending on submit resolution', async () => {
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: false }),
})
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
username: 'marta',
email: 'marta@renew.example',
password: 'limewash2026',
}),
}),
)
})
})
+53
View File
@@ -38,6 +38,31 @@ function loginWith(response: ApiMock, path = '/login'): Omit<VisualState, 'steps
}
}
async function fillRegister(page: Page, username: string, email: string, password: string) {
await page.getByPlaceholder('e.g. marta').fill(username)
await page.getByPlaceholder('e.g. marta').blur()
await page.getByPlaceholder('you@example.com').fill(email)
await page.getByPlaceholder('you@example.com').blur()
await page.locator('input[autocomplete="new-password"]').fill(password)
await page.locator('input[autocomplete="new-password"]').blur()
}
function registerWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: '/register',
api: {
'POST /v1/auth/register': response,
},
}
}
function submitRegister(username: string, email: string, password: string) {
return async (page: Page) => {
await fillRegister(page, username, email, password)
await page.getByRole('button', { name: 'Create account', exact: true }).click()
}
}
export const visualStates: Record<string, Record<string, VisualState>> = {
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
@@ -74,4 +99,32 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitLogin('marta', 'limewash2026'),
},
},
register: {
Default: { path: '/register' },
'Validation errors': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'ma', 'marta@renew', 'lime')
},
},
'Filled, valid': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'marta', 'marta@renew.example', 'limewash2026')
await page.getByRole('button', { name: 'Show password' }).click()
},
},
Submitting: {
...registerWith({ status: 200, hang: true }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Registration closed (403)': {
...registerWith({ status: 403 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Server error': {
...registerWith({ status: 500 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
},
}