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

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

No files matched your search

+200 -37
View File
@@ -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()
}