refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types

This commit is contained in:
Florian Sylvain committed 2026-10-29 16:28:25 +01:00
1 parent 5d6f378fce
commit b39a41be88
124 files changed
+8816 -523

No files matched your search

+1
View File
@@ -218,6 +218,7 @@ onMounted(loadKeys)
<template>
<DashboardShell
compact-padding
:mobile-title="t('API keys')"
back-to="/integration"
sticky-footer
+33 -6
View File
@@ -9,6 +9,8 @@ import ArticleCardList from '@/components/ArticleCardList.vue'
import ArticleListNotice from '@/components/ArticleListNotice.vue'
import ArticleListToolbar from '@/components/ArticleListToolbar.vue'
import EmptyState from '@/components/EmptyState.vue'
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import { useArticleList } from '@/composables/useArticleList'
@@ -24,11 +26,13 @@ const DEFAULT_SKELETON_ROWS = 6
const router = useRouter()
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
const writerLine = computed(() =>
access.canCreate.value &&
access.updateScope.value === 'all' &&
access.publishScope.value === 'none'
? t('You can write and edit all articles. Publishing is done by an editor.')
: t("Everything you've written, online and in draft."),
access.readsOwnOnly.value
? t('Online articles and the drafts you created.')
: access.canCreate.value &&
access.updateScope.value === 'all' &&
access.publishScope.value === 'none'
? t('You can write and edit all articles. Publishing is done by an editor.')
: t("Everything you've written, online and in draft."),
)
const list = useArticleList({
@@ -154,6 +158,17 @@ onMounted(() => {
</UButton>
</div>
<ReadOnlyBanner
v-if="access.readsOwnOnly.value"
icon="i-lucide-eye-off"
:title="t('Drafts by others are hidden')"
:description="
t(
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
)
"
/>
<ArticleListToolbar
v-model:search="searchInput"
:status="desired.status"
@@ -177,8 +192,20 @@ onMounted(() => {
/>
<template v-if="hasLoaded || loadFailed || showSkeleton">
<OwnScopeEmpty
v-if="emptyVariant === 'no-drafts' && access.readsOwnOnly.value"
icon="i-lucide-file-text"
:title="t('You have no drafts')"
:description="
t(
'Drafts you create appear here. Your role can\'t see drafts by other people.',
)
"
:action="access.canCreate.value ? t('New article') : undefined"
@action="openNewArticleModal"
/>
<EmptyState
v-if="emptyVariant"
v-else-if="emptyVariant"
:variant="emptyVariant"
:query="emptyVariant === 'no-site' ? siteName : shown.q"
@action="onEmptyAction"
+7 -6
View File
@@ -64,6 +64,12 @@ const sections = computed(() => [
{ id: 'notes', label: t('Notes') },
])
const authKeyCopy = computed(() =>
t(
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.',
),
)
const authItems = computed(() => {
const examples = authExamples(origin.value)
return [
@@ -487,12 +493,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
/>
</div>
<span class="text-pretty text-toned"
><CodeText
:text="
t(
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role (owner or admin). A key never has more rights than its creator.',
)
"
><CodeText :text="authKeyCopy"
/></span>
<CodeSnippet v-model:active="authTab" :tabs="authItems" />
<span class="text-pretty text-muted"
+2 -2
View File
@@ -265,8 +265,8 @@ onBeforeRouteLeave(async () => {
</script>
<template>
<DashboardShell render-while-checking>
<div class="flex max-w-164 flex-col gap-5">
<DashboardShell compact-padding render-while-checking>
<div class="flex max-w-164 flex-col gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Profile') }}
+48 -6
View File
@@ -5,6 +5,7 @@ import DashboardShell from '@/components/DashboardShell.vue'
import PermissionGrid from '@/components/PermissionGrid.vue'
import PermissionGroup from '@/components/PermissionGroup.vue'
import PresetPills from '@/components/PresetPills.vue'
import RoleDifferencesDialog from '@/components/RoleDifferencesDialog.vue'
import RoleNotices from '@/components/RoleNotices.vue'
import RolePreview from '@/components/RolePreview.vue'
import RoleSaveBar from '@/components/RoleSaveBar.vue'
@@ -92,7 +93,19 @@ onMounted(editor.load)
</script>
<template>
<DashboardShell>
<DashboardShell compact-padding>
<template #breadcrumb>
<UBreadcrumb
:items="[
{ label: t('Settings') },
{ label: t('Roles'), to: '/settings/roles' },
{ label: title },
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div
v-if="editor.loadState.value === 'loading'"
class="flex flex-col gap-3"
@@ -177,14 +190,14 @@ onMounted(editor.load)
:reason="editor.readOnlyReason.value"
:conflict="editor.conflict.value"
:refusal="editor.refusal.value"
:save-failed="editor.saveFailed.value"
:can-duplicate="canDuplicate"
:warn-members="warnMembers"
:reviewing="editor.reviewing.value"
:differences="editor.differenceRows.value"
@duplicate="editor.duplicate"
@reload-role="editor.reloadRole"
@reload-page="editor.reloadPage"
@review="editor.toggleReview"
@review="editor.openReview"
@retry-save="editor.save"
/>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
@@ -218,7 +231,7 @@ onMounted(editor.load)
</UFormField>
</section>
<section class="flex flex-col gap-3">
<section class="flex flex-col gap-3 border border-default bg-default p-4">
<div class="flex flex-col gap-0.5">
<h2 class="text-base font-semibold text-highlighted">
{{ t('Content permissions') }}
@@ -246,9 +259,27 @@ onMounted(editor.load)
@change="editor.changeCell"
@reset="editor.resetType"
/>
<UAlert
v-if="editor.readsOwn.value"
color="info"
variant="soft"
icon="i-lucide-info"
:close="false"
:title="t('Read: Own')"
:description="
t(
'Articles show online articles plus the person\'s own drafts. Custom content types show only entries the person created. Status counts follow the same rule.',
)
"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'text-toned',
}"
/>
</section>
<section class="flex flex-col gap-3">
<section class="flex flex-col gap-3 border border-default bg-default p-4">
<div class="flex flex-wrap items-end justify-between gap-2">
<div class="flex flex-col gap-0.5">
<h2 class="text-base font-semibold text-highlighted">
@@ -318,6 +349,17 @@ onMounted(editor.load)
/>
</div>
<RoleDifferencesDialog
v-if="editor.conflict.value"
:open="editor.reviewing.value"
:description="editor.conflictSummary.value"
:rows="editor.differenceRows.value"
:pending="editor.keeping.value"
@update:open="(value: boolean) => !value && editor.closeReview()"
@use-saved="editor.reloadRole"
@keep-edits="editor.keepEdits"
/>
<RolePreview
v-if="!isDesktop"
v-model:open="sheetOpen"
+47 -2
View File
@@ -10,6 +10,7 @@ import RolesList from '@/components/RolesList.vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import type { LoadState } from '@/composables/useRoleEditor'
import { useRoles } from '@/composables/useRoles'
import { staleToast, userToast } from '@/lib/userRefusal'
import { canCover } from '@/lib/grantLocks'
import type { RoleItem } from '@/lib/roles'
import { usePermissionsStore } from '@/stores/permissions'
@@ -83,6 +84,48 @@ function onDeleted(role: RoleItem) {
}))
}
const INFO_TOAST_MS = 6000
function onRoleGone(role: RoleItem) {
roles.value = roles.value.filter((item) => item.id !== role.id)
toast.add(
userToast(
`role-gone-${role.id}`,
'info',
() => t('That role no longer exists'),
() => t('It was deleted somewhere else, so we removed it from the list.'),
INFO_TOAST_MS,
),
)
}
async function onRoleConflict(role: RoleItem) {
let editor = t('Someone')
try {
const fresh = await listRoles()
roles.value = fresh
editor = fresh.find((item) => item.id === role.id)?.updated_by?.name ?? editor
} catch {
editor = t('Someone')
}
const id = `role-conflict-${role.id}`
toast.add(
staleToast(
id,
() => t('Role changed elsewhere'),
() =>
t('{0} edited {1}, so nothing was saved. Reload and try again.', [
editor,
role.name,
]),
() => {
toast.remove(id)
void load()
},
),
)
}
function onDialogOpenChange(isOpen: boolean) {
if (!isOpen) {
deleting.value = null
@@ -97,7 +140,7 @@ onMounted(load)
</script>
<template>
<DashboardShell>
<DashboardShell compact-padding>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
@@ -205,7 +248,9 @@ onMounted(load)
:can-assign="canAssign"
@update:open="onDialogOpenChange"
@deleted="onDeleted"
@gone="load"
@gone="onRoleGone"
@conflict="onRoleConflict"
@refreshed="roles = $event"
@duplicate="onDuplicate"
/>
</DashboardShell>
+87 -5
View File
@@ -5,11 +5,18 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import ChangeRoleDialog from '@/components/ChangeRoleDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import InviteUserModal from '@/components/InviteUserModal.vue'
import RemoveUserDialog from '@/components/RemoveUserDialog.vue'
import UserCardList from '@/components/UserCardList.vue'
import TransferOwnershipDialog from '@/components/TransferOwnershipDialog.vue'
import UsersTable from '@/components/UsersTable.vue'
import { useInviteRows } from '@/composables/useInviteRows'
import { useRoles } from '@/composables/useRoles'
import { useUsers } from '@/composables/useUsers'
import { useLocalizedToast } from '@/i18n/presentation'
import { elideDe } from '@/lib/frenchElision'
import { canCover } from '@/lib/grantLocks'
import { staleToast } from '@/lib/userRefusal'
import type { RoleItem } from '@/lib/roles'
import type { AcceptedUser, UserListItem } from '@/lib/users'
import type { UserMenuContext } from '@/lib/userMenu'
import { useSessionStore } from '@/stores/session'
@@ -23,6 +30,8 @@ type LoadState = 'loading' | 'ready' | 'error'
const session = useSessionStore()
const permissions = usePermissionsStore()
const { listUsers } = useUsers()
const { listRoles } = useRoles()
const toast = useLocalizedToast()
const isDesktop = useIsDesktop()
const loadState = ref<LoadState>('loading')
@@ -34,6 +43,9 @@ const { busyKeys, onInvited, confirmCancel, renew } = useInviteRows(items, highl
const changeTarget = ref<AcceptedUser | null>(null)
const changeOpen = ref(false)
const transferOpen = ref(false)
const removeTarget = ref<AcceptedUser | null>(null)
const removeOpen = ref(false)
const rolesById = ref<Map<number, RoleItem>>(new Map())
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let opener: HTMLElement | null = null
@@ -42,7 +54,8 @@ const canInvite = computed(() => permissions.can('users.invite'))
const menu = computed<UserMenuContext | undefined>(() => {
const canChangeRole = permissions.can('users.change_role')
const canTransfer = permissions.can('system.transfer_ownership')
if (!canChangeRole && !canTransfer) {
const canRemove = permissions.can('users.remove')
if (!canChangeRole && !canTransfer && !canRemove) {
return undefined
}
return {
@@ -50,8 +63,11 @@ const menu = computed<UserMenuContext | undefined>(() => {
canChangeRole,
canTransfer,
canViewRoles: permissions.can('roles.view'),
canRemove,
canCover: canCoverRole,
changeRole: openChangeRole,
transfer: openTransfer,
removeUser: openRemove,
}
})
const showList = computed(
@@ -61,6 +77,22 @@ const showOnlyYouHint = computed(
() => loadState.value === 'ready' && items.value.length === 1 && canInvite.value,
)
function canCoverRole(user: AcceptedUser) {
const role = user.role ? rolesById.value.get(user.role.id) : undefined
return role === undefined || canCover(role, permissions.scopeOf)
}
async function loadRolesQuietly() {
if (!permissions.can('users.remove') || permissions.role?.built_in) {
return
}
try {
rolesById.value = new Map((await listRoles()).map((role) => [role.id, role]))
} catch {
rolesById.value = new Map()
}
}
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
@@ -107,16 +139,24 @@ function openChangeRole(user: AcceptedUser, trigger: HTMLElement | null) {
changeOpen.value = true
}
function openRemove(user: AcceptedUser, trigger: HTMLElement | null) {
rememberOpener(trigger)
removeTarget.value = user
removeOpen.value = true
}
function openTransfer(trigger: HTMLElement | null) {
rememberOpener(trigger)
transferOpen.value = true
}
function onOverlayOpenChange(target: 'change' | 'transfer', isOpen: boolean) {
function onOverlayOpenChange(target: 'change' | 'transfer' | 'remove', isOpen: boolean) {
if (target === 'change') {
changeOpen.value = isOpen
} else {
} else if (target === 'transfer') {
transferOpen.value = isOpen
} else {
removeOpen.value = isOpen
}
if (!isOpen) {
restoreFocus()
@@ -129,6 +169,34 @@ function onRoleChanged(updated: AcceptedUser) {
)
}
function onRemoved(removed: AcceptedUser) {
items.value = items.value.filter((item) => !(item.kind === 'user' && item.id === removed.id))
}
function onRemoveRoleChanged(member: AcceptedUser) {
const id = `user-remove-stale-${member.id}`
const name = member.display_name || member.username
void reloadQuietly()
toast.add(
staleToast(
id,
() => t('User not removed'),
() =>
elideDe(
t(
"{0}'s role changed in the meantime, so nothing was removed. Check it and try again.",
[name],
),
name,
),
() => {
toast.remove(id)
void reloadQuietly()
},
),
)
}
function openInviteModal() {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
inviteModalOpen.value = true
@@ -147,12 +215,15 @@ function onInviteModalOpenChange(isOpen: boolean) {
}
}
onMounted(loadUsers)
onMounted(async () => {
await loadUsers()
await loadRolesQuietly()
})
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<DashboardShell>
<DashboardShell compact-padding>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
@@ -258,6 +329,17 @@ onBeforeUnmount(clearSkeletonTimer)
:user="changeTarget"
@update:open="onOverlayOpenChange('change', $event)"
@changed="onRoleChanged"
@refreshed="onRoleChanged"
@stale="reloadQuietly"
/>
<RemoveUserDialog
v-if="removeTarget && removeOpen"
:open="removeOpen"
:user="removeTarget"
@update:open="onOverlayOpenChange('remove', $event)"
@removed="onRemoved"
@role-changed="onRemoveRoleChanged"
@stale="reloadQuietly"
/>
@@ -163,6 +163,18 @@ describe('Filter bar: each control sends its query parameter and resets the page
})
})
it('chooses Category "Roles"', async () => {
await onPage3()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'roles')
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
category: 'roles',
})
expect(chipTexts()).toEqual(['Category: Roles'])
})
it('chooses Outcome "Succeeded", then "All"', async () => {
await onPage3()
await clickIn('Outcome', 'Succeeded')
@@ -168,3 +168,98 @@ describe('The French interface', () => {
expect(textOf(document.body.querySelector('h1'))).toBe('Mon activité')
})
})
describe('The role events in the list', () => {
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
const roleChange = {
field: 'role',
kind: 'text' as const,
before: 'Writer',
after: 'Content lead',
}
function roleEvents(outcome: 'success' | 'failed') {
return [
makeItem({
id: 1,
action: 'role.create',
category: 'roles',
outcome,
target: roleTarget('Contributor'),
}),
makeItem({
id: 2,
action: 'role.edit',
category: 'roles',
outcome,
target: roleTarget('Contributor'),
}),
makeItem({
id: 3,
action: 'role.delete',
category: 'roles',
outcome,
target: roleTarget('Intern'),
}),
makeItem({
id: 4,
action: 'role.reassign',
category: 'roles',
outcome,
target: roleTarget('Intern'),
params: { from: 5, to: 3, moved: 3, to_name: 'Writer' },
}),
makeItem({
id: 5,
action: 'user.change_role',
category: 'users',
outcome,
target: userTarget('Ines Duarte'),
changes: [roleChange],
}),
makeItem({
id: 6,
action: 'user.transfer_ownership',
category: 'users',
outcome,
target: userTarget('Jonas Weber'),
}),
]
}
function sentences() {
return Array.from(document.body.querySelectorAll('[data-testid="activity-sentence"]')).map(
textOf,
)
}
it('lists the six events with their sentences', async () => {
await mountActivity({ list: () => pageOf(roleEvents('success')) })
expect(sentences()).toEqual([
'Role Contributor created',
'Role Contributor edited',
'Role Intern deleted',
'Moved 3 members from Intern to Writer',
"Ines Duarte's role changed from Writer to Content lead",
'Ownership transferred from Marta Alves to Jonas Weber',
])
})
it('lists the six failed events with the Failed badge', async () => {
await mountActivity({ list: () => pageOf(roleEvents('failed')) })
expect(sentences()).toEqual([
'Failed to create the role Contributor',
'Failed to edit the role Contributor',
'Failed to delete the role Intern',
'Failed to move 3 members from Intern to Writer',
'Failed to change the role of Ines Duarte',
'Failed to transfer ownership to Jonas Weber',
])
for (const row of rows()) {
expect(textOf(row)).toContain('Failed')
}
})
})
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { customAccess } from '@/test/access'
import {
articleOf,
createMetaBackend,
mountMeta,
textOf,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
localStorage.clear()
initializeLocale()
await unmountMeta()
})
const READ_OWN = 'content.read=own, content.create, content.update=own'
const READ_ALL = 'content.read=all, content.create, content.update=own'
const BOARD_ARTICLES = [
articleOf(1, 'How we price a bathroom renovation', { is_online: true }),
articleOf(2, 'Retrofit grants in 2027', { is_online: true }),
articleOf(3, 'Winter checklist for timber windows', { is_online: true }),
articleOf(4, 'Choosing lime plaster', { is_online: false }),
articleOf(5, 'Site safety checklist', { is_online: false }),
]
function backendFor(scope: string, articles = BOARD_ARTICLES): MetaBackend {
const backend = createMetaBackend()
backend.me.access = customAccess(scope, 'Junior writer')
backend.articles = articles
return backend
}
function tabs() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).map((tab) =>
textOf(tab).replace(/\s+/g, ' '),
)
}
function page() {
return textOf(document.body)
}
describe('Articles list: Read = Own with drafts of their own', () => {
it('reads the header, the banner and the tabs from the counts of the answer', async () => {
await mountMeta('articles', '/article', { backend: backendFor(READ_OWN) })
expect(page()).toContain('Online articles and the drafts you created.')
expect(page()).toContain('Drafts by others are hidden')
expect(page()).toContain(
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
)
expect(tabs()).toEqual(expect.arrayContaining(['All 5', 'Online 3', 'Drafts 2']))
})
it('keeps New article enabled when the role can create', async () => {
await mountMeta('articles', '/article', { backend: backendFor(READ_OWN) })
const button = Array.from(document.body.querySelectorAll('button')).find(
(candidate) => textOf(candidate) === 'New article',
)
expect(button).toBeDefined()
expect(button!.disabled).toBe(false)
})
it('takes the tabs exactly from the API whatever the scope', async () => {
const backend = backendFor(READ_OWN)
backend.responders['GET /v1/article/'] = () => ({
items: BOARD_ARTICLES.slice(0, 2),
total: 2,
page: 1,
size: 25,
counts: { all: 2, online: 2, draft: 0, suggestions: 0 },
})
await mountMeta('articles', '/article', { backend })
expect(tabs()).toEqual(expect.arrayContaining(['All 2', 'Online 2', 'Drafts 0']))
})
})
describe('Articles list: no drafts of their own', () => {
it('shows the own-drafts empty state on the Draft tab with the counts 3, 3, 0', async () => {
await mountMeta('articles', '/article?status=draft', {
backend: backendFor(READ_OWN, BOARD_ARTICLES.slice(0, 3)),
})
expect(page()).toContain('You have no drafts')
expect(page()).toContain(
"Drafts you create appear here. Your role can't see drafts by other people.",
)
expect(tabs()).toEqual(expect.arrayContaining(['All 3', 'Online 3', 'Drafts 0']))
const action = Array.from(
document.body.querySelectorAll('main button, #dashboard-content button'),
).find((candidate) => textOf(candidate) === 'New article')
expect(action).toBeDefined()
})
it('hides the New article action when the role cannot create', async () => {
await mountMeta('articles', '/article?status=draft', {
backend: backendFor('content.read=own', BOARD_ARTICLES.slice(0, 3)),
})
expect(page()).toContain('You have no drafts')
expect(page()).not.toContain('New article')
})
})
describe('Articles list: Read = All is unchanged', () => {
it('shows no banner and the existing header', async () => {
await mountMeta('articles', '/article', { backend: backendFor(READ_ALL) })
expect(page()).not.toContain('Drafts by others are hidden')
expect(page()).not.toContain('Online articles and the drafts you created.')
})
it('keeps the existing empty state of the Draft tab', async () => {
await mountMeta('articles', '/article?status=draft', {
backend: backendFor(READ_ALL, BOARD_ARTICLES.slice(0, 3)),
})
expect(page()).toContain('No drafts')
expect(page()).not.toContain('You have no drafts')
})
})
describe('Articles list: Read = Own in French', () => {
it('reads the banner and the empty state in French', async () => {
setLocale('fr')
await mountMeta('articles', '/article?status=draft', {
backend: backendFor(READ_OWN, BOARD_ARTICLES.slice(0, 3)),
})
await flushPromises()
expect(page()).toContain('Les brouillons des autres sont masqués')
expect(page()).toContain('Vous n’avez aucun brouillon')
})
})
@@ -0,0 +1,158 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import { customAccess } from '@/test/access'
import {
customTypes,
eventEntries,
installSelectPolyfills,
listPage,
lookupNames,
mountList,
text,
unmountAll,
} from './contentTestKit'
import { failWithBody, mountEditor, main as editorMain } from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(async () => {
localStorage.clear()
initializeLocale()
await unmountAll()
})
const READ_OWN = 'content.read:events=own, content.create:events=all'
const READ_ALL = 'content.read:events=all, content.create:events=all'
function main() {
return document.body.querySelector('#dashboard-content')!
}
function accessWith(spec: string) {
return customAccess(spec, 'Junior writer')
}
function newEntryButtons() {
return [...main().querySelectorAll('button')].filter(
(candidate) => text(candidate) === 'New entry',
)
}
const three = (_key: string, query: URLSearchParams) => listPage(eventEntries.slice(0, 3), 3, query)
const none = (_key: string, query: URLSearchParams) => listPage([], 0, query)
describe('Entry list: Read = Own with entries of their own', () => {
it('reads the header, the banner and the count line from the total', async () => {
await mountList('/content/events', {
access: accessWith(READ_OWN),
list: three,
lookup: lookupNames,
})
expect(text(main())).toContain('Events')
expect(text(main())).toContain('Your role shows only entries you created.')
expect(text(main())).toContain("Entries by others aren't shown")
expect(text(main())).toContain(
'Your role reads only entries you created. Ask an owner or admin if you need to see more.',
)
expect(text(main())).toContain('3 entries')
expect(main().querySelector('nav[aria-label*="pagination" i]')).toBeNull()
expect(newEntryButtons().length).toBeGreaterThan(0)
expect(newEntryButtons().every((button) => !button.disabled)).toBe(true)
})
it('shows neither the banner nor the own-scope header under Read = All', async () => {
await mountList('/content/events', {
access: accessWith(READ_ALL),
list: three,
lookup: lookupNames,
})
expect(text(main())).not.toContain("Entries by others aren't shown")
expect(text(main())).not.toContain('Your role shows only entries you created.')
})
})
describe('Entry list: Read = Own with no entries of their own', () => {
it('says what the person has not created, without hinting at other entries', async () => {
await mountList('/content/events', { access: accessWith(READ_OWN), list: none })
expect(text(main())).toContain("You haven't created an event yet")
expect(text(main())).toContain(
"Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one.",
)
expect(text(main())).not.toContain('No entries yet')
expect(newEntryButtons().length).toBeGreaterThan(0)
})
it('hides the action when the role cannot create', async () => {
await mountList('/content/events', {
access: accessWith('content.read:events=own'),
list: none,
})
expect(text(main())).toContain("You haven't created an event yet")
expect(newEntryButtons()).toHaveLength(0)
})
it('keeps the existing empty state under Read = All', async () => {
await mountList('/content/events', { access: accessWith(READ_ALL), list: none })
expect(text(main())).toContain('No entries yet')
expect(text(main())).not.toContain("You haven't created an event yet")
})
it('reads in French', async () => {
setLocale('fr')
await mountList('/content/events', { access: accessWith(READ_OWN), list: none })
expect(text(main())).toContain('Vous n’avez pas encore créé d’entrée dans Events')
expect(text(main())).toContain('Les entrées des autres ne sont pas affichées')
})
})
describe('Entry list: the singular noun comes from the type name', () => {
it.each([
[
'Recipes',
"You haven't created a recipe yet",
"other people's recipes aren't listed here",
],
[
'Press mentions',
"You haven't created a press mention yet",
"other people's press mentions aren't listed here",
],
[
'Categories',
"You haven't created a category yet",
"other people's categories aren't listed here",
],
])('%s', async (name, title, fragment) => {
await mountList('/content/events', {
access: accessWith(READ_OWN),
list: none,
type: () => ({ ...customTypes[0]!, name }),
})
expect(text(main())).toContain(title)
expect(text(main())).toContain(fragment)
})
})
describe('Entry list: a direct link to a hidden entry', () => {
it('shows the existing Entry not found page and nothing about the entry', async () => {
await mountEditor('/content/events/1', {
access: accessWith(READ_OWN),
entry: failWithBody(404, 'entry_not_found'),
})
expect(text(editorMain())).toContain('Entry not found')
expect(text(editorMain())).not.toContain('created by')
})
})
@@ -228,3 +228,167 @@ describe('A missing target', () => {
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
})
})
describe('Load more: the next page is loading', () => {
const TOTAL = 62
const PAGE = 25
const SKELETON_ROWS = 3
const pageNames = (page: number) =>
Array.from(
{ length: Math.max(0, Math.min(PAGE, TOTAL - (page - 1) * PAGE)) },
(_, index) => `Rue Verte hall ${(page - 1) * PAGE + index + 1}`,
)
const answerPage = (page: number) => ({
items: pageNames(page).map((name, index) => ({
id: 100 + (page - 1) * PAGE + index,
data: { name, city: '', details: '' },
relations: {},
})),
total: TOTAL,
page,
size: PAGE,
})
const moreSkeletons = () =>
document.body.querySelectorAll('[data-testid="relation-more-skeleton"]')
const footer = () => text(document.body.querySelector('[data-testid="relation-footer"]'))
const loadMore = () => button('Load more') ?? button('Loading…')
function pending(release: { resolve?: (value: unknown) => void } = {}) {
return (_key: string, query: URLSearchParams) => {
const page = Number(query.get('page'))
if (page === 1) {
return answerPage(1)
}
return new Promise((resolve) => {
release.resolve = () => resolve(answerPage(page))
})
}
}
it('shows the footer count with Load more before the press', async () => {
await mountRelation('/content/events/42', {
search: (_key, query) => answerPage(Number(query.get('page'))),
})
await openList('venue', vi, 'rue')
expect(footer()).toContain('Showing 25 of 62')
expect(button('Load more')).toBeDefined()
expect(moreSkeletons()).toHaveLength(0)
})
it('sends page 2, shows skeleton rows after the last result and a disabled Loading… button', async () => {
await mountRelation('/content/events/42', { search: pending() })
await openList('venue', vi, 'rue')
button('Load more')!.click()
await flushPromises()
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?q=rue&page=2&size=25')
expect(moreSkeletons()).toHaveLength(SKELETON_ROWS)
const loading = button('Loading…')!
expect(loading.getAttribute('aria-disabled') ?? String(loading.disabled)).toBe('true')
expect(button('Load more')).toBeUndefined()
expect(footer()).toContain('Showing 25 of 62')
expect(optionLabels()).toHaveLength(25)
})
it('sends nothing on a second press while loading', async () => {
await mountRelation('/content/events/42', { search: pending() })
await openList('venue', vi, 'rue')
button('Load more')!.click()
await flushPromises()
const before = searches().length
button('Loading…')!.click()
await flushPromises()
expect(searches()).toHaveLength(before)
})
it('replaces the skeleton with the new results and moves focus to the first new row', async () => {
const release: { resolve?: (value: unknown) => void } = {}
await mountRelation('/content/events/42', { search: pending(release) })
await openList('venue', vi, 'rue')
button('Load more')!.click()
await flushPromises()
release.resolve!(null)
await flushPromises()
expect(moreSkeletons()).toHaveLength(0)
expect(optionLabels()).toHaveLength(50)
expect(footer()).toContain('Showing 50 of 62')
expect(button('Load more')).toBeDefined()
expect(text(document.activeElement)).toContain('Rue Verte hall 26')
})
it('ends with Showing 62 of 62 and no button on the last page', async () => {
await mountRelation('/content/events/42', {
search: (_key, query) => answerPage(Number(query.get('page'))),
})
await openList('venue', vi, 'rue')
button('Load more')!.click()
await flushPromises()
button('Load more')!.click()
await flushPromises()
expect(footer()).toContain('Showing 62 of 62')
expect(loadMore()).toBeUndefined()
expect(optionLabels()).toHaveLength(62)
})
it.each([
['500', () => new ApiError(500)],
['a network failure', () => new NetworkError()],
])(
'restores Load more, keeps the results and refocuses the button when the page answers %s',
async (_failure, error) => {
await mountRelation('/content/events/42', {
search: (_key, query) => {
if (Number(query.get('page')) === 1) {
return answerPage(1)
}
throw error()
},
})
await openList('venue', vi, 'rue')
button('Load more')!.click()
await flushPromises()
expect(moreSkeletons()).toHaveLength(0)
expect(optionLabels()).toHaveLength(25)
expect(button('Load more')).toBeDefined()
expect(document.activeElement).toBe(button('Load more'))
},
)
it('ignores the pending page when a new term is typed', async () => {
const release: { resolve?: (value: unknown) => void } = {}
await mountRelation('/content/events/42', {
search: (_key, query) => {
const page = Number(query.get('page'))
if (query.get('q') === 'port') {
return { items: [], total: 0, page: 1, size: PAGE }
}
if (page === 1) {
return answerPage(1)
}
return new Promise((resolve) => {
release.resolve = () => resolve(answerPage(2))
})
},
})
await openList('venue', vi, 'rue')
button('Load more')!.click()
await flushPromises()
await typeSearch('venue', 'port')
await settleSearch(vi)
release.resolve!(null)
await flushPromises()
expect(moreSkeletons()).toHaveLength(0)
expect(optionLabels()).toEqual([])
expect(footer()).not.toContain('Showing 50')
})
})
+19 -15
View File
@@ -223,20 +223,20 @@ describe('Default, every card collapsed', () => {
it.each([
['GET /v1/content-type/', 'Verified user'],
['GET /v1/content-type/{key}', 'Verified user'],
['POST /v1/content-type/', 'Owner or admin'],
['PUT /v1/content-type/{key}', 'Owner or admin'],
['DELETE /v1/content-type/{key}', 'Owner or admin'],
['POST /v1/content-type/', 'Needs permission'],
['PUT /v1/content-type/{key}', 'Needs permission'],
['DELETE /v1/content-type/{key}', 'Needs permission'],
['GET /v1/content-type/{key}/entry', 'Verified user'],
['POST /v1/content-type/{key}/entry', 'Verified user'],
['GET /v1/content-type/{key}/entry/{id}', 'Verified user'],
['PUT /v1/content-type/{key}/entry/{id}', 'Verified user'],
['DELETE /v1/content-type/{key}/entry/{id}', 'Verified user'],
['GET /v1/builtin-type/', 'Owner or admin'],
['GET /v1/builtin-type/{key}', 'Owner or admin'],
['GET /v1/media/purge-settings', 'Owner or admin'],
['PATCH /v1/media/purge-settings', 'Owner or admin'],
['GET /v1/media/purge-preview', 'Owner or admin'],
['POST /v1/media/purge', 'Owner or admin'],
['GET /v1/builtin-type/', 'Needs permission'],
['GET /v1/builtin-type/{key}', 'Needs permission'],
['GET /v1/media/purge-settings', 'Needs permission'],
['PATCH /v1/media/purge-settings', 'Needs permission'],
['GET /v1/media/purge-preview', 'Needs permission'],
['POST /v1/media/purge', 'Needs permission'],
['GET /v1/site/', 'Verified user'],
['GET /v1/author/', 'Verified user'],
])('shows %s with the chip %s and API key OK', async (route, access) => {
@@ -252,10 +252,10 @@ describe('Default, every card collapsed', () => {
const wrapper = await mountIntegration()
expect(section(wrapper, 'ctypes').text()).toContain(
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
)
expect(section(wrapper, 'entries').text()).toContain(
'Read and write the content of a custom type. Any verified user can write entries.',
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.",
)
const builtin = section(wrapper, 'builtin').text()
expect(builtin).toContain('Built-in types are read-only')
@@ -462,7 +462,7 @@ describe('Authentication', () => {
expect(auth.text()).toContain('GET /v1/article/')
expect(auth.text()).toContain('GET /v1/article/{id}')
expect(auth.text()).toContain(
"Send Authorization: Bearer rcms_…. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.",
'Send Authorization: Bearer rcms_…. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.',
)
expect(auth.text()).toContain(
'Keys start with rcms_ and are shown in full once, when you create them.',
@@ -696,14 +696,17 @@ describe('Errors table rows of the custom content types round', () => {
const order = [
'This action needs a real session.',
'missing_permission',
'You must verify your account to access this resource.',
'missing_permission',
].map((message) => text.indexOf(message))
expect(order.every((position) => position > -1)).toBe(true)
expect(order).toEqual([...order].sort((a, b) => a - b))
expect(forbidden.text()).toContain(
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
'missing_permission is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.',
)
expect(codes(forbidden)).toContain('missing_permission')
expect(forbidden.text()).not.toContain('Only owners and admins can do this.')
expect(forbidden.text()).not.toContain('Only the owner can do this.')
})
it('adds the new codes to the 404, 409 and 422 rows and explains code style', async () => {
@@ -1129,8 +1132,9 @@ describe('Authentication copy of the custom content types round', () => {
)
const auth = section(wrapper, 'auth').text()
expect(auth).toContain(
"Requests act as the user who created the key, with that user's role (owner or admin).",
"Requests act as the user who created the key, with that user's role as it is now.",
)
expect(auth).not.toContain('(owner or admin)')
expect(auth).toContain(
'Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.',
)
+62 -17
View File
@@ -8,6 +8,7 @@ import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor, customAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -942,25 +943,67 @@ function roleRequests(request: ReturnType<typeof vi.fn>) {
return request.mock.calls.filter(([path]) => String(path).startsWith('/v1/role'))
}
function sessionWith(access: Access): Session {
return { ...MARTA, access }
}
function roleCard() {
return [...document.body.querySelectorAll('h2')]
.find((heading) => heading.textContent?.trim() === 'Your role')!
.closest('section')!
}
function roleBadges() {
return [...roleCard().querySelectorAll('span[data-slot="base"]')]
.map((badge) => badge.textContent?.trim())
.filter((text) => text)
}
describe('Profile: your role', () => {
it('shows the role card with the badge, the assigner and the keys sentence', async () => {
it.each([
['a custom role', contributor(MARTA_ASSIGNER), ['Contributor', 'Custom']],
[
'the locked Owner',
sessionWith({
role: { id: 1, name: 'Owner', built_in: true, locked: true },
permissions: customAccess('content.read=all').permissions,
}),
['owner', 'Locked', 'Built-in'],
],
[
'a built-in Writer',
sessionWith({
role: { id: 3, name: 'Writer', built_in: true, locked: false },
permissions: customAccess('content.read=all').permissions,
}),
['writer', 'Built-in'],
],
])('shows the badge and the tags for %s in one row', async (_label, session, expected) => {
await mountApp({ session })
expect(roleBadges()).toEqual(expected)
expect(roleCard().textContent).toContain(KEYS_SENTENCE)
expect(buttonByText(/^See what I can do$/)).toBeDefined()
expect(roleCard().querySelector('[class*="i-lucide-list-checks"], svg')).not.toBeNull()
})
it('hides Locked when not locked and Custom when built in', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
expect(pageText()).toContain('Your role')
expect(pageText()).toContain('Contributor')
expect(pageText()).toContain('Assigned by Marta Alves')
expect(pageText()).toContain(KEYS_SENTENCE)
expect(buttonByText(/^See what I can do$/)).toBeDefined()
expect(roleCard().textContent).not.toContain('Locked')
expect(roleCard().textContent).not.toContain('Built-in')
})
it.each([
['an object', MARTA_ASSIGNER],
['null', null],
['an absent key', undefined],
])('renders no Assigned by text for %s', async (_label, assigner) => {
])('shows no summary line and no Assigned by text with %s', async (_label, assigner) => {
await mountApp({ session: contributor(assigner) })
expect(pageText()).toContain('Your role')
expect(pageText()).not.toContain('Assigned by')
expect(pageText()).not.toContain('Creates content and edits only their own.')
})
it('places the card after the Account card', async () => {
@@ -970,21 +1013,23 @@ describe('Profile: your role', () => {
expect(titles.indexOf('Your role')).toBe(titles.indexOf('Account') + 1)
})
it('follows a refreshed session for the assigner and the role', async () => {
it('follows a refreshed session for the badge, the tags and the summary', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
expect(roleBadges()).toEqual(['Contributor', 'Custom'])
useSessionStore().session = {
...contributor({ id: 2, name: 'Jonas Weber' }),
const writer: Access = {
role: { id: 3, name: 'Writer', built_in: true, locked: false },
permissions: customAccess('content.read=all').permissions,
}
await flushPromises()
expect(pageText()).toContain('Assigned by Jonas Weber')
expect(pageText()).not.toContain('Assigned by Marta Alves')
const writer = customAccess('content.read=all', 'Writer')
useSessionStore().session = { ...MARTA, access: writer }
await flushPromises()
expect(pageText()).toContain('Writer')
expect(pageText()).not.toContain('Assigned by')
expect(roleBadges()).toEqual(['writer', 'Built-in'])
buttonByText(/^See what I can do$/)!.click()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'A summary of your role, Writer. Read-only.',
)
})
it('opens the summary dialog from the session grants without reading roles, and Close returns focus', async () => {
+276 -7
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { setLocale } from '@/i18n'
import RoleEditorView from '../RoleEditorView.vue'
import { radiosOf } from '@/components/__tests__/roleKit'
import {
@@ -106,6 +107,7 @@ beforeEach(() => {
})
afterEach(async () => {
setLocale('en')
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
@@ -431,9 +433,6 @@ describe('Role editor: Errors on save', () => {
expect(buttonNamed('Review differences')).toBeDefined()
expect(checked('Events: Delete')).toBe('Own')
await press('Review differences')
expect(document.body.querySelector('[data-testid="role-differences"]')).not.toBeNull()
await press('Reload role')
expect(page()).not.toContain('This role changed while you were editing')
@@ -489,11 +488,82 @@ describe('Role editor: Errors on save', () => {
expect(page()).toContain('Ask another owner or admin.')
})
it('shows a generic toast for another failure', async () => {
await failWith(refuse(500, 'boom'))
function saveFailedAlert() {
return Array.from(document.body.querySelectorAll('[role="alert"]')).find((alert) =>
textOf(alert).includes("Couldn't save the role"),
)
}
expect(toastTitles()).toHaveLength(1)
expect(saveBar()).not.toBeNull()
it.each([
['500', refuse(500, 'boom')],
[
'a network failure',
() => {
throw new TypeError('Failed to fetch')
},
],
])(
'shows the save failed alert on %s, keeps the edits and Save on',
async (_failure, handler) => {
await failWith(handler)
expect(saveFailedAlert()).toBeDefined()
expect(textOf(saveFailedAlert())).toContain(
'Your changes are still here. Try again in a moment.',
)
expect(toastAdd).not.toHaveBeenCalled()
expect(checked('Events: Delete')).toBe('Own')
expect(saveBar()).not.toBeNull()
expect(buttonNamed('Save changes')!.disabled).toBe(false)
},
)
it('sends the same save again from Try again', async () => {
let calls = 0
const backend = await failWith(() => {
calls += 1
if (calls === 1) {
throw new ApiError(500)
}
return { ...CONTRIBUTOR, version: 3 }
})
const retry = Array.from(saveFailedAlert()!.querySelectorAll('button')).find(
(candidate) => textOf(candidate) === 'Try again',
)!
retry.click()
await flushPromises()
const puts = backend.callsTo('PUT', '/v1/role/4')
expect(puts).toHaveLength(2)
expect(puts[1]!.body).toEqual(puts[0]!.body)
expect(saveFailedAlert()).toBeUndefined()
})
it('shows the same alert when creating a role fails', async () => {
const backend = backendFor(null, { 'POST /v1/role/': refuse(500, 'boom') })
await open('/settings/roles/new', backend)
await type(nameInput(), 'Reviewer')
await press('Save changes')
expect(backend.callsTo('POST', '/v1/role/')).toHaveLength(1)
expect(saveFailedAlert()).toBeDefined()
expect(nameInput().value).toBe('Reviewer')
expect(buttonNamed('Save changes')!.disabled).toBe(false)
})
it('reads the alert in French', async () => {
await failWith(refuse(500, 'boom'))
setLocale('fr')
await flushPromises()
const alert = Array.from(document.body.querySelectorAll('[role="alert"]')).find(
(candidate) => textOf(candidate).includes("Impossible d'enregistrer le rôle"),
)!
expect(textOf(alert).replace(/\u2019/g, "'")).toContain(
'Vos modifications sont toujours là. Réessayez dans un instant.',
)
expect(textOf(alert)).toContain('Réessayer')
})
})
@@ -879,3 +949,202 @@ describe('Role editor: unknown reset failure', () => {
expect(page()).not.toContain('This role changed while you were editing')
})
})
describe('Role editor: stale save recovery', () => {
const SAVED_AFTER_JONAS = {
...CONTRIBUTOR,
version: 3,
updated_at: '2026-10-27T11:02:00Z',
updated_by: JONAS,
}
async function staleWithTwoEdits(extra: Record<string, Handler> = {}) {
let reads = 0
const backend = new Backend({
'GET /v1/permission/': () => ({ groups: CATALOGUE }),
'GET /v1/content-type': () => TYPES,
'GET /v1/role/4': () => (++reads === 1 ? CONTRIBUTOR : SAVED_AFTER_JONAS),
'PUT /v1/role/4': refuse(409, 'role_conflict'),
...extra,
})
await open('/settings/roles/4', backend)
await choose('Events: Update', 'All')
await choose('Events: Delete', 'Own')
await press('Save changes')
return backend
}
function dialog() {
return document.body.querySelector('[role="alertdialog"]')
}
function tableRows() {
return Array.from(dialog()!.querySelectorAll('[role="row"]')).map((row) =>
Array.from(row.querySelectorAll('[role="cell"], [role="columnheader"]')).map(textOf),
)
}
it('lists only the settings where the saved role and the edits disagree', async () => {
await staleWithTwoEdits()
await press('Review differences')
const text = textOf(dialog()!)
expect(text).toContain('How the saved role differs from your edits')
expect(text).toContain('Jonas Weber saved changes to Contributor at 11:02.')
expect(tableRows()).toEqual([
['Setting', 'Saved', 'Yours'],
['Events · Update', 'Own', 'All'],
['Events · Delete', 'None', 'Own'],
])
expect(buttonNamed('Use the saved role', dialog()!)).toBeDefined()
expect(buttonNamed('Keep my edits', dialog()!)).toBeDefined()
})
it('keeps the edits on top of the reloaded role, turns Save on and says so in an info toast', async () => {
const backend = await staleWithTwoEdits({
'PUT /v1/role/4': (() => {
let attempts = 0
return () => {
if (++attempts === 1) {
throw new ApiError(409, undefined, 'role_conflict', {
code: 'role_conflict' as never,
})
}
return { ...SAVED_AFTER_JONAS, version: 4 }
}
})(),
})
await press('Review differences')
await press('Keep my edits')
expect(dialog()).toBeNull()
expect(page()).not.toContain('This role changed while you were editing')
expect(checked('Events: Update')).toBe('All')
expect(checked('Events: Delete')).toBe('Own')
expect(buttonNamed('Save changes')!.disabled).toBe(false)
expect(page()).toContain('2 unsaved changes')
expect(toastTitles()).toContainEqual({
title: 'Role reloaded',
description: 'Your 2 changes are still here. Review them, then save.',
})
expect(toastAdd.mock.calls.at(-1)![0]).toMatchObject({ color: 'info', duration: 6000 })
await press('Save changes')
expect(backend.callsTo('PUT', '/v1/role/4').at(-1)!.body).toMatchObject({ version: 3 })
})
it('drops the edits with Use the saved role and shows no toast about edits', async () => {
await staleWithTwoEdits()
await press('Review differences')
await press('Use the saved role')
expect(dialog()).toBeNull()
expect(checked('Events: Update')).toBe('Own')
expect(checked('Events: Delete')).toBe('None')
expect(saveBar()).toBeNull()
expect(toastTitles().map((toast) => toast.title)).not.toContain('Role reloaded')
})
it('closes the dialog on Escape and keeps the banner and the edits', async () => {
await staleWithTwoEdits()
await press('Review differences')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(dialog()).toBeNull()
expect(page()).toContain('This role changed while you were editing')
expect(checked('Events: Delete')).toBe('Own')
expect(buttonNamed('Save changes')!.disabled).toBe(true)
})
it('brings the banner back with the newest author when the save conflicts again', async () => {
const backend = await staleWithTwoEdits()
await press('Review differences')
await press('Keep my edits')
await press('Save changes')
expect(backend.callsTo('PUT', '/v1/role/4')).toHaveLength(2)
expect(page()).toContain('This role changed while you were editing')
expect(checked('Events: Delete')).toBe('Own')
})
it('reads the dialog and the toast in French', async () => {
await staleWithTwoEdits()
setLocale('fr')
await flushPromises()
await press('Voir les différences')
const text = textOf(dialog()!)
expect(text).toContain('Comment le rôle enregistré diffère de vos modifications')
expect(tableRows()[0]).toEqual(['Réglage', 'Enregistré', 'Vous'])
expect(text).toContain('Utiliser le rôle enregistré')
await press('Garder mes modifications')
expect(toastTitles()).toContainEqual({
title: 'Rôle rechargé',
description: 'Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez.',
})
})
})
describe('Role editor: Read = Own note', () => {
const NOTE = 'Read: Own'
function junior(readScope: 'own' | 'all' = 'own') {
return role({
id: 6,
name: 'Junior writer',
grants: grants(`content.read=${readScope}, content.create`),
})
}
it('shows the info alert with the exact copy when a Read cell is Own', async () => {
await open('/settings/roles/6', backendFor(junior()))
expect(page()).toContain(NOTE)
expect(page()).toContain(
"Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule.",
)
})
it('is absent when no Read cell is Own', async () => {
await open('/settings/roles/6', backendFor(junior('all')))
expect(page()).not.toContain(NOTE)
})
it('appears and disappears live as the cell changes', async () => {
await open('/settings/roles/6', backendFor(junior('all')))
await choose('Events: Read', 'Own')
expect(page()).toContain(NOTE)
await choose('Events: Read', 'All')
expect(page()).not.toContain(NOTE)
})
it('renders the same way without a Save bar for a person who can only view roles', async () => {
await open('/settings/roles/6', backendFor(junior()), customAccess('roles.view'))
expect(page()).toContain(NOTE)
expect(page()).toContain('Can read online articles and their own drafts')
expect(page()).toContain('Can read only entries they created')
expect(saveBar()).toBeNull()
})
it('reads in French', async () => {
setLocale('fr')
await open('/settings/roles/6', backendFor(junior()))
expect(page()).toContain('Lecture : Les siennes')
expect(page()).toContain('Les compteurs de statut suivent la même règle.')
})
})
+115 -3
View File
@@ -6,6 +6,9 @@ import RolesListView from '../RolesListView.vue'
import { Backend, buttonNamed, grants, mountRoleView, role, textOf } from './roleKit'
import { customAccess, ownerAccess, adminAccess } from '@/test/access'
const toastAdd = vi.fn()
const toastRemove = vi.fn()
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
@@ -13,7 +16,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
})
const OWNER = role({
@@ -68,6 +71,8 @@ afterEach(async () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
toastAdd.mockReset()
toastRemove.mockReset()
})
function rows() {
@@ -339,7 +344,7 @@ describe('Roles list: Delete', () => {
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
})
it('refreshes the list when the role is already gone', async () => {
it('removes the row and toasts that the role no longer exists when it is already gone', async () => {
const intern = role({ id: 6, name: 'Intern', members: 0 })
let listed = 0
const backend = new Backend({
@@ -359,7 +364,114 @@ describe('Roles list: Delete', () => {
buttonNamed('Delete role')!.click()
await flushPromises()
expect(backend.callsTo('GET', '/v1/role/')).toHaveLength(2)
expect(rows().map((row) => textOf(row).split(' ')[0])).not.toContain('Intern')
const toast = toastAdd.mock.calls[0]![0]
expect(toast.color).toBe('info')
expect(toast.duration).toBe(6000)
expect(toast.title).toBe('That role no longer exists')
expect(toast.description).toBe(
'It was deleted somewhere else, so we removed it from the list.',
)
})
})
describe('Roles list: Role edited elsewhere (409)', () => {
const EDITED = role({
...CONTRIBUTOR,
version: 3,
updated_at: '2026-10-27T11:02:00Z',
updated_by: { id: 2, name: 'Jonas Weber' },
})
async function conflictOn(
method: 'DELETE' | 'POST',
path: string,
items: ReturnType<typeof role>[],
) {
let listed = 0
const backend = new Backend({
'GET /v1/role/': () => (++listed === 1 ? [OWNER, ADMIN, WRITER, CONTRIBUTOR] : items),
[`${method} ${path}`]: () => {
throw new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' })
},
})
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend,
})
return backend
}
it('warns with Reload when Move members meets a role edited meanwhile, and Reload refetches the list', async () => {
const backend = await conflictOn('POST', '/v1/role/4/reassign', [
OWNER,
ADMIN,
WRITER,
EDITED,
])
item(await openMenu('Contributor'), 'Delete').click()
await flushPromises()
document.body
.querySelector<HTMLElement>(
'[role="alertdialog"] [role="combobox"], [role="alertdialog"] button[aria-haspopup]',
)!
.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((option) => textOf(option).startsWith('Writer'))!
.click()
await flushPromises()
buttonNamed('Move 4 members and delete role')!.click()
await flushPromises()
const toast = toastAdd.mock.calls[0]![0]
expect(toast.color).toBe('warning')
expect(toast.duration).toBe(0)
expect(toast.title).toBe('Role changed elsewhere')
expect(toast.description).toBe(
'Jonas Weber edited Contributor, so nothing was saved. Reload and try again.',
)
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
const before = backend.callsTo('GET', '/v1/role/').length
toast.actions[0].onClick()
await flushPromises()
expect(backend.callsTo('GET', '/v1/role/').length).toBe(before + 1)
expect(toastRemove).toHaveBeenCalledWith(toast.id)
})
it('warns the same way when Delete meets a role edited meanwhile', async () => {
const intern = role({ id: 6, name: 'Intern', members: 0 })
let listed = 0
const backend = new Backend({
'GET /v1/role/': () =>
++listed === 1
? [OWNER, ADMIN, WRITER, intern]
: [
OWNER,
ADMIN,
WRITER,
{ ...intern, updated_by: { id: 2, name: 'Jonas Weber' } },
],
'DELETE /v1/role/6': () => {
throw new ApiError(409, undefined, 'role_conflict', { code: 'role_conflict' })
},
})
wrapper = await mountRoleView(RolesListView, '/settings/roles', {
access: ownerAccess(),
backend,
})
item(await openMenu('Intern'), 'Delete').click()
await flushPromises()
buttonNamed('Delete role')!.click()
await flushPromises()
const toast = toastAdd.mock.calls[0]![0]
expect(toast.title).toBe('Role changed elsewhere')
expect(toast.description).toContain('Jonas Weber edited Intern')
expect(rowOf('Intern')).toBeDefined()
})
})
@@ -12,7 +12,9 @@ import IntegrationView from '../IntegrationView.vue'
const ORIGIN = 'https://cms.renew.example'
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const NOTE =
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.'
'missing_permission is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.'
const RAW_NOTE =
'`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.'
const GROUP_DESCRIPTION =
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
@@ -187,8 +189,9 @@ describe('Search and sharing group', () => {
const wrapper = await mountIntegration()
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
expect(forbidden.messages).toHaveLength(3)
expect(forbidden.note).toBe(NOTE)
expect(forbidden.messages).toHaveLength(2)
expect(forbidden.codes).toEqual(['missing_permission'])
expect(forbidden.note).toBe(RAW_NOTE)
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
expect(notes.filter((item) => item === NOTE)).toHaveLength(2)
expect(ERROR_ROWS.filter((row) => row.note).map((row) => row.status)).toEqual([
@@ -213,7 +216,7 @@ describe('Search and sharing group', () => {
expect(group.text()).toContain('RenewCMS n’affiche pas de pages')
expect(group.text()).toContain('2 routes')
expect(wrapper.text()).toContain(
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire.',
'Les valeurs SEO par défaut d’un site demandent la permission de gérer les sites.',
)
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
})
+272
View File
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import UsersView from '../UsersView.vue'
import { Backend, buttonNamed, grants, mountRoleView, role, textOf, type Handler } from './roleKit'
import { customAccess, ownerAccess, roleRefFor } from '@/test/access'
import type { Access } from '@/lib/permissions'
import type { UserListItem } from '@/lib/users'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toastAdd = vi.fn()
const toastRemove = vi.fn()
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
})
function accepted(id: number, name: string, roleName: string): UserListItem {
return {
kind: 'user',
id,
username: name.toLowerCase().split(' ')[0]!,
display_name: name,
email: `${name.toLowerCase().replace(' ', '.')}@renew.example`,
role: roleRefFor(roleName),
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
}
}
const MARTA = accepted(1, 'Marta Alves', 'owner')
const JONAS = accepted(2, 'Jonas Weber', 'admin')
const INES = accepted(7, 'Ines Duarte', 'editor')
const INVITE: UserListItem = {
kind: 'invite',
id: 9,
email: 'pending@renew.example',
role: roleRefFor('editor'),
status: 'pending',
created_at: '2026-09-27T09:00:00Z',
}
const ROLES = [
role({ id: 1, name: 'Owner', built_in: true, locked: true }),
role({ id: 2, name: 'Admin', built_in: true }),
role({ id: 3, name: 'Writer', built_in: true }),
]
let wrapper: VueWrapper | null = null
async function open(extra: Record<string, Handler> = {}, access: Access = ownerAccess()) {
const backend = new Backend({
'GET /v1/user/': () => [INVITE, INES, JONAS, MARTA],
'GET /v1/role/': () => ROLES,
...extra,
})
wrapper = await mountRoleView(UsersView, '/users', { access, backend })
return backend
}
function rowOf(name: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="user-row"]')).find(
(row) => textOf(row).includes(name),
)!
}
async function openMenu(name: string) {
rowOf(name).querySelector<HTMLElement>('button[aria-label^="Actions for"]')!.click()
await flushPromises()
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}
function menuItem(items: HTMLElement[], label: string) {
return items.find((candidate) => textOf(candidate).startsWith(label))
}
async function chooseRemove(name: string) {
menuItem(await openMenu(name), 'Remove user')!.click()
await flushPromises()
}
async function confirmRemoval() {
buttonNamed('Remove user', document.body.querySelector('[role="alertdialog"]')!)!.click()
await flushPromises()
}
function refuse(status: number, code: string): Handler {
return () => {
throw new ApiError(status, undefined, code, { code: code as never })
}
}
function toastContent(index = 0) {
const argument = toastAdd.mock.calls[index]![0]
return typeof argument === 'function' ? argument() : argument
}
beforeEach(() => {
toastAdd.mockReset()
toastRemove.mockReset()
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
localStorage.clear()
initializeLocale()
await router.push('/login')
})
describe('Users: Remove user menu item', () => {
it('is enabled on an accepted member for a person with Remove users', async () => {
await open()
const item = menuItem(await openMenu('Ines Duarte'), 'Remove user')
expect(item).toBeDefined()
expect(item!.getAttribute('aria-disabled')).toBeNull()
})
it('is the only item for a person with users.view and users.remove only', async () => {
await open({}, customAccess('users.view, users.remove'))
const items = await openMenu('Ines Duarte')
expect(items.map((item) => textOf(item))).toEqual(['Remove user'])
})
it('is absent with users.view and users.change_role only', async () => {
await open({}, customAccess('users.view, users.change_role, roles.view'))
const items = await openMenu('Ines Duarte')
expect(menuItem(items, 'Remove user')).toBeUndefined()
})
it('is disabled on the owner row with the owner reason', async () => {
await open()
const item = menuItem(await openMenu('Marta Alves'), 'Remove user')!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(textOf(document.body)).toContain(
"The owner can't be changed or removed. Transfer ownership first.",
)
})
it('is disabled on the row marked you', async () => {
await open({}, customAccess('users.view, users.remove'))
const item = menuItem(await openMenu('Marta Alves'), 'Remove user')!
expect(item.getAttribute('aria-disabled')).toBe('true')
})
it('is disabled with the escalation reason when a custom role cannot cover the member role', async () => {
const lead = role({
id: 5,
name: 'Content lead',
grants: grants('content.read, content.update, content.publish'),
})
const covered = role({ id: 3, name: 'Writer', grants: grants('content.read') })
const leadUser: UserListItem = {
...accepted(8, 'Lea Martin', 'editor'),
role: { id: 5, name: 'Content lead', built_in: false, locked: false },
}
await open(
{
'GET /v1/user/': () => [MARTA, INES, leadUser],
'GET /v1/role/': () => [...ROLES, lead, { ...covered, built_in: false }],
},
customAccess(
'users.view, users.remove, roles.view, content.read, content.update=own',
'People manager',
),
)
const blocked = menuItem(await openMenu('Lea Martin'), 'Remove user')!
expect(blocked.getAttribute('aria-disabled')).toBe('true')
expect(textOf(document.body)).toContain(
"Your role can't assign a role with more power than yours.",
)
})
it('does not appear on a pending invite row', async () => {
await open()
const inviteRow = Array.from(
document.body.querySelectorAll('[data-testid="user-row"]'),
).find((row) => textOf(row).includes('pending@renew'))!
expect(inviteRow.querySelector('button[aria-label^="Actions for"]')).toBeNull()
expect(textOf(inviteRow)).not.toContain('Remove user')
})
})
describe('Users: removing a member', () => {
it('asks for confirmation, sends the delete with the role of the list item and drops the row', async () => {
const backend = await open({ 'DELETE /v1/user/7?expected_role_id=3': () => undefined })
await chooseRemove('Ines Duarte')
expect(textOf(document.body.querySelector('[role="alertdialog"]')!)).toContain(
'Remove Ines Duarte?',
)
await confirmRemoval()
expect(backend.callsTo('DELETE', '/v1/user/7?expected_role_id=3')).toHaveLength(1)
expect(textOf(document.body)).not.toContain('Ines Duarte')
expect(toastContent()).toMatchObject({ color: 'success', duration: 6000 })
})
it('shows the User not removed warning with Reload on 409 and Reload re-reads the list', async () => {
const backend = await open({
'DELETE /v1/user/7?expected_role_id=3': refuse(409, 'user_role_changed'),
})
await chooseRemove('Ines Duarte')
await confirmRemoval()
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
const toast = toastContent()
expect(toast).toMatchObject({
color: 'warning',
duration: 0,
title: 'User not removed',
description:
"Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again.",
})
expect(toast.actions[0].label).toBe('Reload')
const reads = backend.callsTo('GET', '/v1/user/').length
toast.actions[0].onClick()
await flushPromises()
expect(backend.callsTo('GET', '/v1/user/').length).toBe(reads + 1)
expect(toastRemove).toHaveBeenCalledWith(toast.id)
})
it('reads the warning in French', async () => {
setLocale('fr')
await open({ 'DELETE /v1/user/7?expected_role_id=3': refuse(409, 'user_role_changed') })
rowOf('Ines Duarte').querySelector<HTMLElement>('button[aria-label^="Actions"]')!.click()
await flushPromises()
menuItem(
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')),
'Retirer l’utilisateur',
)!.click()
await flushPromises()
buttonNamed(
'Retirer l’utilisateur',
document.body.querySelector('[role="alertdialog"]')!,
)!.click()
await flushPromises()
const toast = toastContent()
expect(toast.title).toBe('Utilisateur non retiré')
expect(toast.description).toBe(
"Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez.",
)
expect(toast.actions[0].label).toBe('Recharger')
})
})
+12 -11
View File
@@ -226,7 +226,7 @@ describe('What the Users page offers per grant', () => {
expect(buttonNamed('Invite user')).toBeInstanceOf(HTMLButtonElement)
await openMenu('Ines Duarte')
expect(menuLabels()).toEqual(['Change role'])
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
})
@@ -253,7 +253,7 @@ describe('Your own row and the owner row', () => {
await mountPage(adminAccess(), 2)
await openMenu('Jonas Weber')
expect(menuLabels()).toEqual(['Change role'])
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Change role')?.textContent).toContain(OWN_ROW_HINT)
})
@@ -262,7 +262,7 @@ describe('Your own row and the owner row', () => {
await mountPage(adminAccess(), 2)
await openMenu('Marta Alves')
expect(menuLabels()).toEqual(['Change role'])
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Change role')?.textContent).toContain(OWNER_HINT)
})
@@ -271,7 +271,7 @@ describe('Your own row and the owner row', () => {
await mountPage()
await openMenu('Marta Alves')
expect(menuLabels()).toEqual(['Change role', 'Transfer ownership'])
expect(menuLabels()).toEqual(['Change role', 'Transfer ownership', 'Remove user'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
})
@@ -286,7 +286,7 @@ describe('Your own row and the owner row', () => {
await mountPage(without(ownerAccess(), 'system.transfer_ownership'))
await openMenu('Marta Alves')
expect(menuLabels()).toEqual(['Change role'])
expect(menuLabels()).toEqual(['Change role', 'Remove user'])
})
})
@@ -308,7 +308,7 @@ describe('The mobile Users menu', () => {
expect(document.body.querySelector('[data-testid="user-card"]')).not.toBeNull()
await openMenu('Marta Alves')
expect(sheetLabels()).toEqual(['Change role'])
expect(sheetLabels()).toEqual(['Change role', 'Remove user'])
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(document.body.querySelector('[role="menu"]')?.textContent).toContain(OWNER_HINT)
})
@@ -317,7 +317,7 @@ describe('The mobile Users menu', () => {
await mountPage(ownerAccess(), 1, false)
await openMenu('Marta Alves')
expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership'])
expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership', 'Remove user'])
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(sheetItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
await click(sheetItem('Transfer ownership'))
@@ -328,10 +328,11 @@ describe('The mobile Users menu', () => {
await mountPage(ownerAccess(), 1, false)
await openMenu('Ines Duarte')
expect(sheetLabels()).toEqual(['Change role'])
expect(sheetLabels()).toEqual(['Change role', 'Remove user'])
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
const before = calls(`GET ${ROLE_PATH}`).length
await click(sheetItem('Change role'))
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(1)
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(before + 1)
})
})
@@ -383,7 +384,7 @@ describe('Changing the role', () => {
expect(dialog()).toBeNull()
})
it('re-reads the list and closes on 409 user_role_changed', async () => {
it('re-reads the list and keeps the dialog open with the alert on 409 user_role_changed', async () => {
answers['PUT /v1/user/4/role'] = () =>
Promise.reject(
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
@@ -399,7 +400,7 @@ describe('Changing the role', () => {
)
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
expect(dialog()).toBeNull()
expect(dialog()?.textContent).toContain('Role changed in the meantime')
})
})
+7 -5
View File
@@ -29,8 +29,9 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const LIST_PATH = '/v1/user/'
const INVITE_PATH = '/v1/user/invite'
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
const FAILED_ALERT = "Couldn't send the invite. Try again in a moment."
const INVALID_EMAIL = 'Enter a valid email address.'
const FAILED_TITLE = "Couldn't send the invite"
const FAILED_DESCRIPTION = 'Try again in a moment.'
const VALID_EMAIL = 'lea.martin@renew.example'
function defaultFixture(): UserListItem[] {
@@ -726,7 +727,8 @@ describe('Invite: 409 conflicts are told apart by the error code', () => {
await openModal()
await submitWith('jonas@renew.example')
expect(alertText()).toContain(FAILED_ALERT)
expect(alertText()).toContain(FAILED_TITLE)
expect(alertText()).toContain(FAILED_DESCRIPTION)
expect(dialog()?.textContent).not.toContain('This address already has an account.')
expect(emailInput().disabled).toBe(false)
})
@@ -783,7 +785,7 @@ describe('Invite: failed — a 500 or a network failure keeps the address and of
},
{
name: 'a 500 with the API text',
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_ALERT)),
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_TITLE)),
},
{
name: 'a network failure',
@@ -794,7 +796,7 @@ describe('Invite: failed — a 500 or a network failure keeps the address and of
await openModal()
await submitWith(VALID_EMAIL)
expect(alertText()).toBe(FAILED_ALERT)
expect(alertText()).toBe(`${FAILED_TITLE}${FAILED_DESCRIPTION}`)
expect(document.body.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(emailInput().value).toBe(VALID_EMAIL)
expect(emailInput().disabled).toBe(false)