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
+200
-37
@@ -1,6 +1,8 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Page } from 'playwright'
|
||||
import { accessFor, roleRefFor } from '../src/test/access'
|
||||
import { accessFor, customAccess, roleRefFor } from '../src/test/access'
|
||||
import { approvedExceptions } from './approvedExceptions'
|
||||
import { rolesStates } from './rolesStates'
|
||||
|
||||
export interface ApiMock {
|
||||
status: number
|
||||
@@ -23,6 +25,7 @@ export interface VisualState {
|
||||
path: string
|
||||
origin?: string
|
||||
now?: string
|
||||
timezone?: string
|
||||
paused?: boolean
|
||||
api?: Record<string, ApiMock>
|
||||
steps?: (page: Page) => Promise<void>
|
||||
@@ -1116,10 +1119,31 @@ const usersFixture = [
|
||||
},
|
||||
]
|
||||
|
||||
const usersRoleList: ApiMock = {
|
||||
status: 200,
|
||||
body: [
|
||||
['Owner', 'Everything on the site. One per site.', true, true],
|
||||
['Admin', 'Everything except owner-only settings.', true, false],
|
||||
['Writer', "Reads everything and writes content. Can't delete or publish.", true, false],
|
||||
].map(([name, description, builtIn, locked], index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
description,
|
||||
built_in: builtIn,
|
||||
locked,
|
||||
version: 1,
|
||||
grants: [],
|
||||
members: 1,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
})),
|
||||
}
|
||||
|
||||
function usersApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/user/': { status: 200, body: usersFixture },
|
||||
'GET /v1/role/': usersRoleList,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -1359,6 +1383,9 @@ async function copyUnder(page: Page, heading: string) {
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const SCROLL_SETTLE_PASSES = 3
|
||||
const SCROLL_SETTLE_MS = 400
|
||||
|
||||
async function scrollToAnchor(
|
||||
page: Page,
|
||||
anchor: string,
|
||||
@@ -1366,25 +1393,32 @@ async function scrollToAnchor(
|
||||
) {
|
||||
const mobile = (page.viewportSize()?.width ?? 0) < 1024
|
||||
const offset = mobile ? offsets?.mobile : offsets?.desktop
|
||||
await page.evaluate(
|
||||
({ id, offset }) => {
|
||||
const target = document.querySelector(`[data-anchor="${id}"]`)
|
||||
if (!target) return
|
||||
if (offset === undefined) {
|
||||
target.scrollIntoView({ block: 'start' })
|
||||
return
|
||||
}
|
||||
let scroller = target.parentElement
|
||||
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
|
||||
scroller = scroller.parentElement
|
||||
}
|
||||
if (!scroller) return
|
||||
scroller.scrollTop +=
|
||||
target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset
|
||||
},
|
||||
{ id: anchor, offset },
|
||||
)
|
||||
await page.waitForTimeout(150)
|
||||
for (let pass = 0; pass < SCROLL_SETTLE_PASSES; pass++) {
|
||||
await page.evaluate(
|
||||
({ id, offset }) => {
|
||||
const target = document.querySelector(`[data-anchor="${id}"]`)
|
||||
if (!target) return
|
||||
if (offset === undefined) {
|
||||
target.scrollIntoView({ block: 'start' })
|
||||
return
|
||||
}
|
||||
let scroller = target.parentElement
|
||||
while (
|
||||
scroller &&
|
||||
!['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)
|
||||
) {
|
||||
scroller = scroller.parentElement
|
||||
}
|
||||
if (!scroller) return
|
||||
scroller.scrollTop +=
|
||||
target.getBoundingClientRect().top -
|
||||
scroller.getBoundingClientRect().top -
|
||||
offset
|
||||
},
|
||||
{ id: anchor, offset },
|
||||
)
|
||||
await page.waitForTimeout(SCROLL_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const acceptInvitePath = '/invite/accept?c=abc123'
|
||||
@@ -1488,6 +1522,42 @@ function profileApi(
|
||||
}
|
||||
}
|
||||
|
||||
function profileRoleCardState(
|
||||
displayName: string,
|
||||
email: string,
|
||||
options: { role: 'owner' | 'contributor' },
|
||||
): VisualState {
|
||||
const access =
|
||||
options.role === 'owner'
|
||||
? accessFor('owner')
|
||||
: customAccess('content.read=all, content.create, content.update=own', 'Contributor')
|
||||
return {
|
||||
path: '/profile',
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 22 },
|
||||
reason: 'The Roles-board profile frame is an older layout without the Display name card, and the design draws a capitalised Owner with an icon while the app keeps the shared RoleBadge (lowercase mono, no icon); changing it would restyle Users and API keys',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
api: {
|
||||
'GET /v1/auth/me': {
|
||||
status: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
is_verified: true,
|
||||
username: displayName.split(' ')[0]!.toLowerCase(),
|
||||
email,
|
||||
display_name: displayName,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: async (page) => {
|
||||
await page.getByRole('heading', { name: 'Your role', exact: true }).waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function profileState(
|
||||
displayName = '',
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
@@ -8246,23 +8316,56 @@ function speakerSummary(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function relationSearch(url: URL) {
|
||||
const needle = (url.searchParams.get('q') ?? '').toLowerCase()
|
||||
const matches = relationVenueRows.filter((row) => row.name.toLowerCase().includes(needle))
|
||||
const relationPickerNames = [
|
||||
'Rue Verte hall',
|
||||
'Rue Verte yard',
|
||||
'Rue du Port studio',
|
||||
'Rue Verte annex',
|
||||
'Rue de la Gare depot',
|
||||
'Rue Verte workshop',
|
||||
'Rue du Port loft',
|
||||
'Rue des Halles hall',
|
||||
'Rue Verte garden room',
|
||||
'Rue du Moulin barn',
|
||||
'Rue de Nantes studio',
|
||||
'Rue Verte gallery',
|
||||
'Rue du Port quay',
|
||||
'Rue des Tanneurs hall',
|
||||
'Rue Verte cellar',
|
||||
'Rue du Four kitchen',
|
||||
'Rue Verte terrace',
|
||||
'Rue du Port warehouse',
|
||||
'Rue des Écoles hall',
|
||||
'Rue Verte pavilion',
|
||||
'Rue du Lac studio',
|
||||
'Rue Verte shed',
|
||||
'Rue de la Paix room',
|
||||
'Rue Verte forge',
|
||||
'Rue du Port atelier',
|
||||
]
|
||||
|
||||
const relationPickerCities = ['Lyon', 'Nantes', 'Lille']
|
||||
const relationPickerTotal = 62
|
||||
|
||||
function relationSearch() {
|
||||
return {
|
||||
items: matches.map((row) => ({
|
||||
id: row.id,
|
||||
items: relationPickerNames.map((name, index) => ({
|
||||
id: 100 + index,
|
||||
content_type: 'venues',
|
||||
data: { name: row.name, city: row.city, details: row.details },
|
||||
data: {
|
||||
name,
|
||||
city: relationPickerCities[index % relationPickerCities.length],
|
||||
details: `${24 + ((index * 17) % 120)} seats`,
|
||||
},
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-02T09:14:00Z',
|
||||
updated_at: '2026-10-12T11:02:00Z',
|
||||
relations: {},
|
||||
})),
|
||||
total: matches.length,
|
||||
total: relationPickerTotal,
|
||||
page: 1,
|
||||
size: 10,
|
||||
size: 25,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8327,6 +8430,34 @@ const relationEntryStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
const relationEntryForm = relationEntryStates['Relation in the entry form']!
|
||||
|
||||
const relationPickerMore: VisualState = {
|
||||
path: relationEntryForm.path,
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 13 },
|
||||
reason: 'The app adds a Saved line, a meta block and a sticky action bar the board omits, as for Relation in the entry form',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
|
||||
api: relationEntryForm.api,
|
||||
steps: async (page) => {
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith('/venues/entry') && url.searchParams.get('page') === '2',
|
||||
() => undefined,
|
||||
)
|
||||
const input = entryField(page, 'venue').getByRole('combobox')
|
||||
await input.click()
|
||||
await input.fill('rue')
|
||||
await page.getByRole('option').first().waitFor()
|
||||
await page.getByRole('button', { name: 'Load more', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Loading…', exact: true }).waitFor()
|
||||
await schemaSettle(page)
|
||||
await page.getByRole('option').last().scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(150)
|
||||
},
|
||||
}
|
||||
|
||||
const relationListType = relationTypeOf(1, 'events', 'Events', [
|
||||
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
|
||||
schemaField('Starts on', 'starts_on', 'date', { required: true, options: [] }),
|
||||
@@ -8386,6 +8517,30 @@ const relationContentStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
function withApprovedExceptions(
|
||||
screen: string,
|
||||
states: Record<string, VisualState>,
|
||||
): Record<string, VisualState> {
|
||||
const approved = approvedExceptions[screen] ?? {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(states).map(([name, state]) => {
|
||||
const cap = approved[name]
|
||||
if (!cap || state.exception) return [name, state]
|
||||
return [
|
||||
name,
|
||||
{
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: { desktop: cap.desktop, mobile: cap.mobile },
|
||||
reason: cap.reason,
|
||||
approvedBy: 'user',
|
||||
},
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
sites: sitesStates,
|
||||
schema: {
|
||||
@@ -8398,10 +8553,18 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
...entryStates,
|
||||
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
|
||||
...relationEntryStates,
|
||||
'Relation picker: loading more': relationPickerMore,
|
||||
},
|
||||
authors: authorsStates,
|
||||
activity: activityStates,
|
||||
profile: {
|
||||
roles: withApprovedExceptions('roles', rolesStates),
|
||||
profile: withApprovedExceptions('profile', {
|
||||
'Role card: custom role': profileRoleCardState('Pedro Lima', 'pedro@renewbuild.example', {
|
||||
role: 'contributor',
|
||||
}),
|
||||
'Role card: Owner': profileRoleCardState('Marta Alves', 'marta@renewbuild.example', {
|
||||
role: 'owner',
|
||||
}),
|
||||
'No author profile': authorProfileState({}),
|
||||
Error: authorProfileState({ 'POST /v1/author/me': { status: 500 } }, async (page) => {
|
||||
await createAuthorProfile(page)
|
||||
@@ -8500,8 +8663,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
approvedBy: 'user',
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
}),
|
||||
users: withApprovedExceptions('users', {
|
||||
Default: {
|
||||
path: '/users',
|
||||
api: usersApi(),
|
||||
@@ -8580,7 +8743,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: inviteWith('lea.martin@renew.example', { status: 500 }),
|
||||
},
|
||||
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
||||
},
|
||||
}),
|
||||
settings: {
|
||||
...cleanupStates,
|
||||
...retentionStates,
|
||||
@@ -8708,7 +8871,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
|
||||
},
|
||||
'integration-api-keys': apiKeysStates,
|
||||
'integration-api-keys': withApprovedExceptions('integration-api-keys', apiKeysStates),
|
||||
integration: {
|
||||
'1. Default': integrationState(),
|
||||
'2. Group scrolled, one card expanded': integrationState({
|
||||
@@ -8782,7 +8945,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
const mobile = (page.viewportSize()?.width ?? 0) < desktopMinWidth
|
||||
await openIntegrationCard('POST /v1/content-type/', 'ep-ct-create', 'curl')(page)
|
||||
await scrollToAnchor(page, mobile ? 'ep-ct-create' : 'ct-fields', {
|
||||
desktop: -30,
|
||||
desktop: 10,
|
||||
mobile: 88,
|
||||
})
|
||||
},
|
||||
@@ -8796,7 +8959,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: openIntegrationCardAt(
|
||||
'GET /v1/content-type/{key}/entry',
|
||||
'en-list',
|
||||
{ anchor: 'ep-en-list', desktop: -44, mobile: 88 },
|
||||
{ anchor: 'ep-en-list', desktop: -4, mobile: 88 },
|
||||
'Response',
|
||||
),
|
||||
exception: {
|
||||
@@ -8808,7 +8971,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'9c. Entries: replace, 422 and 409': integrationState({
|
||||
steps: openIntegrationCardAt('PUT /v1/content-type/{key}/entry/{id}', 'en-update', {
|
||||
anchor: 'ep-en-update',
|
||||
desktop: -108,
|
||||
desktop: -68,
|
||||
mobile: 88,
|
||||
}),
|
||||
exception: {
|
||||
@@ -8840,7 +9003,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
}),
|
||||
'9j. Navigation: scroll-spy on Content types': integrationState({
|
||||
steps: async (page) => {
|
||||
await scrollToAnchor(page, 'ctypes', { desktop: -5, mobile: -3 })
|
||||
await scrollToAnchor(page, 'ctypes', { desktop: 33, mobile: -3 })
|
||||
if ((page.viewportSize()?.width ?? 0) < desktopMinWidth) {
|
||||
await page.getByRole('button', { name: 'Jump to section' }).click()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user