1058 lines
27 KiB
TypeScript
1058 lines
27 KiB
TypeScript
import type { Page } from 'playwright'
|
|
import { accessFor, customAccess } from '../src/test/access'
|
|
import type { ApiMock, VisualState } from './states'
|
|
|
|
type Kind = 'b' | 's' | 'o'
|
|
|
|
interface BoardItem {
|
|
key: string
|
|
kind: Kind
|
|
}
|
|
|
|
const boardGroups: { id: string; items: BoardItem[] }[] = [
|
|
{
|
|
id: 'schema',
|
|
items: [
|
|
{ key: 'schema.view', kind: 'b' },
|
|
{ key: 'schema.edit', kind: 'b' },
|
|
{ key: 'schema.delete', kind: 'b' },
|
|
],
|
|
},
|
|
{
|
|
id: 'media',
|
|
items: [
|
|
{ key: 'media.view', kind: 'b' },
|
|
{ key: 'media.upload', kind: 'b' },
|
|
{ key: 'media.delete', kind: 's' },
|
|
],
|
|
},
|
|
{
|
|
id: 'authors',
|
|
items: [
|
|
{ key: 'authors.view', kind: 'b' },
|
|
{ key: 'authors.create', kind: 'b' },
|
|
{ key: 'authors.edit', kind: 's' },
|
|
{ key: 'authors.delete', kind: 'b' },
|
|
],
|
|
},
|
|
{
|
|
id: 'activity',
|
|
items: [
|
|
{ key: 'activity.view', kind: 's' },
|
|
{ key: 'activity.export', kind: 'b' },
|
|
],
|
|
},
|
|
{
|
|
id: 'users',
|
|
items: [
|
|
{ key: 'users.view', kind: 'b' },
|
|
{ key: 'users.invite', kind: 'b' },
|
|
{ key: 'users.change_role', kind: 'b' },
|
|
{ key: 'users.remove', kind: 'b' },
|
|
],
|
|
},
|
|
{
|
|
id: 'settings',
|
|
items: [
|
|
{ key: 'settings.view', kind: 'b' },
|
|
{ key: 'settings.edit_general', kind: 'b' },
|
|
{ key: 'settings.manage_sites', kind: 'b' },
|
|
{ key: 'apikeys.view', kind: 's' },
|
|
{ key: 'apikeys.create', kind: 'b' },
|
|
{ key: 'apikeys.revoke', kind: 'b' },
|
|
{ key: 'settings.delete_site', kind: 'o' },
|
|
],
|
|
},
|
|
{
|
|
id: 'roles',
|
|
items: [
|
|
{ key: 'roles.view', kind: 'b' },
|
|
{ key: 'roles.manage', kind: 'b' },
|
|
{ key: 'roles.edit_owner_only', kind: 'o' },
|
|
],
|
|
},
|
|
{
|
|
id: 'system',
|
|
items: [
|
|
{ key: 'system.apply_updates', kind: 'o' },
|
|
{ key: 'system.edit_retention', kind: 'o' },
|
|
{ key: 'system.transfer_ownership', kind: 'o' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const contentActions = ['read', 'create', 'update', 'delete', 'publish']
|
|
|
|
const permissionCatalogue = [
|
|
{
|
|
key: 'content',
|
|
items: contentActions.map((action) => ({
|
|
key: `content.${action}`,
|
|
scope_kind: action === 'create' ? 'toggle' : 'scoped',
|
|
owner_only: false,
|
|
per_type: true,
|
|
})),
|
|
},
|
|
...boardGroups.map((group) => ({
|
|
key: group.id,
|
|
items: group.items.map((item) => ({
|
|
key: item.key,
|
|
scope_kind: item.kind === 's' ? 'scoped' : 'toggle',
|
|
owner_only: item.kind === 'o',
|
|
per_type: false,
|
|
})),
|
|
})),
|
|
]
|
|
|
|
const scopeOfChar: Record<string, 'none' | 'own' | 'all'> = {
|
|
n: 'none',
|
|
o: 'own',
|
|
a: 'all',
|
|
'0': 'none',
|
|
'1': 'all',
|
|
}
|
|
|
|
type Grant = { key: string; scope: 'none' | 'own' | 'all' }
|
|
|
|
function contentGrants(chars: string, typeKey?: string): Grant[] {
|
|
return contentActions.flatMap((action, index) => {
|
|
const scope = scopeOfChar[chars[index]!]!
|
|
const key = typeKey === undefined ? `content.${action}` : `content.${action}:${typeKey}`
|
|
return typeKey !== undefined || scope !== 'none' ? [{ key, scope }] : []
|
|
})
|
|
}
|
|
|
|
function generalGrants(general: Record<string, string>): Grant[] {
|
|
return boardGroups.flatMap((group) =>
|
|
group.items.flatMap((item, index) => {
|
|
const scope = scopeOfChar[general[group.id]![index]!]!
|
|
return scope === 'none' ? [] : [{ key: item.key, scope }]
|
|
}),
|
|
)
|
|
}
|
|
|
|
interface BoardRole {
|
|
id: number
|
|
name: string
|
|
description: string
|
|
built_in: boolean
|
|
locked: boolean
|
|
members: number
|
|
general: Record<string, string>
|
|
content: string
|
|
typed?: Record<string, string>
|
|
}
|
|
|
|
const ownerGeneral = {
|
|
schema: '111',
|
|
media: '11a',
|
|
authors: '11a1',
|
|
activity: 'a1',
|
|
users: '1111',
|
|
settings: '111a111',
|
|
roles: '111',
|
|
system: '111',
|
|
}
|
|
|
|
const adminGeneral = { ...ownerGeneral, settings: '111a110', roles: '110', system: '000' }
|
|
|
|
const boardRoles: Record<string, BoardRole> = {
|
|
owner: {
|
|
id: 1,
|
|
name: 'Owner',
|
|
description: 'Everything on the site. One per site.',
|
|
built_in: true,
|
|
locked: true,
|
|
members: 1,
|
|
general: ownerGeneral,
|
|
content: 'a1aaa',
|
|
},
|
|
admin: {
|
|
id: 2,
|
|
name: 'Admin',
|
|
description: 'Everything except owner-only settings.',
|
|
built_in: true,
|
|
locked: false,
|
|
members: 2,
|
|
general: adminGeneral,
|
|
content: 'a1aaa',
|
|
},
|
|
writer: {
|
|
id: 3,
|
|
name: 'Writer',
|
|
description: "Reads everything and writes content. Can't delete or publish.",
|
|
built_in: true,
|
|
locked: false,
|
|
members: 5,
|
|
general: {
|
|
schema: '100',
|
|
media: '11n',
|
|
authors: '10o0',
|
|
activity: 'o0',
|
|
users: '0000',
|
|
settings: '000n000',
|
|
roles: '000',
|
|
system: '000',
|
|
},
|
|
content: 'a1ann',
|
|
},
|
|
contributor: {
|
|
id: 4,
|
|
name: 'Contributor',
|
|
description: 'Creates content and edits only their own.',
|
|
built_in: false,
|
|
locked: false,
|
|
members: 4,
|
|
general: {
|
|
schema: '100',
|
|
media: '11n',
|
|
authors: '10o0',
|
|
activity: 'n0',
|
|
users: '0000',
|
|
settings: '000n000',
|
|
roles: '000',
|
|
system: '000',
|
|
},
|
|
content: 'a1onn',
|
|
typed: { 'case-studies': 'a0nnn' },
|
|
},
|
|
lead: {
|
|
id: 5,
|
|
name: 'Content lead',
|
|
description: 'Edits and publishes all content.',
|
|
built_in: false,
|
|
locked: false,
|
|
members: 1,
|
|
general: {
|
|
schema: '100',
|
|
media: '11a',
|
|
authors: '10a0',
|
|
activity: 'a1',
|
|
users: '1000',
|
|
settings: '100n000',
|
|
roles: '100',
|
|
system: '000',
|
|
},
|
|
content: 'a1aaa',
|
|
},
|
|
junior: {
|
|
id: 6,
|
|
name: 'Junior writer',
|
|
description: 'Writes articles and reads online ones and their own drafts.',
|
|
built_in: false,
|
|
locked: false,
|
|
members: 2,
|
|
general: {
|
|
schema: '100',
|
|
media: '11n',
|
|
authors: '10o0',
|
|
activity: 'n0',
|
|
users: '0000',
|
|
settings: '000n000',
|
|
roles: '000',
|
|
system: '000',
|
|
},
|
|
content: 'o1onn',
|
|
},
|
|
intern: {
|
|
id: 7,
|
|
name: 'Intern',
|
|
description: 'Reads content.',
|
|
built_in: false,
|
|
locked: false,
|
|
members: 0,
|
|
general: {
|
|
schema: '000',
|
|
media: '00n',
|
|
authors: '00n0',
|
|
activity: 'n0',
|
|
users: '0000',
|
|
settings: '000n000',
|
|
roles: '000',
|
|
system: '000',
|
|
},
|
|
content: 'a0nnn',
|
|
},
|
|
}
|
|
|
|
function roleItem(
|
|
key: string,
|
|
overrides: Record<string, unknown> = {},
|
|
version = 2,
|
|
): Record<string, unknown> {
|
|
const role = boardRoles[key]!
|
|
return {
|
|
id: role.id,
|
|
name: role.name,
|
|
description: role.description,
|
|
built_in: role.built_in,
|
|
locked: role.locked,
|
|
version,
|
|
members: role.members,
|
|
grants: [
|
|
...contentGrants(role.content),
|
|
...Object.entries(role.typed ?? {}).flatMap(([typeKey, chars]) =>
|
|
contentGrants(chars, typeKey),
|
|
),
|
|
...generalGrants(role.general),
|
|
],
|
|
updated_at: '2026-10-14T10:52:00Z',
|
|
updated_by: { id: 1, name: 'Marta Alves' },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const JONAS = { id: 2, name: 'Jonas Weber' }
|
|
const STALE_SAVED_AT = '2026-10-27T11:02:00Z'
|
|
|
|
function roleRef(key: string) {
|
|
const role = boardRoles[key]!
|
|
return { id: role.id, name: role.name, built_in: role.built_in, locked: role.locked }
|
|
}
|
|
|
|
function session(
|
|
displayName: string,
|
|
access: ReturnType<typeof accessFor>,
|
|
id = 1,
|
|
email = 'marta@renewbuild.example',
|
|
): ApiMock {
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
id,
|
|
is_verified: true,
|
|
username: displayName.split(' ')[0]!.toLowerCase(),
|
|
email,
|
|
display_name: displayName,
|
|
access,
|
|
},
|
|
}
|
|
}
|
|
|
|
const martaSession = session('Marta Alves', accessFor('owner'))
|
|
const jonasSession = session('Jonas Weber', accessFor('admin'), 2, 'jonas@renewbuild.example')
|
|
|
|
const contentTypeList: ApiMock = {
|
|
status: 200,
|
|
body: [
|
|
{
|
|
id: 1,
|
|
key: 'events',
|
|
name: 'Events',
|
|
icon: 'files',
|
|
fields: [],
|
|
created_at: '2026-09-02T09:00:00Z',
|
|
updated_at: '2026-09-02T09:00:00Z',
|
|
},
|
|
{
|
|
id: 2,
|
|
key: 'case-studies',
|
|
name: 'Case studies',
|
|
icon: 'files',
|
|
fields: [],
|
|
created_at: '2026-09-03T09:00:00Z',
|
|
updated_at: '2026-09-03T09:00:00Z',
|
|
},
|
|
],
|
|
}
|
|
|
|
const rolesList = (...keys: string[]): ApiMock => ({
|
|
status: 200,
|
|
body: keys.map((key) => roleItem(key)),
|
|
})
|
|
|
|
function boardUser(id: number, name: string, role: string, createdAt: string) {
|
|
const [first] = name.split(' ')
|
|
return {
|
|
kind: 'user',
|
|
id,
|
|
username: first!.toLowerCase(),
|
|
display_name: name,
|
|
email: `${first!.toLowerCase()}@renewbuild.example`,
|
|
role: roleRef(role),
|
|
role_assigned_by: null,
|
|
status: 'accepted',
|
|
created_at: createdAt,
|
|
}
|
|
}
|
|
|
|
function boardUsers(overrides: Record<number, string> = {}) {
|
|
const at = (id: number, name: string, role: string, day: string) =>
|
|
boardUser(id, name, overrides[id] ?? role, `2026-09-${day}T09:00:00Z`)
|
|
return [
|
|
at(1, 'Marta Alves', 'owner', '05'),
|
|
at(2, 'Jonas Weber', 'admin', '04'),
|
|
at(3, 'Ines Duarte', 'writer', '03'),
|
|
at(4, 'Pedro Lima', 'contributor', '02'),
|
|
{
|
|
kind: 'invite',
|
|
id: 9,
|
|
email: 'sofia@renewbuild.example',
|
|
role: roleRef('lead'),
|
|
status: 'pending',
|
|
created_at: '2026-09-01T09:00:00Z',
|
|
expires_at: '2026-11-01T09:00:00Z',
|
|
},
|
|
]
|
|
}
|
|
|
|
const SETTLE_MS = 250
|
|
|
|
async function openRowMenu(page: Page, name: string) {
|
|
await page.locator(`button[aria-label="Actions for ${name}"]:visible`).click()
|
|
await page.getByRole('menuitem').first().waitFor()
|
|
}
|
|
|
|
async function chooseMenuItem(page: Page, name: string, label: string) {
|
|
await openRowMenu(page, name)
|
|
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
|
}
|
|
|
|
async function chooseRole(page: Page, role: string) {
|
|
await page.locator('[role="alertdialog"] label', { hasText: role }).first().click()
|
|
}
|
|
|
|
async function pressInDialog(page: Page, label: string) {
|
|
await page
|
|
.locator('[role="alertdialog"]')
|
|
.getByRole('button', { name: label, exact: true })
|
|
.click()
|
|
}
|
|
|
|
async function waitHeading(page: Page, name: string) {
|
|
await page.getByRole('heading', { name, exact: true }).first().waitFor()
|
|
}
|
|
|
|
async function settle(page: Page) {
|
|
await page.mouse.move(0, 0)
|
|
await page.waitForTimeout(SETTLE_MS)
|
|
}
|
|
|
|
function editorApi(extra: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
|
return {
|
|
'GET /v1/auth/me': martaSession,
|
|
'GET /v1/permission/': { status: 200, body: { groups: permissionCatalogue } },
|
|
'GET /v1/content-type': contentTypeList,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
async function chooseScope(page: Page, group: string, scope: 'None' | 'Own' | 'All') {
|
|
await page
|
|
.locator(`[role="radiogroup"][aria-label="${group}"]:visible`)
|
|
.getByRole('radio', { name: scope, exact: true })
|
|
.evaluate((element) => (element as HTMLElement).click())
|
|
}
|
|
|
|
async function staleEditorSteps(page: Page) {
|
|
await waitHeading(page, 'Contributor')
|
|
await chooseScope(page, 'Events: Update', 'All')
|
|
await chooseScope(page, 'Events: Delete', 'Own')
|
|
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
|
|
await page.getByText('This role changed while you were editing').waitFor()
|
|
}
|
|
|
|
function staleEditorApi(): Record<string, ApiMock> {
|
|
return editorApi({
|
|
'GET /v1/role/4': {
|
|
status: 200,
|
|
bodyFor: (_url, call) =>
|
|
call === 1
|
|
? roleItem('contributor')
|
|
: roleItem('contributor', { updated_at: STALE_SAVED_AT, updated_by: JONAS }, 3),
|
|
},
|
|
'PUT /v1/role/4': { status: 409, body: 'role_conflict' },
|
|
})
|
|
}
|
|
|
|
const staleEditor: VisualState = {
|
|
path: '/settings/roles/4',
|
|
timezone: 'UTC',
|
|
api: staleEditorApi(),
|
|
steps: async (page) => {
|
|
await staleEditorSteps(page)
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const staleReview: VisualState = {
|
|
path: '/settings/roles/4',
|
|
timezone: 'UTC',
|
|
api: staleEditorApi(),
|
|
steps: async (page) => {
|
|
await staleEditorSteps(page)
|
|
await page.getByRole('button', { name: 'Review differences', exact: true }).click()
|
|
await page.getByRole('alertdialog').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const staleKept: VisualState = {
|
|
path: '/settings/roles/4',
|
|
timezone: 'UTC',
|
|
api: staleEditorApi(),
|
|
steps: async (page) => {
|
|
await staleEditorSteps(page)
|
|
await page.getByRole('button', { name: 'Review differences', exact: true }).click()
|
|
await pressInDialog(page, 'Keep my edits')
|
|
await page.getByText('Role reloaded').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const saveFailed: VisualState = {
|
|
path: '/settings/roles/4',
|
|
api: editorApi({
|
|
'GET /v1/role/4': { status: 200, body: roleItem('contributor') },
|
|
'PUT /v1/role/4': { status: 500 },
|
|
}),
|
|
steps: async (page) => {
|
|
await waitHeading(page, 'Contributor')
|
|
await chooseScope(page, 'Events: Update', 'All')
|
|
await chooseScope(page, 'Events: Delete', 'Own')
|
|
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
|
|
await page.getByText("Couldn't save the role").waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const readOwnEditor: VisualState = {
|
|
path: '/settings/roles/6',
|
|
api: editorApi({ 'GET /v1/role/6': { status: 200, body: roleItem('junior') } }),
|
|
steps: async (page) => {
|
|
await waitHeading(page, 'Junior writer')
|
|
await page.getByText('Read: Own', { exact: true }).waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
function usersApi(
|
|
session: ApiMock,
|
|
extra: Record<string, ApiMock> = {},
|
|
users = boardUsers(),
|
|
): Record<string, ApiMock> {
|
|
return {
|
|
'GET /v1/auth/me': session,
|
|
'GET /v1/user/': { status: 200, body: users },
|
|
'GET /v1/role/': rolesList('owner', 'admin', 'writer', 'contributor', 'lead'),
|
|
'GET /v1/content-type': contentTypeList,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
async function openChangeRole(page: Page) {
|
|
await waitHeading(page, 'Users')
|
|
await chooseMenuItem(page, 'Ines Duarte', 'Change role')
|
|
await page.locator('[role="alertdialog"] label', { hasText: 'Content lead' }).waitFor()
|
|
}
|
|
|
|
const changeStale: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, {
|
|
'GET /v1/user/': {
|
|
status: 200,
|
|
bodyFor: (_url, call) => boardUsers(call === 1 ? {} : { 3: 'admin' }),
|
|
},
|
|
'PUT /v1/user/3/role': { status: 409, body: 'user_role_changed' },
|
|
}),
|
|
steps: async (page) => {
|
|
await openChangeRole(page)
|
|
await chooseRole(page, 'Content lead')
|
|
await pressInDialog(page, 'Change role')
|
|
await page.getByText('Role changed in the meantime').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const changeFailed: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, { 'PUT /v1/user/3/role': { status: 500 } }),
|
|
steps: async (page) => {
|
|
await openChangeRole(page)
|
|
await chooseRole(page, 'Content lead')
|
|
await pressInDialog(page, 'Change role')
|
|
await page.getByText("Couldn't change the role").waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
async function reachTransferConfirm(page: Page) {
|
|
await waitHeading(page, 'Users')
|
|
await chooseMenuItem(page, 'Marta Alves', 'Transfer ownership')
|
|
await page.locator('[role="alertdialog"] label', { hasText: 'Jonas Weber' }).first().click()
|
|
await pressInDialog(page, 'Continue')
|
|
await page.locator('[role="alertdialog"] input').fill('jonas@renewbuild.example')
|
|
}
|
|
|
|
const transferStale: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, {
|
|
'GET /v1/user/': {
|
|
status: 200,
|
|
bodyFor: (_url, call) => boardUsers(call === 1 ? {} : { 2: 'writer' }),
|
|
},
|
|
'POST /v1/site/transfer-ownership': { status: 409, body: 'user_role_changed' },
|
|
}),
|
|
steps: async (page) => {
|
|
await reachTransferConfirm(page)
|
|
await pressInDialog(page, 'Transfer ownership')
|
|
await page.getByText('Role changed in the meantime').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
async function openInvite(page: Page) {
|
|
await waitHeading(page, 'Users')
|
|
await page.getByRole('button', { name: 'Invite user', exact: true }).first().click()
|
|
await page.getByRole('dialog').waitFor()
|
|
}
|
|
|
|
async function fillInvite(page: Page, email: string) {
|
|
await page.getByPlaceholder('name@example.com').fill(email)
|
|
}
|
|
|
|
async function sendInvite(page: Page) {
|
|
await page.getByRole('button', { name: 'Send invite', exact: true }).click()
|
|
}
|
|
|
|
const inviteInvalid: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession),
|
|
steps: async (page) => {
|
|
await openInvite(page)
|
|
await fillInvite(page, 'sofia@renewbuild')
|
|
await sendInvite(page)
|
|
await page.getByText('Enter a valid email address.').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const inviteDuplicate: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, {
|
|
'POST /v1/user/invite': { status: 409, body: 'email_has_account' },
|
|
}),
|
|
steps: async (page) => {
|
|
await openInvite(page)
|
|
await fillInvite(page, 'ines@renewbuild.example')
|
|
await sendInvite(page)
|
|
await page.getByText('This address already has an account.').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const inviteEscalation: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(jonasSession, {
|
|
'POST /v1/user/invite': { status: 403, body: 'escalation' },
|
|
}),
|
|
steps: async (page) => {
|
|
await openInvite(page)
|
|
await fillInvite(page, 'sofia@renewbuild.example')
|
|
await sendInvite(page)
|
|
await page.getByText("Your role can't assign a role with more power than yours.").waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const inviteFailed: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, { 'POST /v1/user/invite': { status: 500 } }),
|
|
steps: async (page) => {
|
|
await openInvite(page)
|
|
await fillInvite(page, 'sofia@renewbuild.example')
|
|
await sendInvite(page)
|
|
await page.getByText("Couldn't send the invite").waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
function deleteApi(extra: Record<string, ApiMock> = {}, roles?: ApiMock): Record<string, ApiMock> {
|
|
return {
|
|
'GET /v1/auth/me': martaSession,
|
|
'GET /v1/role/':
|
|
roles ?? rolesList('owner', 'admin', 'writer', 'contributor', 'lead', 'intern'),
|
|
'GET /v1/content-type': contentTypeList,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
async function openDelete(page: Page, name: string) {
|
|
await waitHeading(page, 'Roles')
|
|
await chooseMenuItem(page, name, 'Delete')
|
|
await page.getByRole('alertdialog').waitFor()
|
|
}
|
|
|
|
async function pickMoveTarget(page: Page, target: string) {
|
|
await page.locator('[role="alertdialog"] [aria-haspopup="listbox"]').click()
|
|
await page.getByRole('option', { name: new RegExp(`^${target}`) }).click()
|
|
}
|
|
|
|
const deleteTargetGone: VisualState = {
|
|
path: '/settings/roles',
|
|
api: deleteApi({
|
|
'POST /v1/role/4/reassign': { status: 409, body: 'target_role_not_found' },
|
|
}),
|
|
steps: async (page) => {
|
|
await openDelete(page, 'Contributor')
|
|
await pickMoveTarget(page, 'Writer')
|
|
await pressInDialog(page, 'Move 4 members and delete role')
|
|
await page.getByText('Writer no longer exists').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const deleteGainedMembers: VisualState = {
|
|
path: '/settings/roles',
|
|
api: deleteApi(
|
|
{ 'DELETE /v1/role/7': { status: 409, body: 'role_has_members' } },
|
|
{
|
|
status: 200,
|
|
bodyFor: (_url, call) =>
|
|
['owner', 'admin', 'writer', 'contributor', 'lead', 'intern'].map((key) =>
|
|
roleItem(key, call > 1 && key === 'intern' ? { members: 1 } : {}),
|
|
),
|
|
},
|
|
),
|
|
steps: async (page) => {
|
|
await openDelete(page, 'Intern')
|
|
await pressInDialog(page, 'Delete role')
|
|
await page.getByText('Role still has members').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const deleteMoveFailed: VisualState = {
|
|
path: '/settings/roles',
|
|
api: deleteApi({ 'POST /v1/role/4/reassign': { status: 500 } }),
|
|
steps: async (page) => {
|
|
await openDelete(page, 'Contributor')
|
|
await pickMoveTarget(page, 'Writer')
|
|
await pressInDialog(page, 'Move 4 members and delete role')
|
|
await page.getByText("Couldn't move the members").waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const pedroSession = session(
|
|
'Pedro Lima',
|
|
customAccess(
|
|
'content.read=own, content.create, content.update=own, schema.view, media.view, media.upload, authors.view, authors.edit=own',
|
|
'Junior writer',
|
|
6,
|
|
),
|
|
4,
|
|
'pedro@renewbuild.example',
|
|
)
|
|
|
|
function articleRow(id: number, title: string, online: boolean, createdBy: number) {
|
|
return {
|
|
id,
|
|
title,
|
|
body: '',
|
|
is_online: online,
|
|
created_by: createdBy,
|
|
authors: [],
|
|
created_at: '2026-10-01T09:00:00Z',
|
|
updated_at: '2026-10-14T09:00:00Z',
|
|
}
|
|
}
|
|
|
|
const ownArticles = [
|
|
articleRow(1, 'How we price a bathroom renovation', true, 3),
|
|
articleRow(2, 'Retrofit grants in 2027', true, 2),
|
|
articleRow(3, 'Winter checklist for timber windows', true, 1),
|
|
articleRow(4, 'Choosing lime plaster', false, 4),
|
|
articleRow(5, 'Site safety checklist', false, 4),
|
|
]
|
|
|
|
function ownArticleList(all: ReturnType<typeof articleRow>[]): ApiMock {
|
|
return {
|
|
status: 200,
|
|
bodyFor: (url) => {
|
|
const status = url.searchParams.get('status') ?? ''
|
|
const items = all.filter((row) => !status || row.is_online === (status === 'online'))
|
|
const online = all.filter((row) => row.is_online).length
|
|
return {
|
|
items,
|
|
total: items.length,
|
|
page: 1,
|
|
size: 25,
|
|
counts: { all: all.length, online, draft: all.length - online, suggestions: 0 },
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
const articlesReadOwn: VisualState = {
|
|
path: '/article',
|
|
skip: 'clock-sensitive',
|
|
}
|
|
|
|
const articlesNoDrafts: VisualState = {
|
|
path: '/article?status=draft',
|
|
api: {
|
|
'GET /v1/auth/me': pedroSession,
|
|
'GET /v1/article/': ownArticleList(ownArticles.slice(0, 3)),
|
|
'GET /v1/site/': { status: 200, body: [] },
|
|
'GET /v1/content-type': contentTypeList,
|
|
},
|
|
steps: async (page) => {
|
|
await page.getByText('You have no drafts').waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const eventRows = [
|
|
['Lime plaster workshop', '2026-11-21', '2026-10-14T09:00:00Z'],
|
|
['Tool library evening', '2026-12-05', '2026-10-12T09:00:00Z'],
|
|
['Site safety talk', '2026-12-18', '2026-10-09T09:00:00Z'],
|
|
] as const
|
|
|
|
const eventsType = {
|
|
id: 1,
|
|
key: 'events',
|
|
name: 'Events',
|
|
icon: 'files',
|
|
fields: [
|
|
{
|
|
key: 'title',
|
|
label: 'Title',
|
|
type: 'text',
|
|
required: true,
|
|
unique: false,
|
|
default: null,
|
|
min: null,
|
|
max: null,
|
|
options: [],
|
|
},
|
|
{
|
|
key: 'starts_on',
|
|
label: 'Starts on',
|
|
type: 'date',
|
|
required: true,
|
|
unique: false,
|
|
default: null,
|
|
min: null,
|
|
max: null,
|
|
options: [],
|
|
},
|
|
],
|
|
created_at: '2026-09-02T09:00:00Z',
|
|
updated_at: '2026-09-02T09:00:00Z',
|
|
}
|
|
|
|
function eventEntryList(rows: readonly (readonly [string, string, string])[]): ApiMock {
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
items: rows.map(([title, startsOn, updatedAt], index) => ({
|
|
id: index + 1,
|
|
content_type: 'events',
|
|
data: { title, starts_on: startsOn },
|
|
created_by: 4,
|
|
updated_by: 4,
|
|
created_at: '2026-10-01T09:00:00Z',
|
|
updated_at: updatedAt,
|
|
relations: {},
|
|
})),
|
|
total: rows.length,
|
|
page: 1,
|
|
size: 25,
|
|
},
|
|
}
|
|
}
|
|
|
|
function entryListApi(rows: readonly (readonly [string, string, string])[]) {
|
|
return {
|
|
'GET /v1/auth/me': pedroSession,
|
|
'GET /v1/content-type': { status: 200, body: [eventsType] },
|
|
'GET /v1/content-type/events': { status: 200, body: eventsType },
|
|
'GET /v1/content-type/events/entry': eventEntryList(rows),
|
|
'GET /v1/user/lookup': { status: 200, body: [] },
|
|
}
|
|
}
|
|
|
|
const entriesReadOwn: VisualState = {
|
|
path: '/content/events',
|
|
api: entryListApi(eventRows),
|
|
steps: async (page) => {
|
|
await page.getByText("Entries by others aren't shown").waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const entriesNone: VisualState = {
|
|
path: '/content/events',
|
|
api: entryListApi([]),
|
|
steps: async (page) => {
|
|
await page.getByText("You haven't created an event yet").waitFor()
|
|
await settle(page)
|
|
},
|
|
}
|
|
|
|
const toastWait = 120
|
|
|
|
async function waitToast(page: Page, title: string) {
|
|
await page.getByText(title, { exact: true }).waitFor()
|
|
await page.waitForTimeout(toastWait)
|
|
await settle(page)
|
|
}
|
|
|
|
const roleChangedToast: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, {
|
|
'PUT /v1/user/3/role': {
|
|
status: 200,
|
|
body: boardUser(3, 'Ines Duarte', 'lead', '2026-09-03T09:00:00Z'),
|
|
},
|
|
}),
|
|
steps: async (page) => {
|
|
await openChangeRole(page)
|
|
await chooseRole(page, 'Content lead')
|
|
await pressInDialog(page, 'Change role')
|
|
await waitToast(page, 'Role changed')
|
|
},
|
|
}
|
|
|
|
const roleEditedElsewhere: VisualState = {
|
|
path: '/settings/roles',
|
|
api: deleteApi(
|
|
{ 'POST /v1/role/4/reassign': { status: 409, body: 'role_conflict' } },
|
|
{
|
|
status: 200,
|
|
bodyFor: (_url, call) =>
|
|
['owner', 'admin', 'writer', 'contributor', 'lead'].map((key) =>
|
|
roleItem(
|
|
key,
|
|
call > 1 && key === 'contributor'
|
|
? { updated_by: JONAS, updated_at: STALE_SAVED_AT }
|
|
: {},
|
|
),
|
|
),
|
|
},
|
|
),
|
|
steps: async (page) => {
|
|
await openDelete(page, 'Contributor')
|
|
await pickMoveTarget(page, 'Writer')
|
|
await pressInDialog(page, 'Move 4 members and delete role')
|
|
await waitToast(page, 'Role changed elsewhere')
|
|
},
|
|
}
|
|
|
|
const roleDeletedElsewhere: VisualState = {
|
|
path: '/settings/roles',
|
|
api: deleteApi(
|
|
{ 'POST /v1/role/4/reassign': { status: 404, body: 'role_not_found' } },
|
|
rolesList('owner', 'admin', 'writer', 'contributor', 'lead'),
|
|
),
|
|
steps: async (page) => {
|
|
await openDelete(page, 'Contributor')
|
|
await pickMoveTarget(page, 'Writer')
|
|
await pressInDialog(page, 'Move 4 members and delete role')
|
|
await waitToast(page, 'That role no longer exists')
|
|
},
|
|
}
|
|
|
|
async function navigateInApp(page: Page, path: string) {
|
|
await page.evaluate((target) => {
|
|
window.history.pushState({}, '', target)
|
|
window.dispatchEvent(new PopStateEvent('popstate'))
|
|
}, path)
|
|
}
|
|
|
|
function heldRequest(page: Page, pattern: RegExp) {
|
|
let release: (status: number, body: string) => void = () => undefined
|
|
const held = new Promise<{ status: number; body: string }>((resolve) => {
|
|
release = (status, body) => resolve({ status, body })
|
|
})
|
|
void page.route(pattern, async (route) => {
|
|
const answer = await held
|
|
await route.fulfill({
|
|
status: answer.status,
|
|
contentType: 'text/plain',
|
|
body: answer.body,
|
|
})
|
|
})
|
|
return release
|
|
}
|
|
|
|
const roleNotChangedToast: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, {
|
|
'GET /v1/user/': {
|
|
status: 200,
|
|
bodyFor: (_url, call) => boardUsers(call > 1 ? { 3: 'admin' } : {}),
|
|
},
|
|
}),
|
|
steps: async (page) => {
|
|
await openChangeRole(page)
|
|
await chooseRole(page, 'Content lead')
|
|
const release = heldRequest(page, /\/v1\/user\/3\/role$/)
|
|
await pressInDialog(page, 'Change role')
|
|
await navigateInApp(page, '/settings/roles')
|
|
await waitHeading(page, 'Roles')
|
|
release(409, 'user_role_changed')
|
|
await page.waitForTimeout(toastWait)
|
|
await navigateInApp(page, '/users')
|
|
await waitToast(page, 'Role not changed')
|
|
},
|
|
}
|
|
|
|
const ownershipNotTransferredToast: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession),
|
|
steps: async (page) => {
|
|
await reachTransferConfirm(page)
|
|
const release = heldRequest(page, /\/v1\/site\/transfer-ownership$/)
|
|
await pressInDialog(page, 'Transfer ownership')
|
|
await navigateInApp(page, '/settings/roles')
|
|
await waitHeading(page, 'Roles')
|
|
release(409, 'user_role_changed')
|
|
await page.waitForTimeout(toastWait)
|
|
await navigateInApp(page, '/users')
|
|
await waitToast(page, 'Ownership not transferred')
|
|
},
|
|
}
|
|
|
|
const userNotRemovedToast: VisualState = {
|
|
path: '/users',
|
|
api: usersApi(martaSession, {
|
|
'DELETE /v1/user/3': { status: 409, body: 'user_role_changed' },
|
|
}),
|
|
steps: async (page) => {
|
|
await waitHeading(page, 'Users')
|
|
await chooseMenuItem(page, 'Ines Duarte', 'Remove user')
|
|
await pressInDialog(page, 'Remove user')
|
|
await waitToast(page, 'User not removed')
|
|
},
|
|
}
|
|
|
|
export const rolesStates: Record<string, VisualState> = {
|
|
'Role changed': roleChangedToast,
|
|
'Role edited elsewhere (409)': roleEditedElsewhere,
|
|
'Role deleted elsewhere': roleDeletedElsewhere,
|
|
"Role not changed: member's role changed first (409)": roleNotChangedToast,
|
|
"Ownership not transferred: target's role changed first (409)": ownershipNotTransferredToast,
|
|
"User not removed: member's role changed first (409)": userNotRemovedToast,
|
|
'Role editor: save failed (500)': saveFailed,
|
|
'Change role: failed (500)': changeFailed,
|
|
'Delete role: target role deleted': deleteTargetGone,
|
|
'Delete role: role gained members': deleteGainedMembers,
|
|
'Delete role: move failed (500)': deleteMoveFailed,
|
|
'Invite: invalid email': inviteInvalid,
|
|
'Invite: address already has an account (409)': inviteDuplicate,
|
|
'Invite: role above yours (403)': inviteEscalation,
|
|
'Invite: send failed (500)': inviteFailed,
|
|
'Role editor: Read set to Own': readOwnEditor,
|
|
'Articles list: Read = Own': articlesReadOwn,
|
|
'Articles list: no drafts of their own': articlesNoDrafts,
|
|
'Entry list: Read = Own': entriesReadOwn,
|
|
'Entry list: no entries of their own': entriesNone,
|
|
'Stale save: banner': staleEditor,
|
|
'Stale save: review differences': staleReview,
|
|
'Stale save: edits kept after reload': staleKept,
|
|
"Change role: member's role changed first (409)": changeStale,
|
|
"Transfer ownership: target's role changed first (409)": transferStale,
|
|
}
|