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
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { ACTIVITY_CATEGORIES, categoryIcon, categoryLabel } from '@/lib/activity'
|
||||
|
||||
describe('activity categories', () => {
|
||||
@@ -9,3 +10,49 @@ describe('activity categories', () => {
|
||||
expect(categoryIcon('roles')).toMatch(/^i-lucide-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity category names', () => {
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
it('lists the categories in the design order', () => {
|
||||
expect([...ACTIVITY_CATEGORIES]).toEqual([
|
||||
'articles',
|
||||
'media',
|
||||
'users',
|
||||
'roles',
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in English', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Roles',
|
||||
'API keys',
|
||||
'Authentication',
|
||||
'System',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Médias',
|
||||
'Utilisateurs et invitations',
|
||||
'Rôles',
|
||||
'Clés d’API',
|
||||
'Authentification',
|
||||
'Système',
|
||||
])
|
||||
})
|
||||
|
||||
it('never writes "Users & invites"', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel).join()).not.toContain('&')
|
||||
})
|
||||
})
|
||||
@@ -291,6 +291,7 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'role.reassign',
|
||||
'user.change_role',
|
||||
'user.transfer_ownership',
|
||||
'user.remove',
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
@@ -380,3 +381,188 @@ describe('role and ownership sentences', () => {
|
||||
expect(sentenceText(item({ action: 'role.unknown' }))).toBe('role.unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the six role events in EN and FR', () => {
|
||||
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 event(overrides: Partial<ActivityItem>) {
|
||||
return item({ category: 'roles', target: roleTarget('Intern'), ...overrides })
|
||||
}
|
||||
|
||||
function both(source: ActivityItem) {
|
||||
setLocale('en')
|
||||
const english = sentenceText(source)
|
||||
setLocale('fr')
|
||||
const french = sentenceText(source)
|
||||
return [english, french]
|
||||
}
|
||||
|
||||
it.each([
|
||||
['role.create', 'Contributor', 'Role Contributor created', 'Rôle Contributor créé'],
|
||||
['role.edit', 'Contributor', 'Role Contributor edited', 'Rôle Contributor modifié'],
|
||||
['role.delete', 'Intern', 'Role Intern deleted', 'Rôle Intern supprimé'],
|
||||
])('success %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, target: roleTarget(name) }))).toEqual([english, french])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[3, 'Moved 3 members from Intern to Writer', '3 membres déplacés de Intern vers Writer'],
|
||||
[1, 'Moved 1 member from Intern to Writer', '1 membre déplacé de Intern vers Writer'],
|
||||
])('success role.reassign with moved %s', (moved, english, french) => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
params: { from: 4, to: 3, moved, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([english, french])
|
||||
setLocale('en')
|
||||
expect(composeSentence(source).find((segment) => segment.strong)?.text).toBe('Intern')
|
||||
})
|
||||
|
||||
it('success user.change_role', () => {
|
||||
const source = event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
target: userTarget('Ines Duarte'),
|
||||
changes: [roleChange],
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
"Ines Duarte's role changed from Writer to Content lead",
|
||||
"Le rôle d'Ines Duarte est passé de Writer à Content lead",
|
||||
])
|
||||
})
|
||||
|
||||
it('success user.transfer_ownership', () => {
|
||||
const source = event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
target: userTarget('Jonas Weber'),
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Ownership transferred from Marta Alves to Jonas Weber',
|
||||
'Propriété transférée de Marta Alves à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'role.create',
|
||||
'Contributor',
|
||||
'Failed to create the role Contributor',
|
||||
'Échec de la création du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.edit',
|
||||
'Contributor',
|
||||
'Failed to edit the role Contributor',
|
||||
'Échec de la modification du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.delete',
|
||||
'Intern',
|
||||
'Failed to delete the role Intern',
|
||||
'Échec de la suppression du rôle Intern',
|
||||
],
|
||||
])('failed %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, outcome: 'failed', target: roleTarget(name) }))).toEqual([
|
||||
english,
|
||||
french,
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign with the count and the destination', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 3, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Failed to move 3 members from Intern to Writer',
|
||||
'Échec du déplacement de 3 membres de Intern vers Writer',
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign for one member', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 1, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)[0]).toBe('Failed to move 1 member from Intern to Writer')
|
||||
})
|
||||
|
||||
it('failed user.change_role and user.transfer_ownership', () => {
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Ines Duarte'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to change the role of Ines Duarte',
|
||||
"Échec du changement de rôle d'Ines Duarte",
|
||||
])
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Jonas Weber'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to transfer ownership to Jonas Weber',
|
||||
'Échec du transfert de la propriété à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it('writes user.remove with the success and the failed sentence', () => {
|
||||
const target = userTarget('Ines Duarte')
|
||||
const removed = event({ action: 'user.remove', category: 'users', target })
|
||||
|
||||
expect(both(removed)).toEqual(['Removed Ines Duarte', 'Ines Duarte retiré'])
|
||||
expect(both({ ...removed, outcome: 'failed' })).toEqual([
|
||||
'Failed to remove Ines Duarte',
|
||||
"Échec du retrait d'Ines Duarte",
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', { moved: 3 }, 'Moved 3 members from Intern to another role'],
|
||||
['success', { moved: 1 }, 'Moved 1 member from Intern to another role'],
|
||||
['success', { to_name: 'Writer' }, 'Moved the members of the role Intern'],
|
||||
['failed', {}, 'Failed to move the members of the role Intern'],
|
||||
] as const)('role.reassign %s without all params: %j', (outcome, params, sentence) => {
|
||||
const text = sentenceText(event({ action: 'role.reassign', outcome, params }))
|
||||
|
||||
expect(text).toBe(sentence)
|
||||
expect(text).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('ignores an empty destination name', () => {
|
||||
const text = sentenceText(
|
||||
event({ action: 'role.reassign', params: { moved: 2, to_name: ' ' } }),
|
||||
)
|
||||
|
||||
expect(text).toBe('Moved 2 members from Intern to another role')
|
||||
})
|
||||
|
||||
it('renders an unknown action as its name', () => {
|
||||
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
|
||||
})
|
||||
})
|
||||
@@ -251,6 +251,45 @@ describe('custom content types', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles sync: permission-gated routes', () => {
|
||||
const GATED = [
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/builtin-type/{key}',
|
||||
'GET /v1/media/purge-settings',
|
||||
'PATCH /v1/media/purge-settings',
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
]
|
||||
|
||||
it('matches "permission" on the nine routes through the access chip and the description', () => {
|
||||
const found = routes(search('permission').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).toContain(route)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches "missing_permission" on the error code of the gated routes', () => {
|
||||
const found = search('missing_permission').endpoints
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(routes(found)).toContain(route)
|
||||
}
|
||||
expect(found.find((hit) => routes([hit])[0] === GATED[0])!.via!.kind).toBe('error')
|
||||
})
|
||||
|
||||
it('no longer matches "owner or admin" on the nine routes', () => {
|
||||
const found = routes(search('owner or admin').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).not.toContain(route)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('lists the five suggested rows in order', () => {
|
||||
expect(routes(suggestedHits(index))).toEqual([
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('elideDe', () => {
|
||||
it('elides before a vowel in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Ines Duarte a changé', 'Ines Duarte')).toBe(
|
||||
"Le rôle d'Ines Duarte a changé",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps de before a consonant', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Jonas Weber a changé', 'Jonas Weber')).toBe(
|
||||
'Le rôle de Jonas Weber a changé',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves English untouched', () => {
|
||||
expect(elideDe("Ines Duarte's role", 'Ines Duarte')).toBe("Ines Duarte's role")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entryNounOf,
|
||||
inverseNoun,
|
||||
inverseSide,
|
||||
kindFromInverseSide,
|
||||
@@ -87,3 +88,14 @@ describe('the preview', () => {
|
||||
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the entry noun of a type name', () => {
|
||||
it.each([
|
||||
['Events', 'an event', 'events'],
|
||||
['Recipes', 'a recipe', 'recipes'],
|
||||
['Press mentions', 'a press mention', 'press mentions'],
|
||||
['Categories', 'a category', 'categories'],
|
||||
])('turns %s into %s', (name, singular, plural) => {
|
||||
expect(entryNounOf(name)).toEqual({ singular, plural })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { roleDiffRows } from '@/lib/roleDiff'
|
||||
import type { RoleForm } from '@/lib/roleForm'
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'article', name: 'Articles' },
|
||||
{ key: 'events', name: 'Events' },
|
||||
]
|
||||
|
||||
const CONTEXT = {
|
||||
types: TYPES,
|
||||
toggles: new Set(['activity.export']),
|
||||
order: ['activity.view', 'activity.export'],
|
||||
}
|
||||
|
||||
function form(grants: RoleForm['grants'], name = 'Contributor', description = ''): RoleForm {
|
||||
return { name, description, grants }
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('roleDiffRows', () => {
|
||||
it('lists only the settings where the saved role and the edits disagree', () => {
|
||||
const saved = form({ 'content.read': 'all', 'content.update:events': 'own' })
|
||||
const yours = form({
|
||||
'content.read': 'all',
|
||||
'content.update:events': 'all',
|
||||
'content.delete:events': 'own',
|
||||
})
|
||||
|
||||
expect(
|
||||
roleDiffRows(saved, yours, CONTEXT).map(({ setting, saved, yours }) => [
|
||||
setting,
|
||||
saved,
|
||||
yours,
|
||||
]),
|
||||
).toEqual([
|
||||
['Events · Update', 'Own', 'All'],
|
||||
['Events · Delete', 'None', 'Own'],
|
||||
])
|
||||
})
|
||||
|
||||
it('shows nothing when an override equals the inherited value', () => {
|
||||
const saved = form({ 'content.read': 'all' })
|
||||
const yours = form({ 'content.read': 'all', 'content.read:events': 'all' })
|
||||
|
||||
expect(roleDiffRows(saved, yours, CONTEXT)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the Create switch as On and Off', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.create:events': 'all' }), CONTEXT)
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ id: 'content.create:events', setting: 'Events · Create', saved: 'Off', yours: 'On' },
|
||||
])
|
||||
})
|
||||
|
||||
it('names general permissions and keeps the catalogue order', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({ 'activity.export': 'all' }),
|
||||
form({ 'activity.view': 'own', 'activity.export': 'none' }),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['See activity', 'None', 'Own'],
|
||||
['Export activity', 'On', 'Off'],
|
||||
])
|
||||
})
|
||||
|
||||
it('lists a changed name or description first', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({}, 'Contributor', 'Old'),
|
||||
form({}, 'Writer', 'New'),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['Name', 'Contributor', 'Writer'],
|
||||
['Description', 'Old', 'New'],
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to one row per action when there are no types', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.read': 'all' }), {
|
||||
...CONTEXT,
|
||||
types: [],
|
||||
})
|
||||
|
||||
expect(rows.map((row) => row.setting)).toEqual(['All content types · Read'])
|
||||
})
|
||||
|
||||
it('translates the scopes', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const rows = roleDiffRows(form({}), form({ 'content.update:events': 'own' }), CONTEXT)
|
||||
|
||||
expect(rows[0]).toMatchObject({ saved: 'Aucune', yours: 'Les siennes' })
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formFromRole,
|
||||
grantsToPayload,
|
||||
isCustomRow,
|
||||
reapplyEdits,
|
||||
resetRow,
|
||||
scopeAt,
|
||||
setScope,
|
||||
@@ -191,3 +192,46 @@ describe('differences', () => {
|
||||
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reapplyEdits', () => {
|
||||
const initial = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all' } as const,
|
||||
}
|
||||
|
||||
it('puts the edited cells back on top of the new saved role', () => {
|
||||
const base = {
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own' } as const,
|
||||
}
|
||||
const edited = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all', 'content.delete:events': 'own' } as const,
|
||||
}
|
||||
|
||||
const merged = reapplyEdits(base, initial, edited)
|
||||
|
||||
expect(merged).toEqual({
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own', 'content.delete:events': 'own' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an edited name and removes a cell the person reset', () => {
|
||||
const withOverride = {
|
||||
...initial,
|
||||
grants: { ...initial.grants, 'content.update:events': 'own' } as const,
|
||||
}
|
||||
const base = { name: 'Contributor', description: '', grants: { ...withOverride.grants } }
|
||||
const edited = { ...initial, name: 'Writer', grants: { 'content.read': 'all' } as const }
|
||||
|
||||
const merged = reapplyEdits(base, withOverride, edited)
|
||||
|
||||
expect(merged.name).toBe('Writer')
|
||||
expect(merged.grants).toEqual({ 'content.read': 'all' })
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe('buildRolePreview content', () => {
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => line.label)).toEqual([
|
||||
'Articles, Case studies',
|
||||
'Articles and Case studies',
|
||||
'Events',
|
||||
])
|
||||
expect(preview.content[1]!.sentence).toContain('edit any entry')
|
||||
@@ -132,3 +132,67 @@ describe('buildRolePreview Changed tagging and members', () => {
|
||||
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRolePreview Read = Own', () => {
|
||||
const readOwn = {
|
||||
'content.read': 'own',
|
||||
'content.create': 'all',
|
||||
'content.update': 'own',
|
||||
} as const
|
||||
|
||||
it('writes one line per kind of type when Read is Own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => [line.label, line.sentence])).toEqual([
|
||||
[
|
||||
'Articles',
|
||||
"Can read online articles and their own drafts, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
[
|
||||
'Events and Case studies',
|
||||
"Can read only entries they created, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the custom type sentence for a custom type override', () => {
|
||||
const grants = setScope({ 'content.read': 'all' }, 'content.read', 'own', 'events')
|
||||
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
const events = preview.content.find((line) => line.label === 'Events')
|
||||
expect(events?.sentence).toContain('read only entries they created')
|
||||
})
|
||||
|
||||
it('uses the articles sentence alone when only Articles exists', () => {
|
||||
const preview = buildRolePreview({
|
||||
grants: { ...readOwn },
|
||||
types: [{ key: 'article', name: 'Articles' }],
|
||||
})
|
||||
|
||||
expect(preview.content).toHaveLength(1)
|
||||
expect(preview.content[0]).toMatchObject({ label: 'All content types' })
|
||||
expect(preview.content[0]!.sentence).toContain('read online articles and their own drafts')
|
||||
})
|
||||
|
||||
it('never says read only their own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(JSON.stringify(preview)).not.toContain('read only their own')
|
||||
})
|
||||
|
||||
it('keeps the Read All wording', () => {
|
||||
const preview = buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('Can read any entry')
|
||||
})
|
||||
|
||||
it('translates both sentences', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('lire les articles en ligne')
|
||||
expect(preview.content[1]!.sentence).toContain('créées par la personne')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { userMenuItems, type UserMenuContext } from '@/lib/userMenu'
|
||||
import type { AcceptedUser } from '@/lib/users'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
|
||||
function accepted(id: number, role: string): AcceptedUser {
|
||||
return {
|
||||
kind: 'user',
|
||||
id,
|
||||
username: `user${id}`,
|
||||
display_name: `User ${id}`,
|
||||
email: `user${id}@renew.example`,
|
||||
role: roleRefFor(role),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<UserMenuContext> = {}): UserMenuContext {
|
||||
return {
|
||||
sessionUserId: 1,
|
||||
canChangeRole: true,
|
||||
canTransfer: false,
|
||||
canViewRoles: true,
|
||||
canRemove: true,
|
||||
canCover: () => true,
|
||||
changeRole: vi.fn(),
|
||||
transfer: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
function remove(user: AcceptedUser, overrides: Partial<UserMenuContext> = {}) {
|
||||
return userMenuItems(user, context(overrides)).find((item) => item.key === 'remove-user')
|
||||
}
|
||||
|
||||
describe('Remove user in the row menu', () => {
|
||||
it('is enabled below Change role for an accepted user the person can cover', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context())
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['change-role', 'remove-user'])
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'Remove user',
|
||||
icon: 'i-lucide-user-x',
|
||||
destructive: true,
|
||||
unavailable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('is the only item for a person who can remove but not change roles', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context({ canChangeRole: false }))
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['remove-user'])
|
||||
})
|
||||
|
||||
it('is absent without the permission', () => {
|
||||
expect(remove(accepted(2, 'editor'), { canRemove: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is disabled on the owner row with the owner reason', () => {
|
||||
expect(remove(accepted(3, 'owner'))).toMatchObject({
|
||||
unavailable: true,
|
||||
hint: "The owner can't be changed or removed. Transfer ownership first.",
|
||||
})
|
||||
})
|
||||
|
||||
it('is disabled on the row marked you', () => {
|
||||
const item = remove(accepted(1, 'admin'))
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("You can't remove yourself. Ask another owner or admin.")
|
||||
})
|
||||
|
||||
it('is disabled with the escalation reason when the role cannot be covered', () => {
|
||||
const item = remove(accepted(2, 'admin'), { canCover: () => false })
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("Your role can't assign a role with more power than yours.")
|
||||
})
|
||||
|
||||
it('selects through the context', () => {
|
||||
const removeUser = vi.fn()
|
||||
const user = accepted(2, 'editor')
|
||||
|
||||
remove(user, { removeUser })!.onSelect(null)
|
||||
|
||||
expect(removeUser).toHaveBeenCalledWith(user, null)
|
||||
})
|
||||
|
||||
it('is absent for a user without a role', () => {
|
||||
const user = { ...accepted(2, 'editor'), role: null }
|
||||
|
||||
expect(remove(user)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(remove(accepted(2, 'editor'))?.label).toBe('Retirer l’utilisateur')
|
||||
expect(remove(accepted(1, 'admin'))?.hint).toBe(
|
||||
'Vous ne pouvez pas vous retirer vous-même. Demandez à un autre propriétaire ou administrateur.',
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user