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:
1 parent
5d6f378fce
commit
b39a41be88
124 files changed
+8816
-523
No files matched your search
@@ -218,6 +218,7 @@ onMounted(loadKeys)
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:mobile-title="t('API keys')"
|
||||
back-to="/integration"
|
||||
sticky-footer
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user