From a4d71f89b823f87c456bc83164a32fb427550753 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 09:57:28 +0100 Subject: [PATCH 01/32] feat: user role management --- common/schemas/identity-api.json | 2 +- common/schemas/identity-entities.json | 59 +++- common/src/models/identity/index.ts | 1 + common/src/models/identity/roles.ts | 56 ++++ common/src/models/identity/user.ts | 2 +- frontend/src/components/role-tag/index.tsx | 111 +++++++ frontend/src/pages/admin/app-settings.tsx | 25 ++ frontend/src/pages/admin/user-details.tsx | 351 +++++++++++++++++++++ frontend/src/pages/admin/user-list.tsx | 234 ++++++++++++++ 9 files changed, 838 insertions(+), 3 deletions(-) create mode 100644 common/src/models/identity/roles.ts create mode 100644 frontend/src/components/role-tag/index.tsx create mode 100644 frontend/src/pages/admin/user-details.tsx create mode 100644 frontend/src/pages/admin/user-list.tsx diff --git a/common/schemas/identity-api.json b/common/schemas/identity-api.json index bb2125c2..18e489f5 100644 --- a/common/schemas/identity-api.json +++ b/common/schemas/identity-api.json @@ -18,7 +18,7 @@ "type": "array", "items": { "type": "string", - "const": "admin" + "enum": ["admin", "media-manager", "viewer", "iot-manager"] } }, "IsAuthenticatedAction": { diff --git a/common/schemas/identity-entities.json b/common/schemas/identity-entities.json index bbcbee20..1195471c 100644 --- a/common/schemas/identity-entities.json +++ b/common/schemas/identity-entities.json @@ -5,7 +5,7 @@ "type": "array", "items": { "type": "string", - "const": "admin" + "enum": ["admin", "media-manager", "viewer", "iot-manager"] } }, "User": { @@ -26,6 +26,63 @@ }, "required": ["username", "roles", "createdAt", "updatedAt"], "additionalProperties": false + }, + "RoleMetadata": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "Human-readable name for display in UI" + }, + "description": { + "type": "string", + "description": "Brief description of what this role allows" + } + }, + "required": ["displayName", "description"], + "additionalProperties": false, + "description": "Metadata for a role (displayName, description)" + }, + "RoleDefinition": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "enum": ["admin", "media-manager", "viewer", "iot-manager"], + "description": "Role name (matches values in Roles type)" + }, + "displayName": { + "type": "string", + "description": "Human-readable name for display in UI" + }, + "description": { + "type": "string", + "description": "Brief description of what this role allows" + } + }, + "required": ["description", "displayName", "name"], + "description": "Full role definition including the name" + }, + "getRoleDefinition": { + "$comment": "(name: Roles[number]) => RoleDefinition", + "type": "object", + "properties": { + "namedArgs": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["admin", "media-manager", "viewer", "iot-manager"] + } + }, + "required": ["name"], + "additionalProperties": false + } + } + }, + "getAllRoleDefinitions": { + "$comment": "() => RoleDefinition[]" } } } diff --git a/common/src/models/identity/index.ts b/common/src/models/identity/index.ts index 72e50f89..501fb857 100644 --- a/common/src/models/identity/index.ts +++ b/common/src/models/identity/index.ts @@ -1 +1,2 @@ export * from './user.js' +export * from './roles.js' diff --git a/common/src/models/identity/roles.ts b/common/src/models/identity/roles.ts new file mode 100644 index 00000000..bec9e753 --- /dev/null +++ b/common/src/models/identity/roles.ts @@ -0,0 +1,56 @@ +import type { Roles } from './user.js' + +/** + * Metadata for a role (displayName, description) + */ +export type RoleMetadata = { + /** Human-readable name for display in UI */ + displayName: string + /** Brief description of what this role allows */ + description: string +} + +/** + * Full role definition including the name + */ +export type RoleDefinition = RoleMetadata & { + /** Role name (matches values in Roles type) */ + name: Roles[number] +} + +/** + * All available roles in the system with their metadata. + * Using Record ensures TS will error if a role is missing. + */ +export const AVAILABLE_ROLES: Record = { + admin: { + displayName: 'Application Admin', + description: 'Full system access including user management and all settings', + }, + 'media-manager': { + displayName: 'Media Manager', + description: 'Can manage movies, series, and encoding tasks', + }, + viewer: { + displayName: 'Viewer', + description: 'Can browse and watch media content', + }, + 'iot-manager': { + displayName: 'IoT Manager', + description: 'Can manage IoT devices and their settings', + }, +} + +/** + * Helper to get full role definition by name + */ +export const getRoleDefinition = (name: Roles[number]): RoleDefinition => ({ + name, + ...AVAILABLE_ROLES[name], +}) + +/** + * Get all role definitions as an array (useful for dropdowns) + */ +export const getAllRoleDefinitions = (): RoleDefinition[] => + (Object.keys(AVAILABLE_ROLES) as Array).map(getRoleDefinition) diff --git a/common/src/models/identity/user.ts b/common/src/models/identity/user.ts index 2b63bce0..390765c9 100644 --- a/common/src/models/identity/user.ts +++ b/common/src/models/identity/user.ts @@ -1,4 +1,4 @@ -export type Roles = Array<'admin'> +export type Roles = Array<'admin' | 'media-manager' | 'viewer' | 'iot-manager'> export class User { public username!: string diff --git a/frontend/src/components/role-tag/index.tsx b/frontend/src/components/role-tag/index.tsx new file mode 100644 index 00000000..af7b4a95 --- /dev/null +++ b/frontend/src/components/role-tag/index.tsx @@ -0,0 +1,111 @@ +import { createComponent, Shade } from '@furystack/shades' +import type { Roles } from 'common' +import { getRoleDefinition } from 'common' + +type RoleTagProps = { + roleName: Roles[number] + variant: 'default' | 'added' | 'removed' + onRemove?: () => void + onRestore?: () => void +} + +export const RoleTag = Shade({ + shadowDomName: 'role-tag', + render: ({ props }) => { + const role = getRoleDefinition(props.roleName) + const baseStyle: Partial = { + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + padding: '4px 12px', + borderRadius: '16px', + fontSize: '13px', + fontWeight: '500', + transition: 'all 0.2s ease', + } + + const variantStyles: Record> = { + default: { + backgroundColor: 'var(--theme-background-paper)', + border: '1px solid var(--theme-border-default)', + color: 'var(--theme-text-primary)', + }, + added: { + backgroundColor: 'rgba(76, 175, 80, 0.15)', + border: '1px solid var(--theme-success-main, #4caf50)', + color: 'var(--theme-success-dark, #2e7d32)', + }, + removed: { + backgroundColor: 'rgba(244, 67, 54, 0.15)', + border: '1px solid var(--theme-error-main, #f44336)', + color: 'var(--theme-error-dark, #c62828)', + textDecoration: 'line-through', + }, + } + + const buttonBaseStyle: Partial = { + background: 'none', + border: 'none', + cursor: 'pointer', + padding: '0', + margin: '0', + marginLeft: '4px', + fontSize: '14px', + lineHeight: '1', + opacity: '0.7', + transition: 'opacity 0.2s ease', + } + + const style = { ...baseStyle, ...variantStyles[props.variant] } + + return ( + + {role.displayName} + {props.variant === 'removed' && props.onRestore && ( + + )} + {props.variant !== 'removed' && props.onRemove && ( + + )} + + ) + }, +}) diff --git a/frontend/src/pages/admin/app-settings.tsx b/frontend/src/pages/admin/app-settings.tsx index ff1856f8..9a47c982 100644 --- a/frontend/src/pages/admin/app-settings.tsx +++ b/frontend/src/pages/admin/app-settings.tsx @@ -47,6 +47,28 @@ const settingsRoutes = [ /> ), }, + { + url: '/app-settings/users/:username', + component: () => ( + { + const { UserDetailsPage } = await import('./user-details.js') + return + }} + /> + ), + }, + { + url: '/app-settings/users', + component: () => ( + { + const { UserListPage } = await import('./user-list.js') + return + }} + /> + ), + }, { url: '/app-settings', component: () => ( @@ -103,6 +125,9 @@ export const AppSettingsPage = Shade({ + + +
+ +type UserState = + | { status: 'loading' } + | { status: 'loaded'; user: User } + | { status: 'error'; message: string } + +type RoleChange = { + originalRoles: Roles + currentRoles: Roles +} + +export const UserDetailsPage = Shade({ + shadowDomName: 'user-details-page', + render: ({ injector, useObservable, useDisposable }) => { + const apiClient = injector.getInstance(IdentityApiClient) + const locationService = injector.getInstance(LocationService) + const notyService = injector.getInstance(NotyService) + + // Extract username from URL + const pathParts = window.location.pathname.split('/') + const username = decodeURIComponent(pathParts[pathParts.length - 1]) + + const userStateObservable = useDisposable('userState', () => new ObservableValue({ status: 'loading' })) + const [userState] = useObservable('userStateValue', userStateObservable) + + const roleChangeObservable = useDisposable('roleChange', () => new ObservableValue(null)) + const [roleChange] = useObservable('roleChangeValue', roleChangeObservable) + + const isSavingObservable = useDisposable('isSaving', () => new ObservableValue(false)) + const [isSaving] = useObservable('isSavingValue', isSavingObservable) + + const validationErrorObservable = useDisposable('validationError', () => new ObservableValue(null)) + const [validationError] = useObservable('validationErrorValue', validationErrorObservable) + + // Fetch user on mount + useDisposable('fetchUser', () => { + const fetchUser = async () => { + try { + const { result } = await apiClient.call({ + method: 'GET', + action: '/users/:id', + url: { id: username }, + query: {}, + }) + userStateObservable.setValue({ status: 'loaded', user: result }) + roleChangeObservable.setValue({ + originalRoles: [...result.roles], + currentRoles: [...result.roles], + }) + } catch (error) { + userStateObservable.setValue({ + status: 'error', + message: error instanceof Error ? error.message : 'Failed to load user', + }) + } + } + void fetchUser() + return { [Symbol.dispose]: () => {} } + }) + + const navigateBack = () => { + window.history.pushState({}, '', '/app-settings/users') + locationService.updateState() + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + const addRole = (roleName: Roles[number]) => { + if (!roleChange) return + if (roleChange.currentRoles.includes(roleName)) return + roleChangeObservable.setValue({ + ...roleChange, + currentRoles: [...roleChange.currentRoles, roleName], + }) + validationErrorObservable.setValue(null) + } + + const removeRole = (roleName: Roles[number]) => { + if (!roleChange) return + roleChangeObservable.setValue({ + ...roleChange, + currentRoles: roleChange.currentRoles.filter((r) => r !== roleName), + }) + } + + const restoreRole = (roleName: Roles[number]) => { + if (!roleChange) return + if (roleChange.currentRoles.includes(roleName)) return + roleChangeObservable.setValue({ + ...roleChange, + currentRoles: [...roleChange.currentRoles, roleName], + }) + validationErrorObservable.setValue(null) + } + + const getRoleVariant = (roleName: Roles[number]): 'default' | 'added' | 'removed' => { + if (!roleChange) return 'default' + const isInOriginal = roleChange.originalRoles.includes(roleName) + const isInCurrent = roleChange.currentRoles.includes(roleName) + + if (isInOriginal && isInCurrent) return 'default' + if (!isInOriginal && isInCurrent) return 'added' + if (isInOriginal && !isInCurrent) return 'removed' + return 'default' + } + + const hasChanges = () => { + if (!roleChange) return false + const originalSorted = [...roleChange.originalRoles].sort() + const currentSorted = [...roleChange.currentRoles].sort() + if (originalSorted.length !== currentSorted.length) return true + return originalSorted.some((role, idx) => role !== currentSorted[idx]) + } + + const handleSave = async () => { + if (!roleChange || userState.status !== 'loaded') return + + // Validation + if (roleChange.currentRoles.length === 0) { + validationErrorObservable.setValue('User must have at least one role') + return + } + + isSavingObservable.setValue(true) + validationErrorObservable.setValue(null) + + try { + await apiClient.call({ + method: 'PATCH', + action: '/users/:id', + url: { id: username }, + body: { + username: userState.user.username, + roles: roleChange.currentRoles, + }, + }) + + notyService.emit('onNotyAdded', { + title: 'Success', + body: 'User roles updated successfully', + type: 'success', + }) + + // Update the original roles to reflect saved state + roleChangeObservable.setValue({ + originalRoles: [...roleChange.currentRoles], + currentRoles: [...roleChange.currentRoles], + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to save user' + notyService.emit('onNotyAdded', { + title: 'Error', + body: errorMessage, + type: 'error', + }) + } finally { + isSavingObservable.setValue(false) + } + } + + const handleCancel = () => { + if (roleChange) { + roleChangeObservable.setValue({ + ...roleChange, + currentRoles: [...roleChange.originalRoles], + }) + validationErrorObservable.setValue(null) + } + } + + const allRoles = getAllRoleDefinitions() + const availableRolesToAdd = roleChange + ? allRoles.filter((role) => !roleChange.currentRoles.includes(role.name)) + : [] + + // Get all roles to display (current + removed) + const rolesToDisplay = roleChange + ? [ + ...new Set([ + ...roleChange.originalRoles, // Include original roles (may be removed) + ...roleChange.currentRoles, // Include current roles (may be added) + ]), + ] + : [] + + return ( +
+
+ +

User Details

+
+ + {userState.status === 'loading' && ( + +

Loading user...

+
+ )} + + {userState.status === 'error' && ( + +

Error: {userState.message}

+ +
+ )} + + {userState.status === 'loaded' && roleChange && ( + <> + +

+ User Information +

+ +
+ Username: + {userState.user.username} + + Created: + {formatDate(userState.user.createdAt)} + + Last Updated: + {formatDate(userState.user.updatedAt)} +
+
+ + +

Roles

+ +
+
+ {rolesToDisplay.length > 0 ? ( + rolesToDisplay.map((roleName) => { + const variant = getRoleVariant(roleName) + return ( + removeRole(roleName) : undefined} + onRestore={variant === 'removed' ? () => restoreRole(roleName) : undefined} + /> + ) + }) + ) : ( + No roles assigned + )} +
+
+ + {availableRolesToAdd.length > 0 && ( +
+ + +
+ )} + + {validationError && ( +
+ {validationError} +
+ )} + +
+ + +
+
+ + )} +
+ ) + }, +}) diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx new file mode 100644 index 00000000..3f5d6fa4 --- /dev/null +++ b/frontend/src/pages/admin/user-list.tsx @@ -0,0 +1,234 @@ +import { createComponent, LocationService, Shade } from '@furystack/shades' +import { Button, Paper } from '@furystack/shades-common-components' +import { ObservableValue } from '@furystack/utils' +import type { User } from 'common' +import { RoleTag } from '../../components/role-tag/index.js' +import { IdentityApiClient } from '../../services/api-clients/identity-api-client.js' + +type UserListPageProps = Record + +type UsersState = + | { status: 'loading' } + | { status: 'loaded'; users: User[] } + | { status: 'error'; message: string } + +export const UserListPage = Shade({ + shadowDomName: 'user-list-page', + render: ({ injector, useObservable, useDisposable }) => { + const apiClient = injector.getInstance(IdentityApiClient) + const locationService = injector.getInstance(LocationService) + + const usersStateObservable = useDisposable('usersState', () => new ObservableValue({ status: 'loading' })) + const [usersState] = useObservable('usersStateValue', usersStateObservable) + + // Fetch users on mount + useDisposable('fetchUsers', () => { + const fetchUsers = async () => { + try { + const { result } = await apiClient.call({ + method: 'GET', + action: '/users', + query: {}, + }) + usersStateObservable.setValue({ status: 'loaded', users: result.entries }) + } catch (error) { + usersStateObservable.setValue({ + status: 'error', + message: error instanceof Error ? error.message : 'Failed to load users', + }) + } + } + void fetchUsers() + return { [Symbol.dispose]: () => {} } + }) + + const navigateToUser = (username: string) => { + window.history.pushState({}, '', `/app-settings/users/${encodeURIComponent(username)}`) + locationService.updateState() + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + return ( +
+

👥 Users

+

+ Manage user accounts and their roles. +

+ + {usersState.status === 'loading' && ( + +

Loading users...

+
+ )} + + {usersState.status === 'error' && ( + +

Error: {usersState.message}

+ +
+ )} + + {usersState.status === 'loaded' && ( + + + + + + + + + + + + {usersState.users.map((user) => ( + navigateToUser(user.username)} + onmouseenter={(e) => { + ;(e.currentTarget as HTMLTableRowElement).style.backgroundColor = + 'var(--theme-background-default)' + }} + onmouseleave={(e) => { + ;(e.currentTarget as HTMLTableRowElement).style.backgroundColor = '' + }} + > + + + + + + ))} + {usersState.users.length === 0 && ( + + + + )} + +
+ Username + + Roles + + Created + + Actions +
+ {user.username} + +
+ {user.roles.length > 0 ? ( + user.roles.map((roleName) => ) + ) : ( + No roles + )} +
+
+ {formatDate(user.createdAt)} + + +
+ No users found. +
+
+ )} +
+ ) + }, +}) From 66724995d283a8f33667695e4ed055d99b09e140 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:03:44 +0100 Subject: [PATCH 02/32] added users-service --- frontend/src/pages/admin/user-details.tsx | 74 ++++++++-------------- frontend/src/pages/admin/user-list.tsx | 76 ++++++----------------- frontend/src/services/users-service.ts | 76 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 105 deletions(-) create mode 100644 frontend/src/services/users-service.ts diff --git a/frontend/src/pages/admin/user-details.tsx b/frontend/src/pages/admin/user-details.tsx index 63c47214..e5c3baf9 100644 --- a/frontend/src/pages/admin/user-details.tsx +++ b/frontend/src/pages/admin/user-details.tsx @@ -1,18 +1,13 @@ import { createComponent, LocationService, Shade } from '@furystack/shades' import { Button, NotyService, Paper } from '@furystack/shades-common-components' import { ObservableValue } from '@furystack/utils' -import type { Roles, User } from 'common' +import type { Roles } from 'common' import { getAllRoleDefinitions } from 'common' import { RoleTag } from '../../components/role-tag/index.js' -import { IdentityApiClient } from '../../services/api-clients/identity-api-client.js' +import { UsersService } from '../../services/users-service.js' type UserDetailsPageProps = Record -type UserState = - | { status: 'loading' } - | { status: 'loaded'; user: User } - | { status: 'error'; message: string } - type RoleChange = { originalRoles: Roles currentRoles: Roles @@ -21,7 +16,7 @@ type RoleChange = { export const UserDetailsPage = Shade({ shadowDomName: 'user-details-page', render: ({ injector, useObservable, useDisposable }) => { - const apiClient = injector.getInstance(IdentityApiClient) + const usersService = injector.getInstance(UsersService) const locationService = injector.getInstance(LocationService) const notyService = injector.getInstance(NotyService) @@ -29,8 +24,7 @@ export const UserDetailsPage = Shade({ const pathParts = window.location.pathname.split('/') const username = decodeURIComponent(pathParts[pathParts.length - 1]) - const userStateObservable = useDisposable('userState', () => new ObservableValue({ status: 'loading' })) - const [userState] = useObservable('userStateValue', userStateObservable) + const [userState] = useObservable('user', usersService.getUserAsObservable(username)) const roleChangeObservable = useDisposable('roleChange', () => new ObservableValue(null)) const [roleChange] = useObservable('roleChangeValue', roleChangeObservable) @@ -41,31 +35,13 @@ export const UserDetailsPage = Shade({ const validationErrorObservable = useDisposable('validationError', () => new ObservableValue(null)) const [validationError] = useObservable('validationErrorValue', validationErrorObservable) - // Fetch user on mount - useDisposable('fetchUser', () => { - const fetchUser = async () => { - try { - const { result } = await apiClient.call({ - method: 'GET', - action: '/users/:id', - url: { id: username }, - query: {}, - }) - userStateObservable.setValue({ status: 'loaded', user: result }) - roleChangeObservable.setValue({ - originalRoles: [...result.roles], - currentRoles: [...result.roles], - }) - } catch (error) { - userStateObservable.setValue({ - status: 'error', - message: error instanceof Error ? error.message : 'Failed to load user', - }) - } - } - void fetchUser() - return { [Symbol.dispose]: () => {} } - }) + // Initialize role change state when user is loaded + if (userState.status === 'loaded' && !roleChange) { + roleChangeObservable.setValue({ + originalRoles: [...userState.value.roles], + currentRoles: [...userState.value.roles], + }) + } const navigateBack = () => { window.history.pushState({}, '', '/app-settings/users') @@ -82,6 +58,11 @@ export const UserDetailsPage = Shade({ }) } + const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message + return 'Failed to load user' + } + const addRole = (roleName: Roles[number]) => { if (!roleChange) return if (roleChange.currentRoles.includes(roleName)) return @@ -142,14 +123,9 @@ export const UserDetailsPage = Shade({ validationErrorObservable.setValue(null) try { - await apiClient.call({ - method: 'PATCH', - action: '/users/:id', - url: { id: username }, - body: { - username: userState.user.username, - roles: roleChange.currentRoles, - }, + await usersService.updateUser(username, { + username: userState.value.username, + roles: roleChange.currentRoles, }) notyService.emit('onNotyAdded', { @@ -209,15 +185,15 @@ export const UserDetailsPage = Shade({

User Details

- {userState.status === 'loading' && ( + {(userState.status === 'loading' || userState.status === 'uninitialized') && (

Loading user...

)} - {userState.status === 'error' && ( + {userState.status === 'failed' && ( -

Error: {userState.message}

+

Error: {getErrorMessage(userState.error)}

@@ -233,13 +209,13 @@ export const UserDetailsPage = Shade({
Username: - {userState.user.username} + {userState.value.username} Created: - {formatDate(userState.user.createdAt)} + {formatDate(userState.value.createdAt)} Last Updated: - {formatDate(userState.user.updatedAt)} + {formatDate(userState.value.updatedAt)}
diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx index 3f5d6fa4..8a24fa57 100644 --- a/frontend/src/pages/admin/user-list.tsx +++ b/frontend/src/pages/admin/user-list.tsx @@ -1,46 +1,17 @@ import { createComponent, LocationService, Shade } from '@furystack/shades' import { Button, Paper } from '@furystack/shades-common-components' -import { ObservableValue } from '@furystack/utils' -import type { User } from 'common' import { RoleTag } from '../../components/role-tag/index.js' -import { IdentityApiClient } from '../../services/api-clients/identity-api-client.js' +import { UsersService } from '../../services/users-service.js' type UserListPageProps = Record -type UsersState = - | { status: 'loading' } - | { status: 'loaded'; users: User[] } - | { status: 'error'; message: string } - export const UserListPage = Shade({ shadowDomName: 'user-list-page', - render: ({ injector, useObservable, useDisposable }) => { - const apiClient = injector.getInstance(IdentityApiClient) + render: ({ injector, useObservable }) => { + const usersService = injector.getInstance(UsersService) const locationService = injector.getInstance(LocationService) - const usersStateObservable = useDisposable('usersState', () => new ObservableValue({ status: 'loading' })) - const [usersState] = useObservable('usersStateValue', usersStateObservable) - - // Fetch users on mount - useDisposable('fetchUsers', () => { - const fetchUsers = async () => { - try { - const { result } = await apiClient.call({ - method: 'GET', - action: '/users', - query: {}, - }) - usersStateObservable.setValue({ status: 'loaded', users: result.entries }) - } catch (error) { - usersStateObservable.setValue({ - status: 'error', - message: error instanceof Error ? error.message : 'Failed to load users', - }) - } - } - void fetchUsers() - return { [Symbol.dispose]: () => {} } - }) + const [usersState] = useObservable('users', usersService.findUsersAsObservable({})) const navigateToUser = (username: string) => { window.history.pushState({}, '', `/app-settings/users/${encodeURIComponent(username)}`) @@ -57,6 +28,16 @@ export const UserListPage = Shade({ }) } + const handleRetry = () => { + usersService.userQueryCache.flushAll() + usersService.findUsers({}) + } + + const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message + return 'Failed to load users' + } + return (

👥 Users

@@ -64,33 +45,16 @@ export const UserListPage = Shade({ Manage user accounts and their roles.

- {usersState.status === 'loading' && ( + {(usersState.status === 'loading' || usersState.status === 'uninitialized') && (

Loading users...

)} - {usersState.status === 'error' && ( + {usersState.status === 'failed' && ( -

Error: {usersState.message}

-
@@ -155,7 +119,7 @@ export const UserListPage = Shade({ - {usersState.users.map((user) => ( + {usersState.value.entries.map((user) => ( ({ ))} - {usersState.users.length === 0 && ( + {usersState.value.entries.length === 0 && ( { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users/:id', + url: { id: username }, + query: {}, + }) + return result + }, + }) + + public userQueryCache = new Cache({ + capacity: 10, + load: async (findOptions: FindOptions>) => { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users', + query: { + findOptions, + }, + }) + + result.entries.forEach((entry) => { + this.userCache.setExplicitValue({ + loadArgs: [entry.username], + value: { status: 'loaded', value: entry, updatedAt: new Date() }, + }) + }) + + return result + }, + }) + + public getUser = this.userCache.get.bind(this.userCache) + + public getUserAsObservable = this.userCache.getObservable.bind(this.userCache) + + public findUsers = this.userQueryCache.get.bind(this.userQueryCache) + + public findUsersAsObservable = this.userQueryCache.getObservable.bind(this.userQueryCache) + + public updateUser = async (username: string, body: { username: string; roles: Roles }) => { + const { result } = await this.identityApiClient.call({ + method: 'PATCH', + action: '/users/:id', + url: { id: username }, + body, + }) + this.userCache.setObsolete(username) + this.userQueryCache.flushAll() + return result + } + + public deleteUser = async (username: string) => { + await this.identityApiClient.call({ + method: 'DELETE', + action: '/users/:id', + url: { id: username }, + }) + this.userCache.remove(username) + this.userQueryCache.flushAll() + } +} From 97b3c9270e14a062c9d12c20f359573f59fc2790 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:12:50 +0100 Subject: [PATCH 03/32] improvements --- frontend/src/pages/admin/app-settings.tsx | 4 +- frontend/src/pages/admin/user-details.tsx | 93 +++++++++++++---------- frontend/src/pages/admin/user-list.tsx | 2 +- frontend/src/services/users-service.ts | 5 +- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/frontend/src/pages/admin/app-settings.tsx b/frontend/src/pages/admin/app-settings.tsx index 9a47c982..e5a3a5bb 100644 --- a/frontend/src/pages/admin/app-settings.tsx +++ b/frontend/src/pages/admin/app-settings.tsx @@ -49,11 +49,11 @@ const settingsRoutes = [ }, { url: '/app-settings/users/:username', - component: () => ( + component: ({ match }) => ( { const { UserDetailsPage } = await import('./user-details.js') - return + return }} /> ), diff --git a/frontend/src/pages/admin/user-details.tsx b/frontend/src/pages/admin/user-details.tsx index e5c3baf9..f93e08c2 100644 --- a/frontend/src/pages/admin/user-details.tsx +++ b/frontend/src/pages/admin/user-details.tsx @@ -6,7 +6,9 @@ import { getAllRoleDefinitions } from 'common' import { RoleTag } from '../../components/role-tag/index.js' import { UsersService } from '../../services/users-service.js' -type UserDetailsPageProps = Record +type UserDetailsPageProps = { + username: string +} type RoleChange = { originalRoles: Roles @@ -15,14 +17,12 @@ type RoleChange = { export const UserDetailsPage = Shade({ shadowDomName: 'user-details-page', - render: ({ injector, useObservable, useDisposable }) => { + render: ({ props, injector, useObservable, useDisposable }) => { const usersService = injector.getInstance(UsersService) const locationService = injector.getInstance(LocationService) const notyService = injector.getInstance(NotyService) - // Extract username from URL - const pathParts = window.location.pathname.split('/') - const username = decodeURIComponent(pathParts[pathParts.length - 1]) + const { username } = props const [userState] = useObservable('user', usersService.getUserAsObservable(username)) @@ -64,37 +64,45 @@ export const UserDetailsPage = Shade({ } const addRole = (roleName: Roles[number]) => { - if (!roleChange) return - if (roleChange.currentRoles.includes(roleName)) return + if (userState.status !== 'loaded') return + const current = roleChange?.currentRoles ?? userState.value.roles + const original = roleChange?.originalRoles ?? userState.value.roles + if (current.includes(roleName)) return roleChangeObservable.setValue({ - ...roleChange, - currentRoles: [...roleChange.currentRoles, roleName], + originalRoles: [...original], + currentRoles: [...current, roleName], }) validationErrorObservable.setValue(null) } const removeRole = (roleName: Roles[number]) => { - if (!roleChange) return + if (userState.status !== 'loaded') return + const current = roleChange?.currentRoles ?? userState.value.roles + const original = roleChange?.originalRoles ?? userState.value.roles roleChangeObservable.setValue({ - ...roleChange, - currentRoles: roleChange.currentRoles.filter((r) => r !== roleName), + originalRoles: [...original], + currentRoles: current.filter((r) => r !== roleName), }) } const restoreRole = (roleName: Roles[number]) => { - if (!roleChange) return - if (roleChange.currentRoles.includes(roleName)) return + if (userState.status !== 'loaded') return + const current = roleChange?.currentRoles ?? userState.value.roles + const original = roleChange?.originalRoles ?? userState.value.roles + if (current.includes(roleName)) return roleChangeObservable.setValue({ - ...roleChange, - currentRoles: [...roleChange.currentRoles, roleName], + originalRoles: [...original], + currentRoles: [...current, roleName], }) validationErrorObservable.setValue(null) } const getRoleVariant = (roleName: Roles[number]): 'default' | 'added' | 'removed' => { - if (!roleChange) return 'default' - const isInOriginal = roleChange.originalRoles.includes(roleName) - const isInCurrent = roleChange.currentRoles.includes(roleName) + if (userState.status !== 'loaded') return 'default' + const original = roleChange?.originalRoles ?? userState.value.roles + const current = roleChange?.currentRoles ?? userState.value.roles + const isInOriginal = original.includes(roleName) + const isInCurrent = current.includes(roleName) if (isInOriginal && isInCurrent) return 'default' if (!isInOriginal && isInCurrent) return 'added' @@ -103,18 +111,22 @@ export const UserDetailsPage = Shade({ } const hasChanges = () => { - if (!roleChange) return false - const originalSorted = [...roleChange.originalRoles].sort() - const currentSorted = [...roleChange.currentRoles].sort() + if (userState.status !== 'loaded') return false + const original = roleChange?.originalRoles ?? userState.value.roles + const current = roleChange?.currentRoles ?? userState.value.roles + const originalSorted = [...original].sort() + const currentSorted = [...current].sort() if (originalSorted.length !== currentSorted.length) return true return originalSorted.some((role, idx) => role !== currentSorted[idx]) } const handleSave = async () => { - if (!roleChange || userState.status !== 'loaded') return + if (userState.status !== 'loaded') return + + const current = roleChange?.currentRoles ?? userState.value.roles // Validation - if (roleChange.currentRoles.length === 0) { + if (current.length === 0) { validationErrorObservable.setValue('User must have at least one role') return } @@ -125,7 +137,7 @@ export const UserDetailsPage = Shade({ try { await usersService.updateUser(username, { username: userState.value.username, - roles: roleChange.currentRoles, + roles: current, }) notyService.emit('onNotyAdded', { @@ -136,8 +148,8 @@ export const UserDetailsPage = Shade({ // Update the original roles to reflect saved state roleChangeObservable.setValue({ - originalRoles: [...roleChange.currentRoles], - currentRoles: [...roleChange.currentRoles], + originalRoles: [...current], + currentRoles: [...current], }) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to save user' @@ -162,19 +174,20 @@ export const UserDetailsPage = Shade({ } const allRoles = getAllRoleDefinitions() - const availableRolesToAdd = roleChange - ? allRoles.filter((role) => !roleChange.currentRoles.includes(role.name)) - : [] + + // Get current roles from roleChange if available, otherwise from userState + const currentRoles = roleChange?.currentRoles ?? (userState.status === 'loaded' ? userState.value.roles : []) + const originalRoles = roleChange?.originalRoles ?? (userState.status === 'loaded' ? userState.value.roles : []) + + const availableRolesToAdd = allRoles.filter((role) => !currentRoles.includes(role.name)) // Get all roles to display (current + removed) - const rolesToDisplay = roleChange - ? [ - ...new Set([ - ...roleChange.originalRoles, // Include original roles (may be removed) - ...roleChange.currentRoles, // Include current roles (may be added) - ]), - ] - : [] + const rolesToDisplay = [ + ...new Set([ + ...originalRoles, // Include original roles (may be removed) + ...currentRoles, // Include current roles (may be added) + ]), + ] return (
@@ -185,7 +198,7 @@ export const UserDetailsPage = Shade({

User Details

- {(userState.status === 'loading' || userState.status === 'uninitialized') && ( + {(userState.status === 'loading' || userState.status === 'uninitialized' || userState.status === 'obsolete') && (

Loading user...

@@ -200,7 +213,7 @@ export const UserDetailsPage = Shade({ )} - {userState.status === 'loaded' && roleChange && ( + {userState.status === 'loaded' && ( <>

diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx index 8a24fa57..bf385097 100644 --- a/frontend/src/pages/admin/user-list.tsx +++ b/frontend/src/pages/admin/user-list.tsx @@ -45,7 +45,7 @@ export const UserListPage = Shade({ Manage user accounts and their roles.

- {(usersState.status === 'loading' || usersState.status === 'uninitialized') && ( + {(usersState.status === 'loading' || usersState.status === 'uninitialized' || usersState.status === 'obsolete') && (

Loading users...

diff --git a/frontend/src/services/users-service.ts b/frontend/src/services/users-service.ts index 7ef3f885..0c366811 100644 --- a/frontend/src/services/users-service.ts +++ b/frontend/src/services/users-service.ts @@ -59,7 +59,10 @@ export class UsersService { url: { id: username }, body, }) - this.userCache.setObsolete(username) + this.userCache.setExplicitValue({ + loadArgs: [username], + value: { status: 'loaded', value: result, updatedAt: new Date() }, + }) this.userQueryCache.flushAll() return result } From 9a44672136ce2b6e98c9c27ee63ec9e5716c41fd Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:15:41 +0100 Subject: [PATCH 04/32] added tests --- common/src/models/identity/roles.spec.ts | 155 +++++++ e2e/helpers.ts | 14 + frontend/src/pages/admin/user-details.tsx | 4 +- frontend/src/pages/admin/user-list.tsx | 4 +- frontend/src/services/users-service.spec.ts | 423 ++++++++++++++++++++ 5 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 common/src/models/identity/roles.spec.ts create mode 100644 frontend/src/services/users-service.spec.ts diff --git a/common/src/models/identity/roles.spec.ts b/common/src/models/identity/roles.spec.ts new file mode 100644 index 00000000..74f273dc --- /dev/null +++ b/common/src/models/identity/roles.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { AVAILABLE_ROLES, getAllRoleDefinitions, getRoleDefinition } from './roles.js' +import type { Roles } from './user.js' + +describe('roles', () => { + describe('AVAILABLE_ROLES', () => { + it('should contain all 4 roles', () => { + const roleNames = Object.keys(AVAILABLE_ROLES) + + expect(roleNames).toHaveLength(4) + expect(roleNames).toContain('admin') + expect(roleNames).toContain('media-manager') + expect(roleNames).toContain('viewer') + expect(roleNames).toContain('iot-manager') + }) + + it('should have displayName for each role', () => { + for (const role of Object.values(AVAILABLE_ROLES)) { + expect(role.displayName).toBeDefined() + expect(typeof role.displayName).toBe('string') + expect(role.displayName.length).toBeGreaterThan(0) + } + }) + + it('should have description for each role', () => { + for (const role of Object.values(AVAILABLE_ROLES)) { + expect(role.description).toBeDefined() + expect(typeof role.description).toBe('string') + expect(role.description.length).toBeGreaterThan(0) + } + }) + + it('should have correct metadata for admin role', () => { + expect(AVAILABLE_ROLES.admin).toEqual({ + displayName: 'Application Admin', + description: 'Full system access including user management and all settings', + }) + }) + + it('should have correct metadata for media-manager role', () => { + expect(AVAILABLE_ROLES['media-manager']).toEqual({ + displayName: 'Media Manager', + description: 'Can manage movies, series, and encoding tasks', + }) + }) + + it('should have correct metadata for viewer role', () => { + expect(AVAILABLE_ROLES.viewer).toEqual({ + displayName: 'Viewer', + description: 'Can browse and watch media content', + }) + }) + + it('should have correct metadata for iot-manager role', () => { + expect(AVAILABLE_ROLES['iot-manager']).toEqual({ + displayName: 'IoT Manager', + description: 'Can manage IoT devices and their settings', + }) + }) + }) + + describe('getRoleDefinition', () => { + it('should return correct definition for admin role', () => { + const definition = getRoleDefinition('admin') + + expect(definition).toEqual({ + name: 'admin', + displayName: 'Application Admin', + description: 'Full system access including user management and all settings', + }) + }) + + it('should return correct definition for media-manager role', () => { + const definition = getRoleDefinition('media-manager') + + expect(definition).toEqual({ + name: 'media-manager', + displayName: 'Media Manager', + description: 'Can manage movies, series, and encoding tasks', + }) + }) + + it('should return correct definition for viewer role', () => { + const definition = getRoleDefinition('viewer') + + expect(definition).toEqual({ + name: 'viewer', + displayName: 'Viewer', + description: 'Can browse and watch media content', + }) + }) + + it('should return correct definition for iot-manager role', () => { + const definition = getRoleDefinition('iot-manager') + + expect(definition).toEqual({ + name: 'iot-manager', + displayName: 'IoT Manager', + description: 'Can manage IoT devices and their settings', + }) + }) + + it('should include name, displayName, and description for each role', () => { + const roles: Array = ['admin', 'media-manager', 'viewer', 'iot-manager'] + + for (const roleName of roles) { + const definition = getRoleDefinition(roleName) + + expect(definition.name).toBe(roleName) + expect(definition.displayName).toBeDefined() + expect(definition.description).toBeDefined() + } + }) + }) + + describe('getAllRoleDefinitions', () => { + it('should return array of all 4 role definitions', () => { + const definitions = getAllRoleDefinitions() + + expect(definitions).toHaveLength(4) + }) + + it('should include all role names', () => { + const definitions = getAllRoleDefinitions() + const names = definitions.map((d) => d.name) + + expect(names).toContain('admin') + expect(names).toContain('media-manager') + expect(names).toContain('viewer') + expect(names).toContain('iot-manager') + }) + + it('should have all required properties for each definition', () => { + const definitions = getAllRoleDefinitions() + + for (const definition of definitions) { + expect(definition).toHaveProperty('name') + expect(definition).toHaveProperty('displayName') + expect(definition).toHaveProperty('description') + expect(typeof definition.name).toBe('string') + expect(typeof definition.displayName).toBe('string') + expect(typeof definition.description).toBe('string') + } + }) + + it('should return definitions that match getRoleDefinition for each role', () => { + const definitions = getAllRoleDefinitions() + + for (const definition of definitions) { + const singleDefinition = getRoleDefinition(definition.name) + expect(definition).toEqual(singleDefinition) + } + }) + }) +}) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 53133b9a..17f4c873 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -84,6 +84,20 @@ export const navigateToAppSettings = async (page: Page) => { */ export const navigateToAdminSettings = navigateToAppSettings +export const navigateToUsersSettings = async (page: Page) => { + await navigateToAppSettings(page) + + // Click on Users menu item in the Identity section + await page.getByText('Users').click() + + // Wait for URL to change to users list + await page.waitForURL(/\/app-settings\/users/) + + // Verify we're on the users list page + const usersListPage = page.locator('user-list-page') + await expect(usersListPage).toBeVisible({ timeout: 10000 }) +} + export const uploadFile = async (page: Page, filePath: string, mime: string) => { const fileContent = await readFile(filePath, { encoding: 'utf-8' }) const fileName = basename(filePath) diff --git a/frontend/src/pages/admin/user-details.tsx b/frontend/src/pages/admin/user-details.tsx index f93e08c2..92ce96c1 100644 --- a/frontend/src/pages/admin/user-details.tsx +++ b/frontend/src/pages/admin/user-details.tsx @@ -198,7 +198,9 @@ export const UserDetailsPage = Shade({

User Details

- {(userState.status === 'loading' || userState.status === 'uninitialized' || userState.status === 'obsolete') && ( + {(userState.status === 'loading' || + userState.status === 'uninitialized' || + userState.status === 'obsolete') && (

Loading user...

diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx index bf385097..63486b7c 100644 --- a/frontend/src/pages/admin/user-list.tsx +++ b/frontend/src/pages/admin/user-list.tsx @@ -45,7 +45,9 @@ export const UserListPage = Shade({ Manage user accounts and their roles.

- {(usersState.status === 'loading' || usersState.status === 'uninitialized' || usersState.status === 'obsolete') && ( + {(usersState.status === 'loading' || + usersState.status === 'uninitialized' || + usersState.status === 'obsolete') && (

Loading users...

diff --git a/frontend/src/services/users-service.spec.ts b/frontend/src/services/users-service.spec.ts new file mode 100644 index 00000000..91a6ffac --- /dev/null +++ b/frontend/src/services/users-service.spec.ts @@ -0,0 +1,423 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import { describe, expect, it, vi } from 'vitest' +import { UsersService } from './users-service.js' +import { IdentityApiClient } from './api-clients/identity-api-client.js' +import type { User } from 'common' + +const createMockUser = (username = 'testuser@example.com', roles: User['roles'] = ['admin']): User => ({ + username, + roles, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}) + +describe('UsersService', () => { + const createTestInjector = (mockCall: ReturnType) => { + const injector = new Injector() + injector.setExplicitInstance( + { + call: mockCall, + } as unknown as IdentityApiClient, + IdentityApiClient, + ) + return injector + } + + describe('getUser', () => { + it('should fetch a user by username', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const result = await service.getUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledWith({ + method: 'GET', + action: '/users/:id', + url: { id: 'testuser@example.com' }, + query: {}, + }) + expect(result).toEqual(mockUser) + }) + }) + + it('should cache user results', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.getUser('testuser@example.com') + await service.getUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledTimes(1) + }) + }) + + it('should handle API errors (404)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('User not found')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect(service.getUser('nonexistent@example.com')).rejects.toThrow('User not found') + }) + }) + + it('should handle server errors (500)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('Internal server error')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect(service.getUser('testuser@example.com')).rejects.toThrow('Internal server error') + }) + }) + }) + + describe('getUserAsObservable', () => { + it('should return an observable for user', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const observable = service.getUserAsObservable('testuser@example.com') + + expect(observable).toBeDefined() + expect(observable.getValue().status).toBe('uninitialized') + }) + }) + + it('should share the same observable for the same username', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const observable1 = service.getUserAsObservable('testuser@example.com') + const observable2 = service.getUserAsObservable('testuser@example.com') + + expect(observable1).toBe(observable2) + }) + }) + }) + + describe('findUsers', () => { + it('should find users with query options', async () => { + const mockUsers = { + count: 2, + entries: [createMockUser('user1@example.com'), createMockUser('user2@example.com')], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const findOptions = { top: 10 } + const result = await service.findUsers(findOptions) + + expect(mockCall).toHaveBeenCalledWith({ + method: 'GET', + action: '/users', + query: { + findOptions, + }, + }) + expect(result).toEqual(mockUsers) + }) + }) + + it('should cache query results', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const findOptions = { top: 10 } + await service.findUsers(findOptions) + await service.findUsers(findOptions) + + expect(mockCall).toHaveBeenCalledTimes(1) + }) + }) + + it('should pre-populate individual user cache from query results', async () => { + const user1 = createMockUser('user1@example.com') + const user2 = createMockUser('user2@example.com') + const mockUsers = { + count: 2, + entries: [user1, user2], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.findUsers({ top: 10 }) + + const result = await service.getUser('user1@example.com') + + expect(mockCall).toHaveBeenCalledTimes(1) + expect(result).toEqual(user1) + }) + }) + }) + + describe('findUsersAsObservable', () => { + it('should return an observable for user query', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const observable = service.findUsersAsObservable({ top: 10 }) + + expect(observable).toBeDefined() + expect(observable.getValue().status).toBe('uninitialized') + }) + }) + + it('should share the same observable for the same query options', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const findOptions = { top: 10 } + const observable1 = service.findUsersAsObservable(findOptions) + const observable2 = service.findUsersAsObservable(findOptions) + + expect(observable1).toBe(observable2) + }) + }) + }) + + describe('updateUser', () => { + it('should update user roles', async () => { + const updatedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const mockCall = vi.fn().mockResolvedValue({ result: updatedUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const body = { + username: 'testuser@example.com', + roles: ['admin', 'viewer'] as User['roles'], + } + const result = await service.updateUser('testuser@example.com', body) + + expect(mockCall).toHaveBeenCalledWith({ + method: 'PATCH', + action: '/users/:id', + url: { id: 'testuser@example.com' }, + body, + }) + expect(result).toEqual(updatedUser) + }) + }) + + it('should update cache with new user data after update', async () => { + const originalUser = createMockUser('testuser@example.com', ['admin']) + const updatedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: originalUser }) + .mockResolvedValueOnce({ result: updatedUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.getUser('testuser@example.com') + + const body = { + username: 'testuser@example.com', + roles: ['admin', 'viewer'] as User['roles'], + } + await service.updateUser('testuser@example.com', body) + + const result = await service.getUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledTimes(2) + expect(result).toEqual(updatedUser) + }) + }) + + it('should flush query cache after update', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser('testuser@example.com', ['admin'])], + } + const updatedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const updatedMockUsers = { + count: 1, + entries: [updatedUser], + } + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: mockUsers }) + .mockResolvedValueOnce({ result: updatedUser }) + .mockResolvedValueOnce({ result: updatedMockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.findUsers({ top: 10 }) + + await service.updateUser('testuser@example.com', { + username: 'testuser@example.com', + roles: ['admin', 'viewer'], + }) + + await service.findUsers({ top: 10 }) + + expect(mockCall).toHaveBeenCalledTimes(3) + }) + }) + + it('should handle validation errors (400)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('Invalid user data')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect( + service.updateUser('testuser@example.com', { + username: 'testuser@example.com', + roles: [], + }), + ).rejects.toThrow('Invalid user data') + }) + }) + + it('should handle server errors (500)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('Internal server error')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect( + service.updateUser('testuser@example.com', { + username: 'testuser@example.com', + roles: ['admin'], + }), + ).rejects.toThrow('Internal server error') + }) + }) + }) + + describe('deleteUser', () => { + it('should delete a user', async () => { + const mockCall = vi.fn().mockResolvedValue({}) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.deleteUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledWith({ + method: 'DELETE', + action: '/users/:id', + url: { id: 'testuser@example.com' }, + }) + }) + }) + + it('should remove user from cache after delete', async () => { + const mockUser = createMockUser() + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: mockUser }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.getUser('testuser@example.com') + + await service.deleteUser('testuser@example.com') + + await service.getUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledTimes(3) + }) + }) + + it('should flush query cache after delete', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const emptyMockUsers = { + count: 0, + entries: [], + } + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: mockUsers }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ result: emptyMockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.findUsers({ top: 10 }) + + await service.deleteUser('testuser@example.com') + + await service.findUsers({ top: 10 }) + + expect(mockCall).toHaveBeenCalledTimes(3) + }) + }) + + it('should handle not found errors (404)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('User not found')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect(service.deleteUser('nonexistent@example.com')).rejects.toThrow('User not found') + }) + }) + }) +}) From c46e372875eadc2be5c5b6dc6f5647af316acbf2 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:16:15 +0100 Subject: [PATCH 05/32] added e2e test --- e2e/user-management.spec.ts | 448 ++++++++++++++++++++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 e2e/user-management.spec.ts diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts new file mode 100644 index 00000000..11c456bb --- /dev/null +++ b/e2e/user-management.spec.ts @@ -0,0 +1,448 @@ +import { expect, test } from '@playwright/test' +import { assertAndDismissNoty, login, navigateToAppSettings, navigateToUsersSettings } from './helpers.js' + +test.describe('User Management Navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await login(page) + }) + + test('should display Users menu item in Identity section', async ({ page }) => { + await navigateToAppSettings(page) + await page.waitForSelector('text=OMDB Settings') + + // Verify Users menu item is visible in the Identity section + const usersMenuItem = page.getByText('Users') + await expect(usersMenuItem).toBeVisible() + }) + + test('should navigate to users list page', async ({ page }) => { + await navigateToUsersSettings(page) + + // Verify we're on the users list page + await expect(page).toHaveURL(/\/app-settings\/users/) + await expect(page.locator('user-list-page')).toBeVisible() + }) +}) + +test.describe('User List Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await login(page) + await navigateToUsersSettings(page) + }) + + test('should display users page heading', async ({ page }) => { + // Verify heading is visible + await expect(page.locator('text=👥 Users').first()).toBeVisible() + await expect(page.getByText('Manage user accounts and their roles.')).toBeVisible() + }) + + test('should display users table with columns', async ({ page }) => { + const usersPage = page.locator('user-list-page') + + // Verify table headers + await expect(usersPage.locator('th', { hasText: 'Username' })).toBeVisible() + await expect(usersPage.locator('th', { hasText: 'Roles' })).toBeVisible() + await expect(usersPage.locator('th', { hasText: 'Created' })).toBeVisible() + await expect(usersPage.locator('th', { hasText: 'Actions' })).toBeVisible() + }) + + test('should display at least one user in the table', async ({ page }) => { + const usersPage = page.locator('user-list-page') + + // Wait for table to load and verify at least one user row exists + const tableBody = usersPage.locator('tbody') + const rows = tableBody.locator('tr') + await expect(rows.first()).toBeVisible() + }) + + test('should display user roles with role tags', async ({ page }) => { + const usersPage = page.locator('user-list-page') + + // Verify role tags are rendered + const roleTag = usersPage.locator('role-tag').first() + await expect(roleTag).toBeVisible() + }) + + test('should navigate to user details when clicking Edit button', async ({ page }) => { + const usersPage = page.locator('user-list-page') + + // Click Edit button on first user + const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() + await editButton.click() + + // Verify navigation to user details + await expect(page).toHaveURL(/\/app-settings\/users\//) + await expect(page.locator('user-details-page')).toBeVisible() + }) + + test('should navigate to user details when clicking table row', async ({ page }) => { + const usersPage = page.locator('user-list-page') + + // Get the first row and click it + const firstRow = usersPage.locator('tbody tr').first() + await firstRow.click() + + // Verify navigation to user details + await expect(page).toHaveURL(/\/app-settings\/users\//) + await expect(page.locator('user-details-page')).toBeVisible() + }) +}) + +test.describe('User Details Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await login(page) + await navigateToUsersSettings(page) + + // Navigate to first user's details + const usersPage = page.locator('user-list-page') + const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() + await editButton.click() + await expect(page.locator('user-details-page')).toBeVisible() + }) + + test('should display user information', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + + // Verify user information section + await expect(detailsPage.getByText('User Information')).toBeVisible() + await expect(detailsPage.getByText('Username:')).toBeVisible() + await expect(detailsPage.getByText('Created:')).toBeVisible() + await expect(detailsPage.getByText('Last Updated:')).toBeVisible() + }) + + test('should display roles section', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + + // Verify roles section is visible + await expect(detailsPage.getByText('Roles').first()).toBeVisible() + }) + + test('should display current user roles with role tags', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + + // Verify at least one role tag is displayed + const roleTag = detailsPage.locator('role-tag').first() + await expect(roleTag).toBeVisible() + }) + + test('should navigate back to user list when clicking Back button', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + + // Click back button + const backButton = detailsPage.getByRole('button', { name: /back/i }) + await backButton.click() + + // Verify navigation back to user list + await expect(page).toHaveURL(/\/app-settings\/users$/) + await expect(page.locator('user-list-page')).toBeVisible() + }) + + test('should display Add Role dropdown', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + + // Verify dropdown is visible + await expect(detailsPage.getByText('Add Role:')).toBeVisible() + const roleSelect = detailsPage.locator('select') + await expect(roleSelect).toBeVisible() + }) + + test('should have Save and Cancel buttons', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + + // Verify buttons exist + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) + + await expect(saveButton).toBeVisible() + await expect(cancelButton).toBeVisible() + }) + + test('should have Save button disabled when no changes are made', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + + // Verify Save button is disabled initially + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + await expect(saveButton).toBeDisabled() + }) +}) + +test.describe('Role Editing', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await login(page) + await navigateToUsersSettings(page) + + // Navigate to first user's details + const usersPage = page.locator('user-list-page') + const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() + await editButton.click() + await expect(page.locator('user-details-page')).toBeVisible() + }) + + test('should add a role using the dropdown', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + const roleSelect = detailsPage.locator('select') + + // Get the number of options (available roles to add) + const optionsCount = await roleSelect.locator('option').count() + + // If there are available roles to add (more than just the placeholder) + if (optionsCount > 1) { + // Select the first available role option (not the placeholder) + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + + if (secondOption) { + await roleSelect.selectOption(secondOption) + + // Verify a new role tag appears + const roleTags = detailsPage.locator('role-tag') + await expect(roleTags.first()).toBeVisible() + } + } + }) + + test('should enable Save button when changes are made', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + const roleSelect = detailsPage.locator('select') + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + + // Initially Save button should be disabled + await expect(saveButton).toBeDisabled() + + // Check if there are available roles to add + const optionsCount = await roleSelect.locator('option').count() + + if (optionsCount > 1) { + // Add a role + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + + if (secondOption) { + await roleSelect.selectOption(secondOption) + + // Save button should now be enabled + await expect(saveButton).toBeEnabled() + } + } + }) + + test('should remove a role by clicking the remove button', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + + // Get current role tags count + const roleTags = detailsPage.locator('role-tag') + const initialCount = await roleTags.count() + + if (initialCount > 0) { + // Find and click the remove button on the first role tag + const firstRoleTag = roleTags.first() + const removeButton = firstRoleTag.locator('button', { hasText: '×' }) + + // Check if remove button exists (some roles may not have it) + if ((await removeButton.count()) > 0) { + await removeButton.click() + + // Save button should be enabled after removing a role + await expect(saveButton).toBeEnabled() + } + } + }) + + test('should cancel changes and restore original roles', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + const roleSelect = detailsPage.locator('select') + const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + + // Check if there are available roles to add + const optionsCount = await roleSelect.locator('option').count() + + if (optionsCount > 1) { + // Add a role to make changes + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + + if (secondOption) { + await roleSelect.selectOption(secondOption) + + // Verify Save is now enabled + await expect(saveButton).toBeEnabled() + + // Click Cancel + await cancelButton.click() + + // Save button should be disabled again + await expect(saveButton).toBeDisabled() + } + } + }) +}) + +test.describe('Save Role Changes', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await login(page) + await navigateToUsersSettings(page) + }) + + test('should save role changes successfully', async ({ page }) => { + // Navigate to user details + const usersPage = page.locator('user-list-page') + const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() + await editButton.click() + await expect(page.locator('user-details-page')).toBeVisible() + + const detailsPage = page.locator('user-details-page') + const roleSelect = detailsPage.locator('select') + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + + // Check if there are available roles to add + const optionsCount = await roleSelect.locator('option').count() + + if (optionsCount > 1) { + // Add a role + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + + if (secondOption) { + await roleSelect.selectOption(secondOption) + + // Save changes + await saveButton.click() + + // Verify success notification + await assertAndDismissNoty(page, 'User roles updated successfully') + + // Save button should be disabled after saving + await expect(saveButton).toBeDisabled() + } + } + }) + + test('should show validation error when removing all roles', async ({ page }) => { + // Navigate to user details + const usersPage = page.locator('user-list-page') + const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() + await editButton.click() + await expect(page.locator('user-details-page')).toBeVisible() + + const detailsPage = page.locator('user-details-page') + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + + // Remove all roles one by one + const roleTags = detailsPage.locator('role-tag') + let roleCount = await roleTags.count() + + while (roleCount > 0) { + const roleTag = roleTags.first() + const removeButton = roleTag.locator('button', { hasText: '×' }) + + if ((await removeButton.count()) > 0) { + await removeButton.click() + } else { + break + } + + roleCount = await roleTags.count() + } + + // Try to save + if (await saveButton.isEnabled()) { + await saveButton.click() + + // Should show validation error + await expect(detailsPage.getByText('User must have at least one role')).toBeVisible() + } + }) +}) + +test.describe('Role Tag Display Variants', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await login(page) + await navigateToUsersSettings(page) + + // Navigate to first user's details + const usersPage = page.locator('user-list-page') + const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() + await editButton.click() + await expect(page.locator('user-details-page')).toBeVisible() + }) + + test('should show role with remove button for existing roles', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + const roleTags = detailsPage.locator('role-tag') + + // Check if there are any role tags + const count = await roleTags.count() + + if (count > 0) { + const firstRoleTag = roleTags.first() + + // Verify the role tag is visible + await expect(firstRoleTag).toBeVisible() + + // Verify the role displays text (the displayName) + const text = await firstRoleTag.textContent() + expect(text?.length).toBeGreaterThan(0) + } + }) + + test('should show restore button for removed roles', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + const roleTags = detailsPage.locator('role-tag') + + // Get initial role count + const initialCount = await roleTags.count() + + if (initialCount > 0) { + // Find a role with remove button + const firstRoleTag = roleTags.first() + const removeButton = firstRoleTag.locator('button', { hasText: '×' }) + + if ((await removeButton.count()) > 0) { + await removeButton.click() + + // Find the role tag that now has restore button (↩) + const removedRoleTag = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }) + + // Should have a restore button + await expect(removedRoleTag.first()).toBeVisible() + } + } + }) + + test('should restore a removed role by clicking restore button', async ({ page }) => { + const detailsPage = page.locator('user-details-page') + const roleTags = detailsPage.locator('role-tag') + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + + // Get initial role count + const initialCount = await roleTags.count() + + if (initialCount > 0) { + // Find a role with remove button and remove it + const firstRoleTag = roleTags.first() + const removeButton = firstRoleTag.locator('button', { hasText: '×' }) + + if ((await removeButton.count()) > 0) { + await removeButton.click() + + // Save should be enabled + await expect(saveButton).toBeEnabled() + + // Find and click restore button + const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() + await restoreButton.click() + + // Save should be disabled again (no net changes) + await expect(saveButton).toBeDisabled() + } + } + }) +}) From 1730f8fc999f69296acfcb5124b9417589330413 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:21:55 +0100 Subject: [PATCH 06/32] ts and lint fixes --- frontend/src/pages/admin/app-settings.tsx | 3 ++- frontend/src/pages/admin/user-list.tsx | 2 +- frontend/src/services/users-service.ts | 5 +---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/admin/app-settings.tsx b/frontend/src/pages/admin/app-settings.tsx index e5a3a5bb..65f8e1f9 100644 --- a/frontend/src/pages/admin/app-settings.tsx +++ b/frontend/src/pages/admin/app-settings.tsx @@ -1,4 +1,5 @@ import { createComponent, LocationService, Router, Shade } from '@furystack/shades' +import type { MatchResult } from 'path-to-regexp' import { PiRatLazyLoad } from '../../components/pirat-lazy-load.js' import { SettingsMenuItem, SettingsMenuSection, SettingsSidebar } from '../../components/settings-sidebar/index.js' @@ -49,7 +50,7 @@ const settingsRoutes = [ }, { url: '/app-settings/users/:username', - component: ({ match }) => ( + component: ({ match }: { match: MatchResult<{ username: string }> }) => ( { const { UserDetailsPage } = await import('./user-details.js') diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx index 63486b7c..c04ee312 100644 --- a/frontend/src/pages/admin/user-list.tsx +++ b/frontend/src/pages/admin/user-list.tsx @@ -30,7 +30,7 @@ export const UserListPage = Shade({ const handleRetry = () => { usersService.userQueryCache.flushAll() - usersService.findUsers({}) + void usersService.findUsers({}) } const getErrorMessage = (error: unknown): string => { diff --git a/frontend/src/services/users-service.ts b/frontend/src/services/users-service.ts index 0c366811..7ef3f885 100644 --- a/frontend/src/services/users-service.ts +++ b/frontend/src/services/users-service.ts @@ -59,10 +59,7 @@ export class UsersService { url: { id: username }, body, }) - this.userCache.setExplicitValue({ - loadArgs: [username], - value: { status: 'loaded', value: result, updatedAt: new Date() }, - }) + this.userCache.setObsolete(username) this.userQueryCache.flushAll() return result } From ccadf9aebcf0ce58052fe2b3ae2ab131ef59d6cf Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:23:56 +0100 Subject: [PATCH 07/32] test fix --- frontend/src/services/users-service.spec.ts | 8 +++++--- frontend/src/services/users-service.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/services/users-service.spec.ts b/frontend/src/services/users-service.spec.ts index 91a6ffac..497a9894 100644 --- a/frontend/src/services/users-service.spec.ts +++ b/frontend/src/services/users-service.spec.ts @@ -247,13 +247,15 @@ describe('UsersService', () => { }) }) - it('should update cache with new user data after update', async () => { + it('should invalidate cache after update', async () => { const originalUser = createMockUser('testuser@example.com', ['admin']) const updatedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const refetchedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) const mockCall = vi .fn() .mockResolvedValueOnce({ result: originalUser }) .mockResolvedValueOnce({ result: updatedUser }) + .mockResolvedValueOnce({ result: refetchedUser }) const injector = createTestInjector(mockCall) await usingAsync(injector, async (i) => { @@ -269,8 +271,8 @@ describe('UsersService', () => { const result = await service.getUser('testuser@example.com') - expect(mockCall).toHaveBeenCalledTimes(2) - expect(result).toEqual(updatedUser) + expect(mockCall).toHaveBeenCalledTimes(3) + expect(result).toEqual(refetchedUser) }) }) diff --git a/frontend/src/services/users-service.ts b/frontend/src/services/users-service.ts index 7ef3f885..b99734ea 100644 --- a/frontend/src/services/users-service.ts +++ b/frontend/src/services/users-service.ts @@ -59,7 +59,7 @@ export class UsersService { url: { id: username }, body, }) - this.userCache.setObsolete(username) + this.userCache.remove(username) this.userQueryCache.flushAll() return result } From d4e0c67709445017ac6d0bbbbab208f7f4bde8c5 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:42:16 +0100 Subject: [PATCH 08/32] added / improved tests --- .../src/components/role-tag/index.spec.tsx | 264 +++++++ .../src/pages/admin/user-details.spec.tsx | 678 ++++++++++++++++++ frontend/src/pages/admin/user-list.spec.tsx | 369 ++++++++++ 3 files changed, 1311 insertions(+) create mode 100644 frontend/src/components/role-tag/index.spec.tsx create mode 100644 frontend/src/pages/admin/user-details.spec.tsx create mode 100644 frontend/src/pages/admin/user-list.spec.tsx diff --git a/frontend/src/components/role-tag/index.spec.tsx b/frontend/src/components/role-tag/index.spec.tsx new file mode 100644 index 00000000..7a7362ed --- /dev/null +++ b/frontend/src/components/role-tag/index.spec.tsx @@ -0,0 +1,264 @@ +import { Injector } from '@furystack/inject' +import { createComponent, initializeShadeRoot } from '@furystack/shades' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RoleTag } from './index.js' + +describe('RoleTag', () => { + beforeEach(() => { + document.body.innerHTML = '
' + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('rendering', () => { + it('should render with role display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag).toBeTruthy() + expect(roleTag?.textContent).toContain('Application Admin') + }) + + it('should render media-manager role with correct display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag?.textContent).toContain('Media Manager') + }) + + it('should render viewer role with correct display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag?.textContent).toContain('Viewer') + }) + + it('should render iot-manager role with correct display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag?.textContent).toContain('IoT Manager') + }) + + it('should have title attribute with role description', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const span = roleTag?.querySelector('span') + expect(span?.getAttribute('title')).toBe('Full system access including user management and all settings') + }) + }) + + describe('variants', () => { + it('should render default variant without remove or restore buttons when no handlers provided', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const buttons = roleTag?.querySelectorAll('button') + expect(buttons?.length).toBe(0) + }) + + it('should render default variant with remove button when onRemove is provided', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + expect(removeButton).toBeTruthy() + expect(removeButton?.textContent).toContain('×') + expect(removeButton?.getAttribute('title')).toBe('Remove role') + }) + + it('should render added variant with remove button', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + expect(removeButton).toBeTruthy() + expect(removeButton?.textContent).toContain('×') + }) + + it('should render removed variant with restore button', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRestore = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const restoreButton = roleTag?.querySelector('button') + expect(restoreButton).toBeTruthy() + expect(restoreButton?.textContent).toContain('↩') + expect(restoreButton?.getAttribute('title')).toBe('Restore role') + }) + + it('should not render remove button for removed variant', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + const onRestore = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const buttons = roleTag?.querySelectorAll('button') + // Should only have restore button, not remove button + expect(buttons?.length).toBe(1) + expect(buttons?.[0]?.textContent).toContain('↩') + }) + }) + + describe('interactions', () => { + it('should call onRemove when remove button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') as HTMLButtonElement + removeButton.click() + + expect(onRemove).toHaveBeenCalledTimes(1) + }) + + it('should call onRestore when restore button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRestore = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const restoreButton = roleTag?.querySelector('button') as HTMLButtonElement + restoreButton.click() + + expect(onRestore).toHaveBeenCalledTimes(1) + }) + + it('should stop event propagation when remove button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + const parentClick = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: ( +
+ +
+ ), + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') as HTMLButtonElement + removeButton.click() + + expect(onRemove).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should stop event propagation when restore button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRestore = vi.fn() + const parentClick = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: ( +
+ +
+ ), + }) + + const roleTag = document.querySelector('role-tag') + const restoreButton = roleTag?.querySelector('button') as HTMLButtonElement + restoreButton.click() + + expect(onRestore).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/pages/admin/user-details.spec.tsx b/frontend/src/pages/admin/user-details.spec.tsx new file mode 100644 index 00000000..9da9a771 --- /dev/null +++ b/frontend/src/pages/admin/user-details.spec.tsx @@ -0,0 +1,678 @@ +import { Injector } from '@furystack/inject' +import { createComponent, initializeShadeRoot, LocationService } from '@furystack/shades' +import { NotyService } from '@furystack/shades-common-components' +import { ObservableValue } from '@furystack/utils' +import type { User } from 'common' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { UsersService } from '../../services/users-service.js' +import { UserDetailsPage } from './user-details.js' + +type CacheState = + | { status: 'uninitialized' } + | { status: 'loading' } + | { status: 'obsolete'; value: T; updatedAt: Date } + | { status: 'loaded'; value: T; updatedAt: Date } + | { status: 'failed'; error: unknown; updatedAt: Date } + +const createMockUser = (username = 'testuser@example.com', roles: User['roles'] = ['admin']): User => ({ + username, + roles, + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-20T14:45:00.000Z', +}) + +describe('UserDetailsPage', () => { + let injector: Injector + let mockUsersService: { + getUserAsObservable: ReturnType + updateUser: ReturnType + } + let mockNotyService: { + emit: ReturnType + } + let userObservable: ObservableValue> + + beforeEach(() => { + document.body.innerHTML = '
' + + userObservable = new ObservableValue>({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin']), + updatedAt: new Date(), + }) + + mockUsersService = { + getUserAsObservable: vi.fn().mockReturnValue(userObservable), + updateUser: vi.fn().mockResolvedValue(createMockUser('testuser@example.com', ['admin', 'viewer'])), + } + + mockNotyService = { + emit: vi.fn(), + } + + injector = new Injector() + injector.setExplicitInstance(mockUsersService as unknown as UsersService, UsersService) + injector.setExplicitInstance(mockNotyService as unknown as NotyService, NotyService) + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('rendering', () => { + it('should render the user details page with header', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page).toBeTruthy() + expect(page?.textContent).toContain('User Details') + }) + + it('should display loading state', () => { + userObservable.setValue({ status: 'loading' }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Loading user...') + }) + + it('should display error state with go back button', () => { + userObservable.setValue({ + status: 'failed', + error: new Error('User not found'), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Error: User not found') + // Go Back button exists - Button component renders as button element + const buttons = page?.querySelectorAll('button') + // Should have Back button and Go Back button + expect(buttons?.length).toBeGreaterThanOrEqual(1) + }) + + it('should display fallback error message for non-Error objects', () => { + userObservable.setValue({ + status: 'failed', + error: 'string error', + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Failed to load user') + }) + }) + + describe('user information display', () => { + it('should display username', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Username:') + expect(page?.textContent).toContain('testuser@example.com') + }) + + it('should display created date', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Created:') + }) + + it('should display last updated date', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Last Updated:') + }) + }) + + describe('roles section', () => { + it('should display roles section header', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Roles') + }) + + it('should render role tags for user roles', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const roleTags = page?.querySelectorAll('role-tag') + + expect(roleTags?.length).toBe(1) + }) + + it('should render multiple role tags for user with multiple roles', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin', 'viewer', 'media-manager']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const roleTags = page?.querySelectorAll('role-tag') + + expect(roleTags?.length).toBe(3) + }) + + it('should display "No roles assigned" for user without roles', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', []), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('No roles assigned') + }) + }) + + describe('add role dropdown', () => { + it('should display Add Role dropdown', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Add Role:') + + const select = page?.querySelector('select') + expect(select).toBeTruthy() + }) + + it('should show available roles that user does not have', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') + const options = select?.querySelectorAll('option') + + // Should have placeholder + 3 available roles (viewer, media-manager, iot-manager) + expect(options?.length).toBe(4) + expect(select?.textContent).toContain('Select a role to add...') + expect(select?.textContent).toContain('Viewer') + expect(select?.textContent).toContain('Media Manager') + expect(select?.textContent).toContain('IoT Manager') + expect(select?.textContent).not.toContain('Application Admin') + }) + + it('should not show dropdown when user has all roles', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin', 'viewer', 'media-manager', 'iot-manager']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') + expect(select).toBeFalsy() + }) + }) + + describe('action buttons', () => { + it('should render action buttons', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + // Back button, Save Changes button, and Cancel button + const buttons = page?.querySelectorAll('button') + expect(buttons?.length).toBeGreaterThanOrEqual(3) + }) + + it('should have Save Changes and Cancel buttons disabled when no changes', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + // Button component sets disabled attribute on the button element + const disabledButtons = page?.querySelectorAll('button[disabled]') + // Save and Cancel buttons should be disabled (Back is always enabled) + expect(disabledButtons?.length).toBe(2) + }) + }) + + describe('navigation', () => { + it('should navigate back to user list when Back button is clicked', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + const locationService = injector.getInstance(LocationService) + const updateStateSpy = vi.spyOn(locationService, 'updateState') + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + // Back button is the first button element in the page + const backButton = page?.querySelector('button') as HTMLButtonElement + + backButton.click() + + expect(window.location.pathname).toBe('/app-settings/users') + expect(updateStateSpy).toHaveBeenCalled() + }) + }) + + describe('role editing', () => { + it('should add role when selected from dropdown', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Simulate selecting a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + // Should now have 2 role tags (admin + viewer) + const roleTags = page?.querySelectorAll('role-tag') + expect(roleTags?.length).toBe(2) + + // No disabled buttons anymore (Save and Cancel are enabled) + const disabledButtons = page?.querySelectorAll('button[disabled]') + expect(disabledButtons?.length).toBe(0) + }) + + it('should remove role when remove button is clicked', async () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin', 'viewer']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + + // Find and click the remove button on a role tag + const roleTag = page?.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + removeButton?.click() + + // Wait for re-render + await new Promise((resolve) => setTimeout(resolve, 10)) + + // No disabled buttons (Save and Cancel are enabled) + const disabledButtons = page?.querySelectorAll('button[disabled]') + expect(disabledButtons?.length).toBe(0) + }) + + it('should cancel changes when Cancel button is clicked', async () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + // Wait for state update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Get action buttons (excluding role-tag buttons): Back (0), Save (1), Cancel (2) + const allButtons = Array.from(page?.querySelectorAll('button') ?? []) + const actionButtons = allButtons.filter((btn) => !btn.closest('role-tag')) + // Cancel is the last action button + const cancelButton = actionButtons[actionButtons.length - 1] + cancelButton.click() + + // Wait for re-render + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should be back to original state (1 role tag) + const roleTags = page?.querySelectorAll('role-tag') + expect(roleTags?.length).toBe(1) + + // Save and Cancel buttons should be disabled again + const disabledButtons = page?.querySelectorAll('button[disabled]') + expect(disabledButtons?.length).toBe(2) + }) + }) + + describe('save functionality', () => { + /** + * Helper to get action buttons (excluding buttons inside role-tags) + */ + const getActionButtons = (page: Element | null | undefined) => { + const allButtons = Array.from(page?.querySelectorAll('button') ?? []) + // Filter out buttons that are inside role-tag elements + return allButtons.filter((btn) => !btn.closest('role-tag')) + } + + it('should call updateUser when Save Changes is clicked', async () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + // Wait for state update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Get action buttons (excluding role-tag buttons): Back (0), Save (1), Cancel (2) + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + // Wait for async save + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(mockUsersService.updateUser).toHaveBeenCalledWith('testuser@example.com', { + username: 'testuser@example.com', + roles: ['admin', 'viewer'], + }) + }) + + it('should show success notification after save', async () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Click Save + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(mockNotyService.emit).toHaveBeenCalledWith('onNotyAdded', { + title: 'Success', + body: 'User roles updated successfully', + type: 'success', + }) + }) + + it('should show error notification on save failure', async () => { + mockUsersService.updateUser.mockRejectedValueOnce(new Error('Network error')) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Click Save + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(mockNotyService.emit).toHaveBeenCalledWith('onNotyAdded', { + title: 'Error', + body: 'Network error', + type: 'error', + }) + }) + + it('should disable buttons while saving', async () => { + // Make updateUser slow - need to delay to check the saving state + // eslint-disable-next-line @typescript-eslint/no-misused-promises + mockUsersService.updateUser.mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(createMockUser()) + }, 100) + }) + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify buttons are enabled before save + const actionButtonsBefore = getActionButtons(page) + expect(actionButtonsBefore[1]?.disabled).toBe(false) // Save + expect(actionButtonsBefore[2]?.disabled).toBe(false) // Cancel + + // Click Save + actionButtonsBefore[1]?.click() + + // Wait a bit for state to update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Both Save and Cancel should be disabled while saving + const actionButtonsAfter = getActionButtons(page) + expect(actionButtonsAfter[1]?.disabled).toBe(true) // Save + expect(actionButtonsAfter[2]?.disabled).toBe(true) // Cancel + }) + }) + + describe('validation', () => { + /** + * Helper to get action buttons (excluding buttons inside role-tags) + */ + const getActionButtons = (page: Element | null | undefined) => { + const allButtons = Array.from(page?.querySelectorAll('button') ?? []) + // Filter out buttons that are inside role-tag elements + return allButtons.filter((btn) => !btn.closest('role-tag')) + } + + it('should show validation error when trying to save with no roles', async () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['viewer']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + + // Remove the only role + const roleTag = page?.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + removeButton?.click() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Click Save (index 1 of action buttons, excluding role-tag buttons) + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(page?.textContent).toContain('User must have at least one role') + expect(mockUsersService.updateUser).not.toHaveBeenCalled() + }) + }) + + describe('service integration', () => { + it('should call getUserAsObservable with username on render', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + expect(mockUsersService.getUserAsObservable).toHaveBeenCalledWith('testuser@example.com') + }) + }) +}) diff --git a/frontend/src/pages/admin/user-list.spec.tsx b/frontend/src/pages/admin/user-list.spec.tsx new file mode 100644 index 00000000..1049dc5a --- /dev/null +++ b/frontend/src/pages/admin/user-list.spec.tsx @@ -0,0 +1,369 @@ +import { Injector } from '@furystack/inject' +import { createComponent, initializeShadeRoot, LocationService } from '@furystack/shades' +import { ObservableValue } from '@furystack/utils' +import type { User } from 'common' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { UsersService } from '../../services/users-service.js' +import { UserListPage } from './user-list.js' + +type CacheState = + | { status: 'uninitialized' } + | { status: 'loading' } + | { status: 'obsolete'; value: T; updatedAt: Date } + | { status: 'loaded'; value: T; updatedAt: Date } + | { status: 'failed'; error: unknown; updatedAt: Date } + +const createMockUser = (username = 'testuser@example.com', roles: User['roles'] = ['admin']): User => ({ + username, + roles, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}) + +describe('UserListPage', () => { + let injector: Injector + let mockUsersService: { + findUsersAsObservable: ReturnType + findUsers: ReturnType + userQueryCache: { flushAll: ReturnType } + } + let usersObservable: ObservableValue> + + beforeEach(() => { + document.body.innerHTML = '
' + + usersObservable = new ObservableValue>({ + status: 'loaded', + value: { + count: 2, + entries: [createMockUser('user1@example.com', ['admin']), createMockUser('user2@example.com', ['viewer'])], + }, + updatedAt: new Date(), + }) + + mockUsersService = { + findUsersAsObservable: vi.fn().mockReturnValue(usersObservable), + findUsers: vi.fn().mockResolvedValue({ + count: 2, + entries: [createMockUser('user1@example.com', ['admin']), createMockUser('user2@example.com', ['viewer'])], + }), + userQueryCache: { flushAll: vi.fn() }, + } + + injector = new Injector() + injector.setExplicitInstance(mockUsersService as unknown as UsersService, UsersService) + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('rendering', () => { + it('should render the user list page with header', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page).toBeTruthy() + expect(page?.textContent).toContain('👥 Users') + expect(page?.textContent).toContain('Manage user accounts and their roles.') + }) + + it('should display loading state', () => { + usersObservable.setValue({ status: 'loading' }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Loading users...') + }) + + it('should display uninitialized state as loading', () => { + usersObservable.setValue({ status: 'uninitialized' }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Loading users...') + }) + + it('should display error state with retry button', () => { + usersObservable.setValue({ + status: 'failed', + error: new Error('Network error'), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Error: Network error') + + // Button component renders a button element with content in shadow DOM + const retryButton = page?.querySelector('button') + expect(retryButton).toBeTruthy() + }) + + it('should display error message for non-Error objects', () => { + usersObservable.setValue({ + status: 'failed', + error: 'string error', + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Failed to load users') + }) + }) + + describe('table display', () => { + it('should render table with headers', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const headers = page?.querySelectorAll('th') + + expect(headers?.length).toBe(4) + expect(headers?.[0]?.textContent).toContain('Username') + expect(headers?.[1]?.textContent).toContain('Roles') + expect(headers?.[2]?.textContent).toContain('Created') + expect(headers?.[3]?.textContent).toContain('Actions') + }) + + it('should render users in table rows', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const rows = page?.querySelectorAll('tbody tr') + + expect(rows?.length).toBe(2) + expect(rows?.[0]?.textContent).toContain('user1@example.com') + expect(rows?.[1]?.textContent).toContain('user2@example.com') + }) + + it('should render role tags for each user', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const roleTags = page?.querySelectorAll('role-tag') + + expect(roleTags?.length).toBe(2) // One for admin, one for viewer + }) + + it('should display "No roles" message for user without roles', () => { + usersObservable.setValue({ + status: 'loaded', + value: { + count: 1, + entries: [createMockUser('noRoles@example.com', [])], + }, + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('No roles') + }) + + it('should display empty state when no users exist', () => { + usersObservable.setValue({ + status: 'loaded', + value: { + count: 0, + entries: [], + }, + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('No users found.') + }) + + it('should render Edit button for each user', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + // Button component renders button elements within table rows + const editButtons = page?.querySelectorAll('tbody button') + + expect(editButtons?.length).toBe(2) + // Verify buttons exist (content is in shadow DOM) + expect(editButtons?.[0]).toBeTruthy() + expect(editButtons?.[1]).toBeTruthy() + }) + }) + + describe('navigation', () => { + it('should navigate to user details when Edit button is clicked', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + const locationService = injector.getInstance(LocationService) + const updateStateSpy = vi.spyOn(locationService, 'updateState') + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const editButton = page?.querySelector('tbody button') as HTMLButtonElement + editButton.click() + + expect(window.location.pathname).toBe('/app-settings/users/user1%40example.com') + expect(updateStateSpy).toHaveBeenCalled() + }) + + it('should navigate to user details when table row is clicked', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + const locationService = injector.getInstance(LocationService) + const updateStateSpy = vi.spyOn(locationService, 'updateState') + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const row = page?.querySelector('tbody tr') as HTMLTableRowElement + row.click() + + expect(window.location.pathname).toBe('/app-settings/users/user1%40example.com') + expect(updateStateSpy).toHaveBeenCalled() + }) + + it('should encode username in URL to handle special characters', () => { + usersObservable.setValue({ + status: 'loaded', + value: { + count: 1, + entries: [createMockUser('user+special@example.com', ['admin'])], + }, + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const row = page?.querySelector('tbody tr') as HTMLTableRowElement + row.click() + + expect(window.location.pathname).toBe('/app-settings/users/user%2Bspecial%40example.com') + }) + }) + + describe('retry functionality', () => { + it('should flush cache and refetch when retry is clicked', async () => { + usersObservable.setValue({ + status: 'failed', + error: new Error('Network error'), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const retryButton = page?.querySelector('button') as HTMLButtonElement + retryButton.click() + + expect(mockUsersService.userQueryCache.flushAll).toHaveBeenCalled() + expect(mockUsersService.findUsers).toHaveBeenCalledWith({}) + }) + }) + + describe('service integration', () => { + it('should call findUsersAsObservable on render', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + expect(mockUsersService.findUsersAsObservable).toHaveBeenCalledWith({}) + }) + }) +}) From 2cfc256e16ea81d308ef69ec3406458213242262 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:47:36 +0100 Subject: [PATCH 09/32] test improvements, prettier fixes --- e2e/user-management.spec.ts | 231 +++++++++--------- .../src/pages/admin/user-details.spec.tsx | 40 +-- frontend/src/pages/admin/user-list.spec.tsx | 15 +- frontend/src/services/users-service.spec.ts | 10 +- frontend/src/test-utils/user-test-helpers.ts | 28 +++ 5 files changed, 159 insertions(+), 165 deletions(-) create mode 100644 frontend/src/test-utils/user-test-helpers.ts diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts index 11c456bb..d26b9d55 100644 --- a/e2e/user-management.spec.ts +++ b/e2e/user-management.spec.ts @@ -189,20 +189,20 @@ test.describe('Role Editing', () => { // Get the number of options (available roles to add) const optionsCount = await roleSelect.locator('option').count() - // If there are available roles to add (more than just the placeholder) - if (optionsCount > 1) { - // Select the first available role option (not the placeholder) - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - - if (secondOption) { - await roleSelect.selectOption(secondOption) - - // Verify a new role tag appears - const roleTags = detailsPage.locator('role-tag') - await expect(roleTags.first()).toBeVisible() - } - } + // Verify there are available roles to add (more than just the placeholder) + // If not, this test should fail as it cannot test the intended behavior + expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) + + // Select the first available role option (not the placeholder) + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + expect(secondOption, 'Expected option to have a value').toBeTruthy() + + await roleSelect.selectOption(secondOption!) + + // Verify a new role tag appears + const roleTags = detailsPage.locator('role-tag') + await expect(roleTags.first()).toBeVisible() }) test('should enable Save button when changes are made', async ({ page }) => { @@ -213,21 +213,19 @@ test.describe('Role Editing', () => { // Initially Save button should be disabled await expect(saveButton).toBeDisabled() - // Check if there are available roles to add + // Verify there are available roles to add const optionsCount = await roleSelect.locator('option').count() + expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) - if (optionsCount > 1) { - // Add a role - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') + // Add a role + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + expect(secondOption, 'Expected option to have a value').toBeTruthy() - if (secondOption) { - await roleSelect.selectOption(secondOption) + await roleSelect.selectOption(secondOption!) - // Save button should now be enabled - await expect(saveButton).toBeEnabled() - } - } + // Save button should now be enabled + await expect(saveButton).toBeEnabled() }) test('should remove a role by clicking the remove button', async ({ page }) => { @@ -237,20 +235,20 @@ test.describe('Role Editing', () => { // Get current role tags count const roleTags = detailsPage.locator('role-tag') const initialCount = await roleTags.count() + expect(initialCount, 'Expected at least one role tag').toBeGreaterThan(0) - if (initialCount > 0) { - // Find and click the remove button on the first role tag - const firstRoleTag = roleTags.first() - const removeButton = firstRoleTag.locator('button', { hasText: '×' }) + // Find and click the remove button on the first role tag + const firstRoleTag = roleTags.first() + const removeButton = firstRoleTag.locator('button', { hasText: '×' }) - // Check if remove button exists (some roles may not have it) - if ((await removeButton.count()) > 0) { - await removeButton.click() + // Verify remove button exists + const removeButtonCount = await removeButton.count() + expect(removeButtonCount, 'Expected remove button on role tag').toBeGreaterThan(0) - // Save button should be enabled after removing a role - await expect(saveButton).toBeEnabled() - } - } + await removeButton.click() + + // Save button should be enabled after removing a role + await expect(saveButton).toBeEnabled() }) test('should cancel changes and restore original roles', async ({ page }) => { @@ -259,27 +257,25 @@ test.describe('Role Editing', () => { const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - // Check if there are available roles to add + // Verify there are available roles to add const optionsCount = await roleSelect.locator('option').count() + expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) - if (optionsCount > 1) { - // Add a role to make changes - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') + // Add a role to make changes + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + expect(secondOption, 'Expected option to have a value').toBeTruthy() - if (secondOption) { - await roleSelect.selectOption(secondOption) + await roleSelect.selectOption(secondOption!) - // Verify Save is now enabled - await expect(saveButton).toBeEnabled() + // Verify Save is now enabled + await expect(saveButton).toBeEnabled() - // Click Cancel - await cancelButton.click() + // Click Cancel + await cancelButton.click() - // Save button should be disabled again - await expect(saveButton).toBeDisabled() - } - } + // Save button should be disabled again + await expect(saveButton).toBeDisabled() }) }) @@ -301,27 +297,25 @@ test.describe('Save Role Changes', () => { const roleSelect = detailsPage.locator('select') const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - // Check if there are available roles to add + // Verify there are available roles to add const optionsCount = await roleSelect.locator('option').count() + expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) - if (optionsCount > 1) { - // Add a role - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') + // Add a role + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + expect(secondOption, 'Expected option to have a value').toBeTruthy() - if (secondOption) { - await roleSelect.selectOption(secondOption) + await roleSelect.selectOption(secondOption!) - // Save changes - await saveButton.click() + // Save changes + await saveButton.click() - // Verify success notification - await assertAndDismissNoty(page, 'User roles updated successfully') + // Verify success notification + await assertAndDismissNoty(page, 'User roles updated successfully') - // Save button should be disabled after saving - await expect(saveButton).toBeDisabled() - } - } + // Save button should be disabled after saving + await expect(saveButton).toBeDisabled() }) test('should show validation error when removing all roles', async ({ page }) => { @@ -334,30 +328,42 @@ test.describe('Save Role Changes', () => { const detailsPage = page.locator('user-details-page') const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - // Remove all roles one by one + // Verify there are role tags to remove const roleTags = detailsPage.locator('role-tag') - let roleCount = await roleTags.count() + const initialCount = await roleTags.count() + expect(initialCount, 'Expected at least one role to remove').toBeGreaterThan(0) + + // Remove all roles one by one + let roleCount = initialCount + let rolesRemoved = 0 while (roleCount > 0) { const roleTag = roleTags.first() const removeButton = roleTag.locator('button', { hasText: '×' }) + const removeButtonCount = await removeButton.count() - if ((await removeButton.count()) > 0) { + if (removeButtonCount > 0) { await removeButton.click() + rolesRemoved++ } else { + // Role tag exists but has no remove button (restored role) break } roleCount = await roleTags.count() } + // Verify at least one role was removed + expect(rolesRemoved, 'Expected to remove at least one role').toBeGreaterThan(0) + + // Save button should be enabled after changes + await expect(saveButton).toBeEnabled() + // Try to save - if (await saveButton.isEnabled()) { - await saveButton.click() + await saveButton.click() - // Should show validation error - await expect(detailsPage.getByText('User must have at least one role')).toBeVisible() - } + // Should show validation error + await expect(detailsPage.getByText('User must have at least one role')).toBeVisible() }) }) @@ -378,43 +384,43 @@ test.describe('Role Tag Display Variants', () => { const detailsPage = page.locator('user-details-page') const roleTags = detailsPage.locator('role-tag') - // Check if there are any role tags + // Verify there are role tags const count = await roleTags.count() + expect(count, 'Expected at least one role tag').toBeGreaterThan(0) - if (count > 0) { - const firstRoleTag = roleTags.first() + const firstRoleTag = roleTags.first() - // Verify the role tag is visible - await expect(firstRoleTag).toBeVisible() + // Verify the role tag is visible + await expect(firstRoleTag).toBeVisible() - // Verify the role displays text (the displayName) - const text = await firstRoleTag.textContent() - expect(text?.length).toBeGreaterThan(0) - } + // Verify the role displays text (the displayName) + const text = await firstRoleTag.textContent() + expect(text?.length, 'Expected role tag to have display text').toBeGreaterThan(0) }) test('should show restore button for removed roles', async ({ page }) => { const detailsPage = page.locator('user-details-page') const roleTags = detailsPage.locator('role-tag') - // Get initial role count + // Verify there are role tags const initialCount = await roleTags.count() + expect(initialCount, 'Expected at least one role tag').toBeGreaterThan(0) - if (initialCount > 0) { - // Find a role with remove button - const firstRoleTag = roleTags.first() - const removeButton = firstRoleTag.locator('button', { hasText: '×' }) + // Find a role with remove button + const firstRoleTag = roleTags.first() + const removeButton = firstRoleTag.locator('button', { hasText: '×' }) - if ((await removeButton.count()) > 0) { - await removeButton.click() + // Verify remove button exists + const removeButtonCount = await removeButton.count() + expect(removeButtonCount, 'Expected remove button on role tag').toBeGreaterThan(0) - // Find the role tag that now has restore button (↩) - const removedRoleTag = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }) + await removeButton.click() - // Should have a restore button - await expect(removedRoleTag.first()).toBeVisible() - } - } + // Find the role tag that now has restore button (↩) + const removedRoleTag = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }) + + // Should have a restore button + await expect(removedRoleTag.first()).toBeVisible() }) test('should restore a removed role by clicking restore button', async ({ page }) => { @@ -422,27 +428,28 @@ test.describe('Role Tag Display Variants', () => { const roleTags = detailsPage.locator('role-tag') const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - // Get initial role count + // Verify there are role tags const initialCount = await roleTags.count() + expect(initialCount, 'Expected at least one role tag').toBeGreaterThan(0) - if (initialCount > 0) { - // Find a role with remove button and remove it - const firstRoleTag = roleTags.first() - const removeButton = firstRoleTag.locator('button', { hasText: '×' }) + // Find a role with remove button and remove it + const firstRoleTag = roleTags.first() + const removeButton = firstRoleTag.locator('button', { hasText: '×' }) - if ((await removeButton.count()) > 0) { - await removeButton.click() + // Verify remove button exists + const removeButtonCount = await removeButton.count() + expect(removeButtonCount, 'Expected remove button on role tag').toBeGreaterThan(0) - // Save should be enabled - await expect(saveButton).toBeEnabled() + await removeButton.click() - // Find and click restore button - const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() - await restoreButton.click() + // Save should be enabled + await expect(saveButton).toBeEnabled() - // Save should be disabled again (no net changes) - await expect(saveButton).toBeDisabled() - } - } + // Find and click restore button + const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() + await restoreButton.click() + + // Save should be disabled again (no net changes) + await expect(saveButton).toBeDisabled() }) }) diff --git a/frontend/src/pages/admin/user-details.spec.tsx b/frontend/src/pages/admin/user-details.spec.tsx index 9da9a771..4930bb50 100644 --- a/frontend/src/pages/admin/user-details.spec.tsx +++ b/frontend/src/pages/admin/user-details.spec.tsx @@ -4,22 +4,18 @@ import { NotyService } from '@furystack/shades-common-components' import { ObservableValue } from '@furystack/utils' import type { User } from 'common' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type CacheState, createMockUser } from '../../test-utils/user-test-helpers.js' import { UsersService } from '../../services/users-service.js' import { UserDetailsPage } from './user-details.js' -type CacheState = - | { status: 'uninitialized' } - | { status: 'loading' } - | { status: 'obsolete'; value: T; updatedAt: Date } - | { status: 'loaded'; value: T; updatedAt: Date } - | { status: 'failed'; error: unknown; updatedAt: Date } - -const createMockUser = (username = 'testuser@example.com', roles: User['roles'] = ['admin']): User => ({ - username, - roles, - createdAt: '2024-01-15T10:30:00.000Z', - updatedAt: '2024-01-20T14:45:00.000Z', -}) +/** + * Helper to get action buttons (excluding buttons inside role-tags) + */ +const getActionButtons = (page: Element | null | undefined) => { + const allButtons = Array.from(page?.querySelectorAll('button') ?? []) + // Filter out buttons that are inside role-tag elements + return allButtons.filter((btn) => !btn.closest('role-tag')) +} describe('UserDetailsPage', () => { let injector: Injector @@ -462,15 +458,6 @@ describe('UserDetailsPage', () => { }) describe('save functionality', () => { - /** - * Helper to get action buttons (excluding buttons inside role-tags) - */ - const getActionButtons = (page: Element | null | undefined) => { - const allButtons = Array.from(page?.querySelectorAll('button') ?? []) - // Filter out buttons that are inside role-tag elements - return allButtons.filter((btn) => !btn.closest('role-tag')) - } - it('should call updateUser when Save Changes is clicked', async () => { const rootElement = document.getElementById('root') as HTMLDivElement @@ -617,15 +604,6 @@ describe('UserDetailsPage', () => { }) describe('validation', () => { - /** - * Helper to get action buttons (excluding buttons inside role-tags) - */ - const getActionButtons = (page: Element | null | undefined) => { - const allButtons = Array.from(page?.querySelectorAll('button') ?? []) - // Filter out buttons that are inside role-tag elements - return allButtons.filter((btn) => !btn.closest('role-tag')) - } - it('should show validation error when trying to save with no roles', async () => { userObservable.setValue({ status: 'loaded', diff --git a/frontend/src/pages/admin/user-list.spec.tsx b/frontend/src/pages/admin/user-list.spec.tsx index 1049dc5a..b90c934a 100644 --- a/frontend/src/pages/admin/user-list.spec.tsx +++ b/frontend/src/pages/admin/user-list.spec.tsx @@ -3,23 +3,10 @@ import { createComponent, initializeShadeRoot, LocationService } from '@furystac import { ObservableValue } from '@furystack/utils' import type { User } from 'common' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type CacheState, createMockUser } from '../../test-utils/user-test-helpers.js' import { UsersService } from '../../services/users-service.js' import { UserListPage } from './user-list.js' -type CacheState = - | { status: 'uninitialized' } - | { status: 'loading' } - | { status: 'obsolete'; value: T; updatedAt: Date } - | { status: 'loaded'; value: T; updatedAt: Date } - | { status: 'failed'; error: unknown; updatedAt: Date } - -const createMockUser = (username = 'testuser@example.com', roles: User['roles'] = ['admin']): User => ({ - username, - roles, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), -}) - describe('UserListPage', () => { let injector: Injector let mockUsersService: { diff --git a/frontend/src/services/users-service.spec.ts b/frontend/src/services/users-service.spec.ts index 497a9894..93dc4863 100644 --- a/frontend/src/services/users-service.spec.ts +++ b/frontend/src/services/users-service.spec.ts @@ -1,16 +1,10 @@ import { Injector } from '@furystack/inject' import { usingAsync } from '@furystack/utils' +import type { User } from 'common' import { describe, expect, it, vi } from 'vitest' +import { createMockUser } from '../test-utils/user-test-helpers.js' import { UsersService } from './users-service.js' import { IdentityApiClient } from './api-clients/identity-api-client.js' -import type { User } from 'common' - -const createMockUser = (username = 'testuser@example.com', roles: User['roles'] = ['admin']): User => ({ - username, - roles, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), -}) describe('UsersService', () => { const createTestInjector = (mockCall: ReturnType) => { diff --git a/frontend/src/test-utils/user-test-helpers.ts b/frontend/src/test-utils/user-test-helpers.ts new file mode 100644 index 00000000..b873a5fd --- /dev/null +++ b/frontend/src/test-utils/user-test-helpers.ts @@ -0,0 +1,28 @@ +import type { User } from 'common' + +/** + * Cache state type for testing observable cache values + */ +export type CacheState = + | { status: 'uninitialized' } + | { status: 'loading' } + | { status: 'obsolete'; value: T; updatedAt: Date } + | { status: 'loaded'; value: T; updatedAt: Date } + | { status: 'failed'; error: unknown; updatedAt: Date } + +/** + * Factory function to create mock User objects for testing + */ +export const createMockUser = ( + username = 'testuser@example.com', + roles: User['roles'] = ['admin'], + options?: { + createdAt?: string + updatedAt?: string + }, +): User => ({ + username, + roles, + createdAt: options?.createdAt ?? new Date().toISOString(), + updatedAt: options?.updatedAt ?? new Date().toISOString(), +}) From a8113053c142a726e38bfe5edc11cbb1037acf41 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 10:54:18 +0100 Subject: [PATCH 10/32] test improvements --- common/src/models/identity/roles.spec.ts | 92 ++++-------------------- e2e/user-management.spec.ts | 8 +-- 2 files changed, 19 insertions(+), 81 deletions(-) diff --git a/common/src/models/identity/roles.spec.ts b/common/src/models/identity/roles.spec.ts index 74f273dc..02bf4e67 100644 --- a/common/src/models/identity/roles.spec.ts +++ b/common/src/models/identity/roles.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { AVAILABLE_ROLES, getAllRoleDefinitions, getRoleDefinition } from './roles.js' -import type { Roles } from './user.js' describe('roles', () => { describe('AVAILABLE_ROLES', () => { @@ -30,86 +29,25 @@ describe('roles', () => { } }) - it('should have correct metadata for admin role', () => { - expect(AVAILABLE_ROLES.admin).toEqual({ - displayName: 'Application Admin', - description: 'Full system access including user management and all settings', - }) - }) - - it('should have correct metadata for media-manager role', () => { - expect(AVAILABLE_ROLES['media-manager']).toEqual({ - displayName: 'Media Manager', - description: 'Can manage movies, series, and encoding tasks', - }) - }) - - it('should have correct metadata for viewer role', () => { - expect(AVAILABLE_ROLES.viewer).toEqual({ - displayName: 'Viewer', - description: 'Can browse and watch media content', - }) - }) - - it('should have correct metadata for iot-manager role', () => { - expect(AVAILABLE_ROLES['iot-manager']).toEqual({ - displayName: 'IoT Manager', - description: 'Can manage IoT devices and their settings', - }) + it.each([ + ['admin', 'Application Admin', 'Full system access including user management and all settings'], + ['media-manager', 'Media Manager', 'Can manage movies, series, and encoding tasks'], + ['viewer', 'Viewer', 'Can browse and watch media content'], + ['iot-manager', 'IoT Manager', 'Can manage IoT devices and their settings'], + ] as const)('should have correct metadata for %s role', (role, displayName, description) => { + expect(AVAILABLE_ROLES[role]).toEqual({ displayName, description }) }) }) describe('getRoleDefinition', () => { - it('should return correct definition for admin role', () => { - const definition = getRoleDefinition('admin') - - expect(definition).toEqual({ - name: 'admin', - displayName: 'Application Admin', - description: 'Full system access including user management and all settings', - }) - }) - - it('should return correct definition for media-manager role', () => { - const definition = getRoleDefinition('media-manager') - - expect(definition).toEqual({ - name: 'media-manager', - displayName: 'Media Manager', - description: 'Can manage movies, series, and encoding tasks', - }) - }) - - it('should return correct definition for viewer role', () => { - const definition = getRoleDefinition('viewer') - - expect(definition).toEqual({ - name: 'viewer', - displayName: 'Viewer', - description: 'Can browse and watch media content', - }) - }) - - it('should return correct definition for iot-manager role', () => { - const definition = getRoleDefinition('iot-manager') - - expect(definition).toEqual({ - name: 'iot-manager', - displayName: 'IoT Manager', - description: 'Can manage IoT devices and their settings', - }) - }) - - it('should include name, displayName, and description for each role', () => { - const roles: Array = ['admin', 'media-manager', 'viewer', 'iot-manager'] - - for (const roleName of roles) { - const definition = getRoleDefinition(roleName) - - expect(definition.name).toBe(roleName) - expect(definition.displayName).toBeDefined() - expect(definition.description).toBeDefined() - } + it.each([ + ['admin', 'Application Admin', 'Full system access including user management and all settings'], + ['media-manager', 'Media Manager', 'Can manage movies, series, and encoding tasks'], + ['viewer', 'Viewer', 'Can browse and watch media content'], + ['iot-manager', 'IoT Manager', 'Can manage IoT devices and their settings'], + ] as const)('should return correct definition for %s role', (name, displayName, description) => { + const definition = getRoleDefinition(name) + expect(definition).toEqual({ name, displayName, description }) }) }) diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts index d26b9d55..b27beea5 100644 --- a/e2e/user-management.spec.ts +++ b/e2e/user-management.spec.ts @@ -198,7 +198,7 @@ test.describe('Role Editing', () => { const secondOption = await options.nth(1).getAttribute('value') expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption!) + await roleSelect.selectOption(secondOption) // Verify a new role tag appears const roleTags = detailsPage.locator('role-tag') @@ -222,7 +222,7 @@ test.describe('Role Editing', () => { const secondOption = await options.nth(1).getAttribute('value') expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption!) + await roleSelect.selectOption(secondOption) // Save button should now be enabled await expect(saveButton).toBeEnabled() @@ -266,7 +266,7 @@ test.describe('Role Editing', () => { const secondOption = await options.nth(1).getAttribute('value') expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption!) + await roleSelect.selectOption(secondOption) // Verify Save is now enabled await expect(saveButton).toBeEnabled() @@ -306,7 +306,7 @@ test.describe('Save Role Changes', () => { const secondOption = await options.nth(1).getAttribute('value') expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption!) + await roleSelect.selectOption(secondOption) // Save changes await saveButton.click() From 5e105e8220d15a54f30292e63dd677afaaba5272 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 11:02:38 +0100 Subject: [PATCH 11/32] eslint ide fix --- e2e/tsconfig.json | 8 ++++++++ eslint.config.js | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 e2e/tsconfig.json diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..f08884de --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": ["."] +} diff --git a/eslint.config.js b/eslint.config.js index d7b5dcb0..b817c867 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,7 +34,7 @@ export default tseslint.config( }, languageOptions: { parserOptions: { - project: ['tsconfig.json'], + projectService: true, tsconfigRootDir: import.meta.dirname, }, }, From 3987336585ec40e0a7e05b5394c336a4f5c3cf90 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 11:28:21 +0100 Subject: [PATCH 12/32] hanging observable fix --- frontend/src/pages/admin/user-details.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/admin/user-details.tsx b/frontend/src/pages/admin/user-details.tsx index 92ce96c1..104a0864 100644 --- a/frontend/src/pages/admin/user-details.tsx +++ b/frontend/src/pages/admin/user-details.tsx @@ -146,11 +146,13 @@ export const UserDetailsPage = Shade({ type: 'success', }) - // Update the original roles to reflect saved state - roleChangeObservable.setValue({ - originalRoles: [...current], - currentRoles: [...current], - }) + // Update the original roles to reflect saved state (guard against disposal during async operation) + if (!roleChangeObservable.isDisposed) { + roleChangeObservable.setValue({ + originalRoles: [...current], + currentRoles: [...current], + }) + } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to save user' notyService.emit('onNotyAdded', { @@ -159,7 +161,10 @@ export const UserDetailsPage = Shade({ type: 'error', }) } finally { - isSavingObservable.setValue(false) + // Guard against disposal during async operation (component may have unmounted) + if (!isSavingObservable.isDisposed) { + isSavingObservable.setValue(false) + } } } From 14d9926a3e1e1a39eb79becadf1a44591e7f628b Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 11:45:44 +0100 Subject: [PATCH 13/32] improved cursor rules --- .cursor/rules/FRONTEND_PATTERNS.mdc | 244 +++++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 4 deletions(-) diff --git a/.cursor/rules/FRONTEND_PATTERNS.mdc b/.cursor/rules/FRONTEND_PATTERNS.mdc index 61497253..9485669e 100644 --- a/.cursor/rules/FRONTEND_PATTERNS.mdc +++ b/.cursor/rules/FRONTEND_PATTERNS.mdc @@ -1,5 +1,207 @@ # Frontend Development Patterns +## Data Access Patterns + +### Service-Based Data Access with Caching + +**Never query entities directly from components.** Always use a service that abstracts caching and data fetching. + +```typescript +// ✅ Good - Service with caching +@Injectable({ lifetime: 'singleton' }) +export class UsersService { + @Injected(IdentityApiClient) + declare private readonly identityApiClient: IdentityApiClient + + // Cache for individual entities + public userCache = new Cache({ + capacity: 100, + load: async (username: string) => { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users/:id', + url: { id: username }, + query: {}, + }) + return result + }, + }) + + // Cache for list queries + public userQueryCache = new Cache({ + capacity: 10, + load: async (findOptions: FindOptions>) => { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users', + query: { findOptions }, + }) + + // Populate individual cache from list results + result.entries.forEach((entry) => { + this.userCache.setExplicitValue({ + loadArgs: [entry.username], + value: { status: 'loaded', value: entry, updatedAt: new Date() }, + }) + }) + + return result + }, + }) + + // Expose cache methods + public getUser = this.userCache.get.bind(this.userCache) + public getUserAsObservable = this.userCache.getObservable.bind(this.userCache) + public findUsers = this.userQueryCache.get.bind(this.userQueryCache) + public findUsersAsObservable = this.userQueryCache.getObservable.bind(this.userQueryCache) + + // Mutations invalidate cache + public updateUser = async (username: string, body: { username: string; roles: Roles }) => { + const { result } = await this.identityApiClient.call({ + method: 'PATCH', + action: '/users/:id', + url: { id: username }, + body, + }) + this.userCache.remove(username) + this.userQueryCache.flushAll() + return result + } +} + +// ❌ Bad - Direct API call in component +const MyComponent = Shade({ + render: ({ injector }) => { + const apiClient = injector.getInstance(IdentityApiClient) + // Don't do this - no caching, duplicate requests + const fetchUser = async () => { + const { result } = await apiClient.call({ method: 'GET', action: '/users/:id', ... }) + } + } +}) + +// ✅ Good - Use service with observable +const MyComponent = Shade({ + render: ({ injector, useObservable }) => { + const usersService = injector.getInstance(UsersService) + const [userState] = useObservable('user', usersService.getUserAsObservable(username)) + + // Handle cache states + if (userState.status === 'loading') return
Loading...
+ if (userState.status === 'failed') return
Error: {userState.error}
+ if (userState.status === 'loaded') return
{userState.value.username}
+ } +}) +``` + +### Cache Invalidation on Mutations + +Always invalidate relevant caches after mutations: + +```typescript +// ✅ Good - Invalidate after mutation +public deleteUser = async (username: string) => { + await this.identityApiClient.call({ + method: 'DELETE', + action: '/users/:id', + url: { id: username }, + }) + this.userCache.remove(username) // Remove specific entry + this.userQueryCache.flushAll() // Invalidate list queries +} +``` + +## Props & State Architecture + +### Keep Props Simple + +Avoid prop drilling and complex prop passing through many layers. Use the injector for shared state. + +```typescript +// ❌ Bad - Prop drilling +const GrandparentComponent = Shade({ + render: () => { + const user = /* ... */ + const onUpdate = /* ... */ + const permissions = /* ... */ + + return + } +}) + +const ParentComponent = Shade<{ user: User; onUpdate: () => void; permissions: Permissions }>({ + render: ({ props }) => { + // Just passing through... + return + } +}) + +// ✅ Good - Use injector for shared state +@Injectable({ lifetime: 'singleton' }) +export class UserEditStateService { + public currentUser = new ObservableValue(null) + public permissions = new ObservableValue(null) + + public async loadUser(username: string) { /* ... */ } + public async updateUser(changes: Partial) { /* ... */ } +} + +const ChildComponent = Shade({ + render: ({ injector, useObservable }) => { + const stateService = injector.getInstance(UserEditStateService) + const [user] = useObservable('user', stateService.currentUser) + // Direct access to state, no prop drilling + } +}) +``` + +### When to Use Injectable State Services + +Create an injectable service when: +- State needs to be shared across multiple components +- Logic is becoming complex with multiple observables +- You find yourself passing the same props through 3+ component levels +- Multiple components need to trigger the same mutations + +```typescript +// ✅ Good - Complex state in service +@Injectable({ lifetime: 'singleton' }) +export class RoleEditorStateService { + public originalRoles = new ObservableValue([]) + public currentRoles = new ObservableValue([]) + public isModified = new ObservableValue(false) + + public addRole(role: Roles[number]) { + const current = this.currentRoles.getValue() + if (!current.includes(role)) { + this.currentRoles.setValue([...current, role]) + this.updateModifiedState() + } + } + + public removeRole(role: Roles[number]) { /* ... */ } + + private updateModifiedState() { + const original = this.originalRoles.getValue() + const current = this.currentRoles.getValue() + this.isModified.setValue( + original.length !== current.length || + !original.every(r => current.includes(r)) + ) + } +} +``` + +### Props Guidelines + +| Scenario | Approach | +|----------|----------| +| Simple display data | Props are fine | +| Callbacks for parent | Props are fine | +| Data needed 3+ levels deep | Use injectable service | +| Complex interdependent state | Use injectable service | +| Shared across sibling components | Use injectable service | + ## Routing ### Route Definition @@ -25,15 +227,49 @@ export const myRoute = { export const myRoutes = [myRoute] as const ``` -### Navigation & Integration +### Navigation + +**Always use `navigateToRoute()` for programmatic navigation.** Never manipulate `window.history` or `LocationService` directly in components. ```typescript -// ✅ Always use navigateToRoute() +// ✅ Good - Use navigateToRoute helper import { navigateToRoute } from '../navigate-to-route.js' -import { myRoute } from './routes/my-routes.js' +import { userDetailsRoute } from './routes/user-routes.js' + +const MyComponent = Shade({ + render: ({ injector }) => { + const handleUserClick = (username: string) => { + navigateToRoute(injector, userDetailsRoute, { username }) + } + + return + } +}) -onclick={() => navigateToRoute(injector, myRoute, { param: 'value' })} +// ❌ Bad - Direct history/location manipulation +const MyComponent = Shade({ + render: ({ injector }) => { + const locationService = injector.getInstance(LocationService) + + const handleUserClick = (username: string) => { + // Don't do this - bypasses route system, no type safety + window.history.pushState({}, '', `/users/${username}`) + locationService.updateState() + } + } +}) +``` +### Why Use navigateToRoute + +1. **Type safety** - Route params are type-checked at compile time +2. **Centralized routes** - Routes defined in one place, referenced everywhere +3. **URL compilation** - Handles path-to-regexp compilation automatically +4. **Consistency** - Same pattern across the codebase + +### Integration + +```typescript // Add routes to body.tsx import { myRoutes } from './routes/my-routes.js' From ab4aac3331d377c9e9499cc8624d34563d7dfddc Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 11:50:17 +0100 Subject: [PATCH 14/32] e2e improvements --- e2e/user-management.spec.ts | 9 +++++++++ frontend/src/pages/admin/user-details.tsx | 8 ++++++-- playwright.config.ts | 7 ++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts index b27beea5..4c512452 100644 --- a/e2e/user-management.spec.ts +++ b/e2e/user-management.spec.ts @@ -170,6 +170,9 @@ test.describe('User Details Page', () => { }) test.describe('Role Editing', () => { + // Run these tests serially to prevent concurrent modifications to the same user + test.describe.configure({ mode: 'serial' }) + test.beforeEach(async ({ page }) => { await page.goto('/') await login(page) @@ -280,6 +283,9 @@ test.describe('Role Editing', () => { }) test.describe('Save Role Changes', () => { + // Run these tests serially since they modify user roles in the database + test.describe.configure({ mode: 'serial' }) + test.beforeEach(async ({ page }) => { await page.goto('/') await login(page) @@ -368,6 +374,9 @@ test.describe('Save Role Changes', () => { }) test.describe('Role Tag Display Variants', () => { + // Run these tests serially since they interact with the same user's role state + test.describe.configure({ mode: 'serial' }) + test.beforeEach(async ({ page }) => { await page.goto('/') await login(page) diff --git a/frontend/src/pages/admin/user-details.tsx b/frontend/src/pages/admin/user-details.tsx index 104a0864..fe18d7ff 100644 --- a/frontend/src/pages/admin/user-details.tsx +++ b/frontend/src/pages/admin/user-details.tsx @@ -35,8 +35,12 @@ export const UserDetailsPage = Shade({ const validationErrorObservable = useDisposable('validationError', () => new ObservableValue(null)) const [validationError] = useObservable('validationErrorValue', validationErrorObservable) - // Initialize role change state when user is loaded - if (userState.status === 'loaded' && !roleChange) { + // Track if role state has been initialized to prevent re-initialization during render cycles + const isRoleStateInitialized = useDisposable('isRoleStateInitialized', () => new ObservableValue(false)) + + // Initialize role change state when user is loaded (check synchronously to avoid race conditions) + if (userState.status === 'loaded' && !isRoleStateInitialized.getValue()) { + isRoleStateInitialized.setValue(true) roleChangeObservable.setValue({ originalRoles: [...userState.value.roles], currentRoles: [...userState.value.roles], diff --git a/playwright.config.ts b/playwright.config.ts index b0876f29..9b2e9305 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,10 +6,13 @@ const isInCi = !!process.env.CI const config: PlaywrightTestConfig = { forbidOnly: isInCi, testDir: 'e2e', - fullyParallel: true, + fullyParallel: !isInCi, // Run tests in parallel locally, but serialize in CI to reduce resource contention retries: isInCi ? 2 : 0, + workers: isInCi ? 2 : undefined, // Limit workers in CI to prevent resource exhaustion + timeout: 60000, // 60 second timeout per test reporter: isInCi ? 'github' : 'line', expect: { + timeout: 10000, // 10 second timeout for assertions toHaveScreenshot: { maxDiffPixelRatio: 0.05, threshold: 0.3, @@ -18,6 +21,8 @@ const config: PlaywrightTestConfig = { use: { trace: 'on-first-retry', baseURL: 'http://localhost:9090', + actionTimeout: 15000, // 15 second timeout for actions like click, fill, etc. + navigationTimeout: 30000, // 30 second timeout for navigation }, projects: [ From 98b892dfeec413e1401a1f2f9e80383138a9c77e Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 12:07:02 +0100 Subject: [PATCH 15/32] e2e fixes --- e2e/user-management.spec.ts | 121 +++++++++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 31 deletions(-) diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts index 4c512452..f489c560 100644 --- a/e2e/user-management.spec.ts +++ b/e2e/user-management.spec.ts @@ -140,13 +140,23 @@ test.describe('User Details Page', () => { await expect(page.locator('user-list-page')).toBeVisible() }) - test('should display Add Role dropdown', async ({ page }) => { + test('should display Add Role dropdown when roles are available', async ({ page }) => { const detailsPage = page.locator('user-details-page') - // Verify dropdown is visible - await expect(detailsPage.getByText('Add Role:')).toBeVisible() + // The dropdown is only visible when there are roles available to add + // If user already has all roles, the dropdown won't be shown const roleSelect = detailsPage.locator('select') - await expect(roleSelect).toBeVisible() + const selectCount = await roleSelect.count() + + if (selectCount > 0) { + // Dropdown is visible, verify its label + await expect(detailsPage.getByText('Add Role:')).toBeVisible() + await expect(roleSelect).toBeVisible() + } else { + // User has all roles - verify dropdown is intentionally hidden + // This is expected behavior when no roles are available to add + await expect(roleSelect).not.toBeVisible() + } }) test('should have Save and Cancel buttons', async ({ page }) => { @@ -190,10 +200,21 @@ test.describe('Role Editing', () => { const roleSelect = detailsPage.locator('select') // Get the number of options (available roles to add) - const optionsCount = await roleSelect.locator('option').count() + let optionsCount = await roleSelect.locator('option').count() + + // If no roles available to add (user has all roles), remove one first + if (optionsCount <= 1) { + const roleTags = detailsPage.locator('role-tag') + const removeButton = roleTags.first().locator('button', { hasText: '×' }) + if ((await removeButton.count()) > 0) { + await removeButton.click() + // Wait for the dropdown to appear with available options + await expect(roleSelect).toBeVisible() + optionsCount = await roleSelect.locator('option').count() + } + } // Verify there are available roles to add (more than just the placeholder) - // If not, this test should fail as it cannot test the intended behavior expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) // Select the first available role option (not the placeholder) @@ -216,16 +237,21 @@ test.describe('Role Editing', () => { // Initially Save button should be disabled await expect(saveButton).toBeDisabled() - // Verify there are available roles to add + // Check if there are available roles to add const optionsCount = await roleSelect.locator('option').count() - expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) - // Add a role - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - expect(secondOption, 'Expected option to have a value').toBeTruthy() - - await roleSelect.selectOption(secondOption) + if (optionsCount > 1) { + // Add a role using dropdown + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + expect(secondOption, 'Expected option to have a value').toBeTruthy() + await roleSelect.selectOption(secondOption) + } else { + // No roles to add - remove a role instead to trigger changes + const roleTags = detailsPage.locator('role-tag') + const removeButton = roleTags.first().locator('button', { hasText: '×' }) + await removeButton.click() + } // Save button should now be enabled await expect(saveButton).toBeEnabled() @@ -260,16 +286,21 @@ test.describe('Role Editing', () => { const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - // Verify there are available roles to add + // Check if there are available roles to add const optionsCount = await roleSelect.locator('option').count() - expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) - - // Add a role to make changes - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption) + if (optionsCount > 1) { + // Add a role to make changes + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + expect(secondOption, 'Expected option to have a value').toBeTruthy() + await roleSelect.selectOption(secondOption) + } else { + // No roles to add - remove a role instead to trigger changes + const roleTags = detailsPage.locator('role-tag') + const removeButton = roleTags.first().locator('button', { hasText: '×' }) + await removeButton.click() + } // Verify Save is now enabled await expect(saveButton).toBeEnabled() @@ -302,17 +333,24 @@ test.describe('Save Role Changes', () => { const detailsPage = page.locator('user-details-page') const roleSelect = detailsPage.locator('select') const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + const roleTags = detailsPage.locator('role-tag') - // Verify there are available roles to add + // Check if there are available roles to add const optionsCount = await roleSelect.locator('option').count() - expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) - - // Add a role - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - expect(secondOption, 'Expected option to have a value').toBeTruthy() - - await roleSelect.selectOption(secondOption) + let addedRole = false + + if (optionsCount > 1) { + // Add a role using dropdown + const options = roleSelect.locator('option') + const secondOption = await options.nth(1).getAttribute('value') + expect(secondOption, 'Expected option to have a value').toBeTruthy() + await roleSelect.selectOption(secondOption) + addedRole = true + } else { + // No roles to add - remove a role instead to test saving + const removeButton = roleTags.first().locator('button', { hasText: '×' }) + await removeButton.click() + } // Save changes await saveButton.click() @@ -322,6 +360,27 @@ test.describe('Save Role Changes', () => { // Save button should be disabled after saving await expect(saveButton).toBeDisabled() + + // Restore original state to prevent test state accumulation + if (addedRole) { + // Remove the role we just added + const newRoleTags = detailsPage.locator('role-tag') + const lastRoleTag = newRoleTags.last() + const removeButton = lastRoleTag.locator('button', { hasText: '×' }) + if ((await removeButton.count()) > 0) { + await removeButton.click() + await saveButton.click() + await assertAndDismissNoty(page, 'User roles updated successfully') + } + } else { + // Re-add the role we removed by using the restore button or dropdown + const restoreButton = roleTags.first().locator('button', { hasText: '↩' }) + if ((await restoreButton.count()) > 0) { + await restoreButton.click() + await saveButton.click() + await assertAndDismissNoty(page, 'User roles updated successfully') + } + } }) test('should show validation error when removing all roles', async ({ page }) => { From 9b321b143592aaa1a1aa3ea0544f93a6b74806a6 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 12:29:38 +0100 Subject: [PATCH 16/32] e2e improvements --- .cursor/rules/TESTING_GUIDELINES.md | 89 ++++ e2e/user-management.spec.ts | 696 ++++++++++------------------ 2 files changed, 344 insertions(+), 441 deletions(-) diff --git a/.cursor/rules/TESTING_GUIDELINES.md b/.cursor/rules/TESTING_GUIDELINES.md index 02b5d2b7..00d8c729 100644 --- a/.cursor/rules/TESTING_GUIDELINES.md +++ b/.cursor/rules/TESTING_GUIDELINES.md @@ -287,6 +287,95 @@ const errorNoty = page.locator('shade-noty').first() await expect(errorNoty).toBeVisible() ``` +### User Journey Test Pattern + +E2E tests should follow user journeys, not component isolation. Each test should simulate a complete user workflow from start to finish. + +**Structure:** + +- One test per complete user workflow +- Test from login to logical endpoint +- Clean up any created/modified data at the end +- Use helper functions for reusable steps within the test file + +**Good Example - User Journey:** + +```typescript +test('Admin can navigate to users, edit roles, and verify persistence', async ({ page }) => { + // 1. Login as admin + await login(page) + + // 2. Navigate to app settings, verify Users menu + await navigateToAppSettings(page) + await expect(page.getByText('Users')).toBeVisible() + + // 3. Click Users, verify table structure + await page.getByText('Users').click() + await verifyUsersTableStructure(page) + + // 4. Open user, record initial state + const initialRoleCount = await page.locator('role-tag').count() + + // 5. Make changes (add a role) + await addRoleToUser(page) + await page.getByRole('button', { name: 'Save' }).click() + + // 6. Reload and verify persistence + await page.reload() + await expect(page.locator('role-tag')).toHaveCount(initialRoleCount + 1) + + // 7. Cleanup - restore original state + await removeRoleFromUser(page) + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.locator('role-tag')).toHaveCount(initialRoleCount) +}) +``` + +**Bad Example - Fragmented Tests:** + +```typescript +// ❌ Don't split into many small tests - causes repeated setup and state issues +test.describe('User Management', () => { + test('should display Users menu', ...) + test('should navigate to users list', ...) + test('should display table headers', ...) + test('should display at least one user', ...) + test('should open user details', ...) + test('should add a role', ...) + test('should save changes', ...) +}) +``` + +**Cleanup Pattern:** + +```typescript +test('User can create, customize, and delete a resource', async ({ page }) => { + await login(page) + + // Record initial state if needed + const initialCount = await page.locator('.resource').count() + + // Create resource (use unique identifier) + const resourceName = `test-${Date.now()}` + await createResource(page, resourceName) + + // Perform actions and verifications + await customizeResource(page, resourceName) + await verifyResource(page, resourceName) + + // Cleanup - restore original state + await deleteResource(page, resourceName) + await expect(page.locator('.resource')).toHaveCount(initialCount) +}) +``` + +**Key Principles:** + +- Tests are self-contained and don't depend on other tests +- Tests clean up after themselves to avoid state accumulation +- Use unique identifiers (timestamps, UUIDs) for test data +- Helper functions should be local to the test file unless truly reusable across multiple test files + ## Unit Testing Best Practices ### Component Testing diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts index f489c560..09245676 100644 --- a/e2e/user-management.spec.ts +++ b/e2e/user-management.spec.ts @@ -1,523 +1,337 @@ +import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import { assertAndDismissNoty, login, navigateToAppSettings, navigateToUsersSettings } from './helpers.js' +import { assertAndDismissNoty, login, navigateToAppSettings } from './helpers.js' + +/** + * Helper: Verify the users table structure has all expected columns + */ +const verifyUsersTableStructure = async (page: Page) => { + const usersPage = page.locator('user-list-page') + + await expect(usersPage.locator('th', { hasText: 'Username' })).toBeVisible() + await expect(usersPage.locator('th', { hasText: 'Roles' })).toBeVisible() + await expect(usersPage.locator('th', { hasText: 'Created' })).toBeVisible() + await expect(usersPage.locator('th', { hasText: 'Actions' })).toBeVisible() +} + +/** + * Helper: Verify the user details form has all expected elements + */ +const verifyUserDetailsForm = async (page: Page) => { + const detailsPage = page.locator('user-details-page') + + await expect(detailsPage.getByText('User Information')).toBeVisible() + await expect(detailsPage.getByText('Username:')).toBeVisible() + await expect(detailsPage.getByText('Created:')).toBeVisible() + await expect(detailsPage.getByText('Last Updated:')).toBeVisible() + await expect(detailsPage.getByText('Roles').first()).toBeVisible() + + // Verify action buttons exist + await expect(detailsPage.getByRole('button', { name: 'Save Changes' })).toBeVisible() + await expect(detailsPage.getByRole('button', { name: 'Cancel' })).toBeVisible() + await expect(detailsPage.getByRole('button', { name: /back/i })).toBeVisible() +} + +/** + * Helper: Add a role to the user via dropdown. Returns the role name added, or null if no roles available. + */ +const addRoleToUser = async (page: Page): Promise => { + const detailsPage = page.locator('user-details-page') + const roleSelect = detailsPage.locator('select') + + // Check if dropdown is visible (only shown when roles available to add) + const selectCount = await roleSelect.count() + if (selectCount === 0) { + return null + } + + const optionsCount = await roleSelect.locator('option').count() + if (optionsCount <= 1) { + // Only placeholder option, no roles available + return null + } + + // Select the first available role option (not the placeholder) + const options = roleSelect.locator('option') + const roleValue = await options.nth(1).getAttribute('value') + if (!roleValue) { + return null + } + + await roleSelect.selectOption(roleValue) + return roleValue +} + +/** + * Helper: Remove a specific role by name. Returns true if the role was removed. + * IMPORTANT: Avoids removing 'admin' role to prevent locking out the test user. + */ +const removeRoleByName = async (page: Page, roleName: string): Promise => { + const detailsPage = page.locator('user-details-page') + const roleTag = detailsPage.locator('role-tag', { hasText: roleName }) + + const roleCount = await roleTag.count() + if (roleCount === 0) { + return false + } + + const removeButton = roleTag.first().locator('button', { hasText: '×' }) + const removeButtonCount = await removeButton.count() + if (removeButtonCount === 0) { + return false + } + + await removeButton.click() + return true +} + +/** + * Helper: Remove a non-admin role from the user. Returns the role name removed, or null if none available. + * IMPORTANT: Never removes 'admin' role to prevent locking out the test user. + */ +const removeNonAdminRole = async (page: Page): Promise => { + const detailsPage = page.locator('user-details-page') + const roleTags = detailsPage.locator('role-tag') + + const roleCount = await roleTags.count() + for (let i = 0; i < roleCount; i++) { + const roleTag = roleTags.nth(i) + const roleText = await roleTag.textContent() + // Skip admin role to avoid locking out the test user + if (roleText?.toLowerCase().includes('admin')) { + continue + } -test.describe('User Management Navigation', () => { - test.beforeEach(async ({ page }) => { + const removeButton = roleTag.locator('button', { hasText: '×' }) + const removeButtonCount = await removeButton.count() + if (removeButtonCount > 0) { + await removeButton.click() + // Extract role name (remove the × button text) + return roleText?.replace('×', '').replace('↩', '').trim() ?? null + } + } + return null +} + +test.describe('User Management', () => { + test('Admin can navigate to users, view user details, edit roles, and verify persistence', async ({ page }) => { + // ============================================ + // STEP 1: Login as admin + // ============================================ await page.goto('/') await login(page) - }) - test('should display Users menu item in Identity section', async ({ page }) => { + // ============================================ + // STEP 2: Navigate to app settings, verify Users menu is visible + // ============================================ await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Verify Users menu item is visible in the Identity section const usersMenuItem = page.getByText('Users') await expect(usersMenuItem).toBeVisible() - }) - test('should navigate to users list page', async ({ page }) => { - await navigateToUsersSettings(page) + // ============================================ + // STEP 3: Click Users, verify users table structure + // ============================================ + await usersMenuItem.click() + await page.waitForURL(/\/app-settings\/users/) - // Verify we're on the users list page - await expect(page).toHaveURL(/\/app-settings\/users/) - await expect(page.locator('user-list-page')).toBeVisible() - }) -}) - -test.describe('User List Page', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUsersSettings(page) - }) + const usersPage = page.locator('user-list-page') + await expect(usersPage).toBeVisible() - test('should display users page heading', async ({ page }) => { - // Verify heading is visible + // Verify page heading await expect(page.locator('text=👥 Users').first()).toBeVisible() await expect(page.getByText('Manage user accounts and their roles.')).toBeVisible() - }) - - test('should display users table with columns', async ({ page }) => { - const usersPage = page.locator('user-list-page') - // Verify table headers - await expect(usersPage.locator('th', { hasText: 'Username' })).toBeVisible() - await expect(usersPage.locator('th', { hasText: 'Roles' })).toBeVisible() - await expect(usersPage.locator('th', { hasText: 'Created' })).toBeVisible() - await expect(usersPage.locator('th', { hasText: 'Actions' })).toBeVisible() - }) - - test('should display at least one user in the table', async ({ page }) => { - const usersPage = page.locator('user-list-page') + // Verify table structure + await verifyUsersTableStructure(page) - // Wait for table to load and verify at least one user row exists + // ============================================ + // STEP 4: Verify at least one user (admin) exists in the table + // ============================================ const tableBody = usersPage.locator('tbody') const rows = tableBody.locator('tr') await expect(rows.first()).toBeVisible() - }) - - test('should display user roles with role tags', async ({ page }) => { - const usersPage = page.locator('user-list-page') - // Verify role tags are rendered - const roleTag = usersPage.locator('role-tag').first() - await expect(roleTag).toBeVisible() - }) - - test('should navigate to user details when clicking Edit button', async ({ page }) => { - const usersPage = page.locator('user-list-page') + // Verify role tags are rendered in the table + const roleTagInTable = usersPage.locator('role-tag').first() + await expect(roleTagInTable).toBeVisible() - // Click Edit button on first user + // ============================================ + // STEP 5: Open first user via Edit button, verify form elements + // ============================================ const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() await editButton.click() - // Verify navigation to user details await expect(page).toHaveURL(/\/app-settings\/users\//) - await expect(page.locator('user-details-page')).toBeVisible() - }) - - test('should navigate to user details when clicking table row', async ({ page }) => { - const usersPage = page.locator('user-list-page') - - // Get the first row and click it - const firstRow = usersPage.locator('tbody tr').first() - await firstRow.click() - - // Verify navigation to user details - await expect(page).toHaveURL(/\/app-settings\/users\//) - await expect(page.locator('user-details-page')).toBeVisible() - }) -}) - -test.describe('User Details Page', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUsersSettings(page) - - // Navigate to first user's details - const usersPage = page.locator('user-list-page') - const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() - await editButton.click() - await expect(page.locator('user-details-page')).toBeVisible() - }) - - test('should display user information', async ({ page }) => { - const detailsPage = page.locator('user-details-page') - // Verify user information section - await expect(detailsPage.getByText('User Information')).toBeVisible() - await expect(detailsPage.getByText('Username:')).toBeVisible() - await expect(detailsPage.getByText('Created:')).toBeVisible() - await expect(detailsPage.getByText('Last Updated:')).toBeVisible() - }) - - test('should display roles section', async ({ page }) => { const detailsPage = page.locator('user-details-page') + await expect(detailsPage).toBeVisible() - // Verify roles section is visible - await expect(detailsPage.getByText('Roles').first()).toBeVisible() - }) - - test('should display current user roles with role tags', async ({ page }) => { - const detailsPage = page.locator('user-details-page') + // Verify form structure + await verifyUserDetailsForm(page) - // Verify at least one role tag is displayed - const roleTag = detailsPage.locator('role-tag').first() - await expect(roleTag).toBeVisible() - }) + // Verify Save button is disabled initially (no changes) + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + await expect(saveButton).toBeDisabled() - test('should navigate back to user list when clicking Back button', async ({ page }) => { - const detailsPage = page.locator('user-details-page') + // ============================================ + // STEP 6: Record initial role count, then add or remove a role + // ============================================ + const initialRoleCount = await detailsPage.locator('role-tag').count() + expect(initialRoleCount, 'User should have at least one role').toBeGreaterThan(0) + + // Try to add a role first + const addedRole = await addRoleToUser(page) + const roleWasAdded = addedRole !== null + let removedRole: string | null = null + + if (!roleWasAdded) { + // User has all roles - remove a non-admin role instead + removedRole = await removeNonAdminRole(page) + expect(removedRole, 'Should be able to remove a non-admin role').not.toBeNull() + } - // Click back button - const backButton = detailsPage.getByRole('button', { name: /back/i }) - await backButton.click() + // Verify Save button is now enabled + await expect(saveButton).toBeEnabled() - // Verify navigation back to user list - await expect(page).toHaveURL(/\/app-settings\/users$/) - await expect(page.locator('user-list-page')).toBeVisible() - }) + // ============================================ + // STEP 7: Save the changes + // ============================================ + await saveButton.click() + await assertAndDismissNoty(page, 'User roles updated successfully') - test('should display Add Role dropdown when roles are available', async ({ page }) => { - const detailsPage = page.locator('user-details-page') + // Verify Save button is disabled after saving (no pending changes) + await expect(saveButton).toBeDisabled() - // The dropdown is only visible when there are roles available to add - // If user already has all roles, the dropdown won't be shown - const roleSelect = detailsPage.locator('select') - const selectCount = await roleSelect.count() + // ============================================ + // STEP 8: Reload page, verify role change persisted + // ============================================ + await page.reload() + await expect(detailsPage).toBeVisible() - if (selectCount > 0) { - // Dropdown is visible, verify its label - await expect(detailsPage.getByText('Add Role:')).toBeVisible() - await expect(roleSelect).toBeVisible() + const newRoleCount = await detailsPage.locator('role-tag').count() + if (roleWasAdded) { + expect(newRoleCount).toBe(initialRoleCount + 1) } else { - // User has all roles - verify dropdown is intentionally hidden - // This is expected behavior when no roles are available to add - await expect(roleSelect).not.toBeVisible() + expect(newRoleCount).toBe(initialRoleCount - 1) } - }) - test('should have Save and Cancel buttons', async ({ page }) => { - const detailsPage = page.locator('user-details-page') + // ============================================ + // STEP 9: Restore to original state - reverse the change we made + // ============================================ + if (roleWasAdded) { + // We added a role, now remove it by name (not removing admin!) + const removed = await removeRoleByName(page, addedRole) + expect(removed, 'Should be able to remove the added role').toBeTruthy() + } else { + // We removed a role, now add it back (use dropdown since it should be available) + const added = await addRoleToUser(page) + expect(added, 'Should be able to add a role back').not.toBeNull() + } - // Verify buttons exist - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) + // Save to restore original state + await expect(saveButton).toBeEnabled() + await saveButton.click() + await assertAndDismissNoty(page, 'User roles updated successfully') - await expect(saveButton).toBeVisible() - await expect(cancelButton).toBeVisible() - }) + // ============================================ + // STEP 10: Verify clean state - back to original role count + // ============================================ + const finalRoleCount = await detailsPage.locator('role-tag').count() + expect(finalRoleCount).toBe(initialRoleCount) - test('should have Save button disabled when no changes are made', async ({ page }) => { - const detailsPage = page.locator('user-details-page') + // ============================================ + // STEP 11: Test navigation back to users list + // ============================================ + const backButton = detailsPage.getByRole('button', { name: /back/i }) + await backButton.click() - // Verify Save button is disabled initially - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - await expect(saveButton).toBeDisabled() + await expect(page).toHaveURL(/\/app-settings\/users$/) + await expect(usersPage).toBeVisible() }) -}) -test.describe('Role Editing', () => { - // Run these tests serially to prevent concurrent modifications to the same user - test.describe.configure({ mode: 'serial' }) - - test.beforeEach(async ({ page }) => { + test('Admin can verify role editing UI behavior (add, remove, restore, cancel)', async ({ page }) => { + // ============================================ + // STEP 1: Setup - Login and navigate to user details + // ============================================ await page.goto('/') await login(page) - await navigateToUsersSettings(page) + await navigateToAppSettings(page) + await page.getByText('Users').click() + await page.waitForURL(/\/app-settings\/users/) - // Navigate to first user's details const usersPage = page.locator('user-list-page') - const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() - await editButton.click() - await expect(page.locator('user-details-page')).toBeVisible() - }) - - test('should add a role using the dropdown', async ({ page }) => { - const detailsPage = page.locator('user-details-page') - const roleSelect = detailsPage.locator('select') - - // Get the number of options (available roles to add) - let optionsCount = await roleSelect.locator('option').count() - - // If no roles available to add (user has all roles), remove one first - if (optionsCount <= 1) { - const roleTags = detailsPage.locator('role-tag') - const removeButton = roleTags.first().locator('button', { hasText: '×' }) - if ((await removeButton.count()) > 0) { - await removeButton.click() - // Wait for the dropdown to appear with available options - await expect(roleSelect).toBeVisible() - optionsCount = await roleSelect.locator('option').count() - } - } - - // Verify there are available roles to add (more than just the placeholder) - expect(optionsCount, 'Expected available roles to add in dropdown').toBeGreaterThan(1) - - // Select the first available role option (not the placeholder) - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - expect(secondOption, 'Expected option to have a value').toBeTruthy() - - await roleSelect.selectOption(secondOption) - - // Verify a new role tag appears - const roleTags = detailsPage.locator('role-tag') - await expect(roleTags.first()).toBeVisible() - }) + await usersPage.getByRole('button', { name: 'Edit' }).first().click() - test('should enable Save button when changes are made', async ({ page }) => { const detailsPage = page.locator('user-details-page') - const roleSelect = detailsPage.locator('select') - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - - // Initially Save button should be disabled - await expect(saveButton).toBeDisabled() - - // Check if there are available roles to add - const optionsCount = await roleSelect.locator('option').count() - - if (optionsCount > 1) { - // Add a role using dropdown - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption) - } else { - // No roles to add - remove a role instead to trigger changes - const roleTags = detailsPage.locator('role-tag') - const removeButton = roleTags.first().locator('button', { hasText: '×' }) - await removeButton.click() - } + await expect(detailsPage).toBeVisible() - // Save button should now be enabled - await expect(saveButton).toBeEnabled() - }) - - test('should remove a role by clicking the remove button', async ({ page }) => { - const detailsPage = page.locator('user-details-page') const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - - // Get current role tags count - const roleTags = detailsPage.locator('role-tag') - const initialCount = await roleTags.count() - expect(initialCount, 'Expected at least one role tag').toBeGreaterThan(0) - - // Find and click the remove button on the first role tag - const firstRoleTag = roleTags.first() - const removeButton = firstRoleTag.locator('button', { hasText: '×' }) - - // Verify remove button exists - const removeButtonCount = await removeButton.count() - expect(removeButtonCount, 'Expected remove button on role tag').toBeGreaterThan(0) - - await removeButton.click() - - // Save button should be enabled after removing a role - await expect(saveButton).toBeEnabled() - }) - - test('should cancel changes and restore original roles', async ({ page }) => { - const detailsPage = page.locator('user-details-page') - const roleSelect = detailsPage.locator('select') const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - // Check if there are available roles to add - const optionsCount = await roleSelect.locator('option').count() + // ============================================ + // STEP 2: Test Cancel functionality - make change then cancel + // ============================================ + const initialCount = await detailsPage.locator('role-tag').count() - if (optionsCount > 1) { - // Add a role to make changes - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption) - } else { - // No roles to add - remove a role instead to trigger changes - const roleTags = detailsPage.locator('role-tag') - const removeButton = roleTags.first().locator('button', { hasText: '×' }) - await removeButton.click() + // Make a change (add or remove a non-admin role) + const addedRole = await addRoleToUser(page) + if (!addedRole) { + await removeNonAdminRole(page) } - // Verify Save is now enabled + // Verify Save is enabled after change await expect(saveButton).toBeEnabled() - // Click Cancel + // Click Cancel - should restore original state await cancelButton.click() - // Save button should be disabled again + // Verify Save is disabled again (no changes) await expect(saveButton).toBeDisabled() - }) -}) -test.describe('Save Role Changes', () => { - // Run these tests serially since they modify user roles in the database - test.describe.configure({ mode: 'serial' }) + // Verify role count is back to initial + const afterCancelCount = await detailsPage.locator('role-tag').count() + expect(afterCancelCount).toBe(initialCount) - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUsersSettings(page) - }) + // ============================================ + // STEP 3: Test remove and restore UI behavior + // ============================================ + // Remove a non-admin role to test restore functionality + const removed = await removeNonAdminRole(page) + expect(removed).not.toBeNull() - test('should save role changes successfully', async ({ page }) => { - // Navigate to user details - const usersPage = page.locator('user-list-page') - const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() - await editButton.click() - await expect(page.locator('user-details-page')).toBeVisible() - - const detailsPage = page.locator('user-details-page') - const roleSelect = detailsPage.locator('select') - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - const roleTags = detailsPage.locator('role-tag') - - // Check if there are available roles to add - const optionsCount = await roleSelect.locator('option').count() - let addedRole = false - - if (optionsCount > 1) { - // Add a role using dropdown - const options = roleSelect.locator('option') - const secondOption = await options.nth(1).getAttribute('value') - expect(secondOption, 'Expected option to have a value').toBeTruthy() - await roleSelect.selectOption(secondOption) - addedRole = true - } else { - // No roles to add - remove a role instead to test saving - const removeButton = roleTags.first().locator('button', { hasText: '×' }) - await removeButton.click() - } - - // Save changes - await saveButton.click() + // Verify restore button appears for removed role + const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() + await expect(restoreButton).toBeVisible() - // Verify success notification - await assertAndDismissNoty(page, 'User roles updated successfully') + // Click restore + await restoreButton.click() - // Save button should be disabled after saving + // Verify Save is disabled (no net changes) await expect(saveButton).toBeDisabled() - // Restore original state to prevent test state accumulation - if (addedRole) { - // Remove the role we just added - const newRoleTags = detailsPage.locator('role-tag') - const lastRoleTag = newRoleTags.last() - const removeButton = lastRoleTag.locator('button', { hasText: '×' }) - if ((await removeButton.count()) > 0) { - await removeButton.click() - await saveButton.click() - await assertAndDismissNoty(page, 'User roles updated successfully') - } - } else { - // Re-add the role we removed by using the restore button or dropdown - const restoreButton = roleTags.first().locator('button', { hasText: '↩' }) - if ((await restoreButton.count()) > 0) { - await restoreButton.click() - await saveButton.click() - await assertAndDismissNoty(page, 'User roles updated successfully') - } - } - }) - - test('should show validation error when removing all roles', async ({ page }) => { - // Navigate to user details - const usersPage = page.locator('user-list-page') - const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() - await editButton.click() - await expect(page.locator('user-details-page')).toBeVisible() - - const detailsPage = page.locator('user-details-page') - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - - // Verify there are role tags to remove - const roleTags = detailsPage.locator('role-tag') - const initialCount = await roleTags.count() - expect(initialCount, 'Expected at least one role to remove').toBeGreaterThan(0) - + // ============================================ + // STEP 4: Test validation - cannot save with zero roles + // ============================================ // Remove all roles one by one - let roleCount = initialCount - let rolesRemoved = 0 - + let roleCount = await detailsPage.locator('role-tag').count() while (roleCount > 0) { - const roleTag = roleTags.first() - const removeButton = roleTag.locator('button', { hasText: '×' }) - const removeButtonCount = await removeButton.count() - - if (removeButtonCount > 0) { - await removeButton.click() - rolesRemoved++ - } else { - // Role tag exists but has no remove button (restored role) - break - } - - roleCount = await roleTags.count() + const removeBtn = detailsPage.locator('role-tag').first().locator('button', { hasText: '×' }) + const removeBtnCount = await removeBtn.count() + if (removeBtnCount === 0) break + await removeBtn.click() + roleCount = await detailsPage.locator('role-tag').count() } - // Verify at least one role was removed - expect(rolesRemoved, 'Expected to remove at least one role').toBeGreaterThan(0) - - // Save button should be enabled after changes + // Try to save with no roles await expect(saveButton).toBeEnabled() - - // Try to save await saveButton.click() // Should show validation error await expect(detailsPage.getByText('User must have at least one role')).toBeVisible() - }) -}) - -test.describe('Role Tag Display Variants', () => { - // Run these tests serially since they interact with the same user's role state - test.describe.configure({ mode: 'serial' }) - - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUsersSettings(page) - - // Navigate to first user's details - const usersPage = page.locator('user-list-page') - const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() - await editButton.click() - await expect(page.locator('user-details-page')).toBeVisible() - }) - - test('should show role with remove button for existing roles', async ({ page }) => { - const detailsPage = page.locator('user-details-page') - const roleTags = detailsPage.locator('role-tag') - - // Verify there are role tags - const count = await roleTags.count() - expect(count, 'Expected at least one role tag').toBeGreaterThan(0) - - const firstRoleTag = roleTags.first() - - // Verify the role tag is visible - await expect(firstRoleTag).toBeVisible() - - // Verify the role displays text (the displayName) - const text = await firstRoleTag.textContent() - expect(text?.length, 'Expected role tag to have display text').toBeGreaterThan(0) - }) - - test('should show restore button for removed roles', async ({ page }) => { - const detailsPage = page.locator('user-details-page') - const roleTags = detailsPage.locator('role-tag') - - // Verify there are role tags - const initialCount = await roleTags.count() - expect(initialCount, 'Expected at least one role tag').toBeGreaterThan(0) - - // Find a role with remove button - const firstRoleTag = roleTags.first() - const removeButton = firstRoleTag.locator('button', { hasText: '×' }) - - // Verify remove button exists - const removeButtonCount = await removeButton.count() - expect(removeButtonCount, 'Expected remove button on role tag').toBeGreaterThan(0) - - await removeButton.click() - - // Find the role tag that now has restore button (↩) - const removedRoleTag = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }) - - // Should have a restore button - await expect(removedRoleTag.first()).toBeVisible() - }) - - test('should restore a removed role by clicking restore button', async ({ page }) => { - const detailsPage = page.locator('user-details-page') - const roleTags = detailsPage.locator('role-tag') - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - // Verify there are role tags - const initialCount = await roleTags.count() - expect(initialCount, 'Expected at least one role tag').toBeGreaterThan(0) - - // Find a role with remove button and remove it - const firstRoleTag = roleTags.first() - const removeButton = firstRoleTag.locator('button', { hasText: '×' }) - - // Verify remove button exists - const removeButtonCount = await removeButton.count() - expect(removeButtonCount, 'Expected remove button on role tag').toBeGreaterThan(0) - - await removeButton.click() - - // Save should be enabled - await expect(saveButton).toBeEnabled() - - // Find and click restore button - const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() - await restoreButton.click() - - // Save should be disabled again (no net changes) - await expect(saveButton).toBeDisabled() + // Cancel to restore original state (don't persist invalid state) + await cancelButton.click() }) }) From 3aee532b0b50ac0d16cb67da324a77be3834b67b Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 12:34:59 +0100 Subject: [PATCH 17/32] simplified e2e tests --- e2e/app-config.spec.ts | 485 +++++++++++--------------------- e2e/user-settings-basic.spec.ts | 102 ------- e2e/user-settings.spec.ts | 211 +++++++------- 3 files changed, 278 insertions(+), 520 deletions(-) delete mode 100644 e2e/user-settings-basic.spec.ts diff --git a/e2e/app-config.spec.ts b/e2e/app-config.spec.ts index 544f94b2..dfe7bc0a 100644 --- a/e2e/app-config.spec.ts +++ b/e2e/app-config.spec.ts @@ -1,37 +1,35 @@ import { expect, test } from '@playwright/test' import { assertAndDismissNoty, login, navigateToAppSettings } from './helpers.js' -test.describe('OMDB Settings', () => { - test.beforeEach(async ({ page }) => { +test.describe('App Configuration Settings', () => { + test('Admin can configure OMDB settings, toggle visibility, save, and verify persistence', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to app settings + // ============================================ await page.goto('/') await login(page) await navigateToAppSettings(page) - // Wait for the OMDB settings page to load await page.waitForSelector('text=OMDB Settings') - }) - test('should display OMDB settings form', async ({ page }) => { - // Verify OMDB settings heading is visible (using text selector to pierce shadow DOM) + // ============================================ + // STEP 2: Verify OMDB settings form structure + // ============================================ await expect(page.locator('text=OMDB Settings').first()).toBeVisible() - // Verify form fields are present (use locator chain through shadow DOM) const omdbPage = page.locator('omdb-settings-page') const apiKeyInput = omdbPage.locator('input[name="apiKey"]') - await expect(apiKeyInput).toBeVisible() - const searchCheckbox = omdbPage.locator('input[name="trySearchMovieFromTitle"]') - await expect(searchCheckbox).toBeVisible() - const autoDownloadCheckbox = omdbPage.locator('input[name="autoDownloadMetadata"]') - await expect(autoDownloadCheckbox).toBeVisible() - const saveButton = omdbPage.getByRole('button', { name: /save settings/i }) + + await expect(apiKeyInput).toBeVisible() + await expect(searchCheckbox).toBeVisible() + await expect(autoDownloadCheckbox).toBeVisible() await expect(saveButton).toBeVisible() - }) - test('should toggle API key visibility', async ({ page }) => { - const omdbPage = page.locator('omdb-settings-page') - const apiKeyInput = omdbPage.locator('input[name="apiKey"]') + // ============================================ + // STEP 3: Test API key visibility toggle + // ============================================ const toggleButton = omdbPage.locator('[data-toggle-visibility]') // Initially password should be hidden @@ -44,414 +42,269 @@ test.describe('OMDB Settings', () => { // Click toggle to hide again await toggleButton.click() await expect(apiKeyInput).toHaveAttribute('type', 'password') - }) - test('should save OMDB settings successfully', async ({ page }) => { - const omdbPage = page.locator('omdb-settings-page') - const apiKeyInput = omdbPage.locator('input[name="apiKey"]') - const searchCheckbox = omdbPage.locator('input[name="trySearchMovieFromTitle"]') - const autoDownloadCheckbox = omdbPage.locator('input[name="autoDownloadMetadata"]') + // ============================================ + // STEP 4: Record initial values for cleanup + // ============================================ + const initialApiKey = await apiKeyInput.inputValue() + const initialSearchChecked = await searchCheckbox.isChecked() + const initialAutoDownloadChecked = await autoDownloadCheckbox.isChecked() - // Fill in the form - await apiKeyInput.fill('test-api-key-e2e') + // ============================================ + // STEP 5: Fill in and save new settings + // ============================================ + const testApiKey = `test-api-key-${Date.now()}` + await apiKeyInput.fill(testApiKey) - // Ensure checkboxes are in a known state - if (!(await searchCheckbox.isChecked())) { + // Ensure checkboxes are in a known state (toggle them if needed) + if (!initialSearchChecked) { await searchCheckbox.check() } - if (!(await autoDownloadCheckbox.isChecked())) { + if (!initialAutoDownloadChecked) { await autoDownloadCheckbox.check() } - // Submit the form - const saveButton = omdbPage.getByRole('button', { name: /save settings/i }) await saveButton.click() - - // Verify success notification await assertAndDismissNoty(page, 'OMDB settings saved successfully') - }) - test('should persist OMDB settings after save', async ({ page }) => { - const omdbPage = page.locator('omdb-settings-page') - const apiKeyInput = omdbPage.locator('input[name="apiKey"]') - - // Fill in a unique API key - const testApiKey = `test-api-key-${Date.now()}` - await apiKeyInput.fill(testApiKey) - - // Submit the form - const saveButton = omdbPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - await assertAndDismissNoty(page, 'OMDB settings saved successfully') - - // Navigate away and back (simulates leaving and returning) + // ============================================ + // STEP 6: Navigate away and back to verify persistence + // ============================================ await page.goto('/') await page.waitForSelector('text=Apps') - - // Navigate back to OMDB settings await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Verify the value is persisted + // Verify the API key persisted const apiKeyInputAfter = page.locator('omdb-settings-page').locator('input[name="apiKey"]') await expect(apiKeyInputAfter).toHaveValue(testApiKey) + + // ============================================ + // STEP 7: Cleanup - restore original values + // ============================================ + await apiKeyInputAfter.fill(initialApiKey) + + const searchCheckboxAfter = page.locator('omdb-settings-page').locator('input[name="trySearchMovieFromTitle"]') + const autoDownloadCheckboxAfter = page.locator('omdb-settings-page').locator('input[name="autoDownloadMetadata"]') + + if (initialSearchChecked !== (await searchCheckboxAfter.isChecked())) { + await searchCheckboxAfter.click() + } + if (initialAutoDownloadChecked !== (await autoDownloadCheckboxAfter.isChecked())) { + await autoDownloadCheckboxAfter.click() + } + + const saveButtonAfter = page.locator('omdb-settings-page').getByRole('button', { name: /save settings/i }) + await saveButtonAfter.click() + await assertAndDismissNoty(page, 'OMDB settings saved successfully') }) -}) -test.describe('Streaming Settings', () => { - test.beforeEach(async ({ page }) => { + test('Admin can configure Streaming settings, validate inputs, save, and verify persistence', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to streaming settings + // ============================================ await page.goto('/') await login(page) await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Navigate to streaming settings const streamingMenuItem = page.getByText('Streaming Settings') await streamingMenuItem.click() await page.waitForSelector('text=📺 Streaming Settings') - }) - test('should display streaming settings form', async ({ page }) => { - // Verify streaming settings heading (using text selector to pierce shadow DOM) + // ============================================ + // STEP 2: Verify streaming settings form structure + // ============================================ await expect(page.locator('text=📺 Streaming Settings').first()).toBeVisible() const streamingPage = page.locator('streaming-settings-page') - - // Verify form fields are present const extractSubtitlesCheckbox = streamingPage.locator('input[name="autoExtractSubtitles"]') - await expect(extractSubtitlesCheckbox).toBeVisible() - const fullSyncCheckbox = streamingPage.locator('input[name="fullSyncOnStartup"]') - await expect(fullSyncCheckbox).toBeVisible() - const watchFilesCheckbox = streamingPage.locator('input[name="watchFiles"]') - await expect(watchFilesCheckbox).toBeVisible() - const presetSelect = streamingPage.locator('select[name="preset"]') - await expect(presetSelect).toBeVisible() - const threadsInput = streamingPage.locator('input[name="threads"]') - await expect(threadsInput).toBeVisible() - const saveButton = streamingPage.getByRole('button', { name: /save settings/i }) - await expect(saveButton).toBeVisible() - }) - test('should save streaming settings successfully', async ({ page }) => { - const streamingPage = page.locator('streaming-settings-page') - const extractSubtitlesCheckbox = streamingPage.locator('input[name="autoExtractSubtitles"]') - const presetSelect = streamingPage.locator('select[name="preset"]') - const threadsInput = streamingPage.locator('input[name="threads"]') - - // Toggle checkbox - if (!(await extractSubtitlesCheckbox.isChecked())) { - await extractSubtitlesCheckbox.check() - } - - // Select preset - await presetSelect.selectOption('fast') - - // Set threads - await threadsInput.fill('8') - - // Submit the form - const saveButton = streamingPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() + await expect(extractSubtitlesCheckbox).toBeVisible() + await expect(fullSyncCheckbox).toBeVisible() + await expect(watchFilesCheckbox).toBeVisible() + await expect(presetSelect).toBeVisible() + await expect(threadsInput).toBeVisible() + await expect(saveButton).toBeVisible() - // Verify success notification - await assertAndDismissNoty(page, 'Streaming settings saved successfully') - }) + // ============================================ + // STEP 3: Verify input validation attributes + // ============================================ + await expect(threadsInput).toHaveAttribute('min', '1') + await expect(threadsInput).toHaveAttribute('max', '64') - test('should persist streaming settings after save', async ({ page }) => { - const streamingPage = page.locator('streaming-settings-page') - const threadsInput = streamingPage.locator('input[name="threads"]') - const presetSelect = streamingPage.locator('select[name="preset"]') + // ============================================ + // STEP 4: Record initial values for cleanup + // ============================================ + const initialThreads = await threadsInput.inputValue() + const initialPreset = await presetSelect.inputValue() + const initialExtractSubtitles = await extractSubtitlesCheckbox.isChecked() - // Set specific values + // ============================================ + // STEP 5: Update settings and save + // ============================================ await threadsInput.fill('12') await presetSelect.selectOption('veryfast') - // Submit the form - const saveButton = streamingPage.getByRole('button', { name: /save settings/i }) + if (!initialExtractSubtitles) { + await extractSubtitlesCheckbox.check() + } + await saveButton.click() await assertAndDismissNoty(page, 'Streaming settings saved successfully') - // Navigate away and back (simulates leaving and returning) + // ============================================ + // STEP 6: Navigate away and back to verify persistence + // ============================================ await page.goto('/') await page.waitForSelector('text=Apps') - - // Navigate back to app settings await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Navigate to streaming settings - const streamingMenuItem = page.getByText('Streaming Settings') - await streamingMenuItem.click() + await page.getByText('Streaming Settings').click() await page.waitForSelector('text=📺 Streaming Settings') - // Verify the values are persisted const threadsInputAfter = page.locator('streaming-settings-page').locator('input[name="threads"]') const presetSelectAfter = page.locator('streaming-settings-page').locator('select[name="preset"]') + await expect(threadsInputAfter).toHaveValue('12') await expect(presetSelectAfter).toHaveValue('veryfast') - }) - test('should validate threads input', async ({ page }) => { - const streamingPage = page.locator('streaming-settings-page') - const threadsInput = streamingPage.locator('input[name="threads"]') + // ============================================ + // STEP 7: Cleanup - restore original values + // ============================================ + await threadsInputAfter.fill(initialThreads) + await presetSelectAfter.selectOption(initialPreset) - // Verify min/max attributes - await expect(threadsInput).toHaveAttribute('min', '1') - await expect(threadsInput).toHaveAttribute('max', '64') + const extractSubtitlesAfter = page.locator('streaming-settings-page').locator('input[name="autoExtractSubtitles"]') + if (initialExtractSubtitles !== (await extractSubtitlesAfter.isChecked())) { + await extractSubtitlesAfter.click() + } + + const saveButtonAfter = page.locator('streaming-settings-page').getByRole('button', { name: /save settings/i }) + await saveButtonAfter.click() + await assertAndDismissNoty(page, 'Streaming settings saved successfully') }) -}) -test.describe('IOT Settings', () => { - test.beforeEach(async ({ page }) => { + test('Admin can navigate all settings sections and configure IOT and AI settings', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to app settings + // ============================================ await page.goto('/') await login(page) await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Navigate to IOT settings - const iotMenuItem = page.getByText('Device Availability') - await iotMenuItem.click() - await page.waitForSelector('text=📡 IOT Device Availability') - }) + // Should redirect to /app-settings/omdb by default + await expect(page).toHaveURL(/\/app-settings\/omdb/) + await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - test('should display IOT settings form', async ({ page }) => { - // Verify IOT settings heading (using text selector to pierce shadow DOM) + // ============================================ + // STEP 2: Navigate to Streaming settings + // ============================================ + await page.getByText('Streaming Settings').click() + await expect(page).toHaveURL(/\/app-settings\/streaming/) + await expect(page.locator('text=📺 Streaming Settings').first()).toBeVisible() + + // ============================================ + // STEP 3: Navigate to IOT settings and configure + // ============================================ + await page.getByText('Device Availability').click() + await expect(page).toHaveURL(/\/app-settings\/iot/) await expect(page.locator('text=📡 IOT Device Availability').first()).toBeVisible() const iotPage = page.locator('iot-settings-page') - - // Verify form fields are present const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') - await expect(pingIntervalInput).toBeVisible() - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') - await expect(pingTimeoutInput).toBeVisible() - - const saveButton = iotPage.getByRole('button', { name: /save settings/i }) - await expect(saveButton).toBeVisible() - }) + const iotSaveButton = iotPage.getByRole('button', { name: /save settings/i }) - test('should validate ping interval input constraints', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') + await expect(pingIntervalInput).toBeVisible() + await expect(pingTimeoutInput).toBeVisible() + await expect(iotSaveButton).toBeVisible() - // Verify min/max attributes + // Verify input constraints await expect(pingIntervalInput).toHaveAttribute('min', '1000') await expect(pingIntervalInput).toHaveAttribute('max', '3600000') await expect(pingIntervalInput).toHaveAttribute('type', 'number') - }) - - test('should validate ping timeout input constraints', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') - - // Verify min/max attributes await expect(pingTimeoutInput).toHaveAttribute('min', '100') await expect(pingTimeoutInput).toHaveAttribute('max', '60000') await expect(pingTimeoutInput).toHaveAttribute('type', 'number') - }) - test('should save IOT settings successfully', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') + // Record initial values for cleanup + const initialPingInterval = await pingIntervalInput.inputValue() + const initialPingTimeout = await pingTimeoutInput.inputValue() - // Set valid values + // Update and save IOT settings await pingIntervalInput.fill('60000') await pingTimeoutInput.fill('5000') - - // Submit the form - const saveButton = iotPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - await assertAndDismissNoty(page, 'IOT settings saved successfully') - }) - - test('should save and verify IOT settings form accepts valid values', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') - - // Clear and set new values - await pingIntervalInput.clear() - await pingIntervalInput.fill('90000') - await pingTimeoutInput.clear() - await pingTimeoutInput.fill('8000') - - // Submit the form - const saveButton = iotPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - this confirms the form accepts valid values + await iotSaveButton.click() await assertAndDismissNoty(page, 'IOT settings saved successfully') - }) -}) -test.describe('AI Settings', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') - - // Navigate to AI settings - const aiMenuItem = page.getByText('Ollama Settings') - await aiMenuItem.click() - await page.waitForSelector('text=🤖 Ollama Integration') - }) - - test('should display AI settings form', async ({ page }) => { - // Verify AI settings heading (using text selector to pierce shadow DOM) + // ============================================ + // STEP 4: Navigate to AI settings and configure + // ============================================ + await page.getByText('Ollama Settings').click() + await expect(page).toHaveURL(/\/app-settings\/ai/) await expect(page.locator('text=🤖 Ollama Integration').first()).toBeVisible() const aiPage = page.locator('ai-settings-page') - - // Verify form fields are present const hostInput = aiPage.locator('input[name="host"]') + const aiSaveButton = aiPage.getByRole('button', { name: /save settings/i }) + await expect(hostInput).toBeVisible() await expect(hostInput).toHaveAttribute('type', 'url') + await expect(aiSaveButton).toBeVisible() - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await expect(saveButton).toBeVisible() - }) - - test('should save AI settings successfully with valid URL', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') + // Record initial value for cleanup + const initialHost = await hostInput.inputValue() - // Set a valid URL + // Test saving with a valid URL await hostInput.fill('http://localhost:11434') - - // Submit the form - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification + await aiSaveButton.click() await assertAndDismissNoty(page, 'AI settings saved successfully') - }) - - test('should save AI settings successfully with empty URL (disable AI)', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') - // Clear the URL to disable AI features + // Test saving with empty URL (disable AI) await hostInput.fill('') - - // Submit the form - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - await assertAndDismissNoty(page, 'AI settings saved successfully') - }) - - test('should save and verify AI settings form accepts valid URL', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') - - // Clear and set a specific URL - await hostInput.clear() - await hostInput.fill('http://test-ollama:11434') - - // Submit the form - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - this confirms the form accepts valid URLs + await aiSaveButton.click() await assertAndDismissNoty(page, 'AI settings saved successfully') - }) - - test('should have URL input with browser validation', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') - - // Verify the input has type="url" which enables browser-native URL validation - await expect(hostInput).toHaveAttribute('type', 'url') - - // Note: Browser's native URL validation will prevent invalid URLs from being submitted - // Our custom validation serves as a fallback and allows empty values (to disable AI features) - }) -}) -test.describe('Settings Navigation', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - }) - - test('should navigate between OMDB and Streaming settings', async ({ page }) => { - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') - - // Verify we're on OMDB settings by default (using text selector to pierce shadow DOM) - await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - - // Navigate to Streaming settings - const streamingMenuItem = page.getByText('Streaming Settings') - await streamingMenuItem.click() - await page.waitForSelector('text=📺 Streaming Settings') - - await expect(page.locator('text=📺 Streaming Settings').first()).toBeVisible() - - // Navigate back to OMDB settings - const omdbMenuItem = page.getByText('OMDB Settings') - await omdbMenuItem.click() - await page.waitForSelector('text=🎬 OMDB Settings') - - await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - }) - - test('should navigate to all settings sections', async ({ page }) => { - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') - - // Navigate to IOT settings - const iotMenuItem = page.getByText('Device Availability') - await iotMenuItem.click() - await expect(page).toHaveURL(/\/app-settings\/iot/) - await expect(page.locator('text=📡 IOT Device Availability').first()).toBeVisible() - - // Navigate to AI settings - const aiMenuItem = page.getByText('Ollama Settings') - await aiMenuItem.click() - await expect(page).toHaveURL(/\/app-settings\/ai/) - await expect(page.locator('text=🤖 Ollama Integration').first()).toBeVisible() - - // Navigate back to OMDB - const omdbMenuItem = page.getByText('OMDB Settings') - await omdbMenuItem.click() + // ============================================ + // STEP 5: Navigate back to OMDB settings + // ============================================ + await page.getByText('OMDB Settings').click() await expect(page).toHaveURL(/\/app-settings\/omdb/) await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - }) - test('should highlight active menu item', async ({ page }) => { - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') + // ============================================ + // STEP 6: Cleanup - restore IOT and AI settings + // ============================================ + // Restore IOT settings + await page.getByText('Device Availability').click() + await page.waitForSelector('text=📡 IOT Device Availability') - // Check that OMDB menu item is visible - const omdbMenuItem = page.locator('settings-menu-item').filter({ hasText: 'OMDB Settings' }) - await expect(omdbMenuItem).toBeVisible() + const pingIntervalInputCleanup = page.locator('iot-settings-page').locator('input[name="pingIntervalMs"]') + const pingTimeoutInputCleanup = page.locator('iot-settings-page').locator('input[name="pingTimeoutMs"]') + const iotSaveButtonCleanup = page.locator('iot-settings-page').getByRole('button', { name: /save settings/i }) - // Navigate to streaming - const streamingMenuItem = page.getByText('Streaming Settings') - await streamingMenuItem.click() + await pingIntervalInputCleanup.fill(initialPingInterval) + await pingTimeoutInputCleanup.fill(initialPingTimeout) + await iotSaveButtonCleanup.click() + await assertAndDismissNoty(page, 'IOT settings saved successfully') - // Verify URL changed - await expect(page).toHaveURL(/\/app-settings\/streaming/) - }) + // Restore AI settings + await page.getByText('Ollama Settings').click() + await page.waitForSelector('text=🤖 Ollama Integration') - test('should redirect from base /app-settings to /app-settings/omdb', async ({ page }) => { - // Navigate to app settings via UI and verify default sub-route - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') + const hostInputCleanup = page.locator('ai-settings-page').locator('input[name="host"]') + const aiSaveButtonCleanup = page.locator('ai-settings-page').getByRole('button', { name: /save settings/i }) - // Should be on the omdb settings sub-route - await expect(page).toHaveURL(/\/app-settings\/omdb/) + await hostInputCleanup.fill(initialHost) + await aiSaveButtonCleanup.click() + await assertAndDismissNoty(page, 'AI settings saved successfully') }) }) diff --git a/e2e/user-settings-basic.spec.ts b/e2e/user-settings-basic.spec.ts deleted file mode 100644 index ac0a744d..00000000 --- a/e2e/user-settings-basic.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { expect, test } from '@playwright/test' -import { login, navigateToUserSettings } from './helpers.js' - -test('User Settings Page Access', async ({ page }) => { - await page.goto('/') - await login(page) - - // Navigate to user settings - await navigateToUserSettings(page) - - // Verify we're on the settings page - const settingsHeading = page.locator('h1', { hasText: 'User Settings' }) - await expect(settingsHeading).toBeVisible() - - // Verify Profile section is visible - const profileSection = page.locator('h3', { hasText: 'Profile' }) - await expect(profileSection).toBeVisible() - - // Verify Security section is visible - const securitySection = page.locator('h3', { hasText: 'Security' }) - await expect(securitySection).toBeVisible() -}) - -test('Password Reset Form Elements', async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUserSettings(page) - - // Verify password change form is present - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() - - // Verify form inputs are present and functional - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - await expect(currentPasswordInput).toBeVisible() - await expect(currentPasswordInput).toHaveAttribute('type', 'password') - - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - await expect(newPasswordInput).toBeVisible() - await expect(newPasswordInput).toHaveAttribute('type', 'password') - - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - await expect(confirmPasswordInput).toBeVisible() - await expect(confirmPasswordInput).toHaveAttribute('type', 'password') - - const updateButton = passwordForm.getByRole('button', { name: /update password/i }) - await expect(updateButton).toBeVisible() - await expect(updateButton).toBeEnabled() -}) - -test('User Profile Information Display', async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUserSettings(page) - - // Verify profile section shows user information - const profileSection = page.locator('h3', { hasText: 'Profile' }).locator('..') // Parent element - - // Check that username is displayed - const usernameLabel = profileSection.locator('text=Username') - await expect(usernameLabel).toBeVisible() - - const usernameValue = profileSection.locator('text=testuser@gmail.com') - await expect(usernameValue).toBeVisible() - - // Check that roles are displayed - const rolesLabel = profileSection.locator('text=Roles') - await expect(rolesLabel).toBeVisible() - - // The test user should have admin role - const adminRole = profileSection.locator('text=admin') - await expect(adminRole).toBeVisible() -}) - -test('Form Input Functionality', async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUserSettings(page) - - const passwordForm = page.locator('form[data-password-reset-form]') - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - - // Test that inputs can be filled and retain values - await currentPasswordInput.fill('testCurrentPassword') - await newPasswordInput.fill('testNewPassword') - await confirmPasswordInput.fill('testConfirmPassword') - - await expect(currentPasswordInput).toHaveValue('testCurrentPassword') - await expect(newPasswordInput).toHaveValue('testNewPassword') - await expect(confirmPasswordInput).toHaveValue('testConfirmPassword') - - // Test that inputs can be cleared - await currentPasswordInput.fill('') - await newPasswordInput.fill('') - await confirmPasswordInput.fill('') - - await expect(currentPasswordInput).toHaveValue('') - await expect(newPasswordInput).toHaveValue('') - await expect(confirmPasswordInput).toHaveValue('') -}) diff --git a/e2e/user-settings.spec.ts b/e2e/user-settings.spec.ts index 4ba8c3b1..48a3b47d 100644 --- a/e2e/user-settings.spec.ts +++ b/e2e/user-settings.spec.ts @@ -1,106 +1,113 @@ import { expect, test } from '@playwright/test' import { login, navigateToUserSettings } from './helpers.js' -test('User Settings Navigation', async ({ page }) => { - await page.goto('/') - await login(page) - - await navigateToUserSettings(page) - - const settingsHeading = page.locator('h1', { hasText: 'User Settings' }) - await expect(settingsHeading).toBeVisible() - - // Verify Profile section is visible - const profileSection = page.locator('h3', { hasText: 'Profile' }) - await expect(profileSection).toBeVisible() - - // Verify Security section is visible - const securitySection = page.locator('h3', { hasText: 'Security' }) - await expect(securitySection).toBeVisible() - - // Verify password change form is present - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() - - // Verify form inputs are present - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - await expect(currentPasswordInput).toBeVisible() - - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - await expect(newPasswordInput).toBeVisible() - - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - await expect(confirmPasswordInput).toBeVisible() - - const updateButton = passwordForm.getByRole('button', { name: /update password/i }) - await expect(updateButton).toBeVisible() -}) - -test('Password Reset Basic Flow', async ({ page }) => { - await page.goto('/') - await login(page) - - // Navigate to user settings - await navigateToUserSettings(page) - - // Find the password form - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() - - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - const updateButton = passwordForm.getByRole('button', { name: /update password/i }) - - // Test basic form interaction - just verify form can be filled - await currentPasswordInput.fill('password') - await newPasswordInput.fill('newPassword123') - await confirmPasswordInput.fill('newPassword123') - - // Verify form inputs work - await expect(currentPasswordInput).toHaveValue('password') - await expect(newPasswordInput).toHaveValue('newPassword123') - await expect(confirmPasswordInput).toHaveValue('newPassword123') - - // Test that update button is clickable - await expect(updateButton).toBeEnabled() -}) - -test.skip('Password Reset Error Handling', async ({ page }) => { - // Skip this test until error handling is fully implemented - // This test was testing functionality that isn't implemented yet - await page.goto('/') - await login(page) - - await navigateToUserSettings(page) - - // Just verify the form is accessible for now - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() -}) - -test('User Profile Information Display', async ({ page }) => { - await page.goto('/') - await login(page) - - // Navigate to user settings - await navigateToUserSettings(page) - - // Verify profile section shows user information - const profileSection = page.locator('h3', { hasText: 'Profile' }).locator('..') // Parent element - - // Check that username is displayed - const usernameLabel = profileSection.locator('text=Username') - await expect(usernameLabel).toBeVisible() - - const usernameValue = profileSection.locator('text=testuser@gmail.com') - await expect(usernameValue).toBeVisible() - - // Check that roles are displayed - const rolesLabel = profileSection.locator('text=Roles') - await expect(rolesLabel).toBeVisible() - - // The test user should have admin role - const adminRole = profileSection.locator('text=admin') - await expect(adminRole).toBeVisible() +test.describe('User Settings', () => { + test('User can view profile, verify settings page structure, and interact with password form', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to user settings + // ============================================ + await page.goto('/') + await login(page) + await navigateToUserSettings(page) + + // ============================================ + // STEP 2: Verify page structure and heading + // ============================================ + const settingsHeading = page.locator('h1', { hasText: 'User Settings' }) + await expect(settingsHeading).toBeVisible() + + // Verify Profile section is visible + const profileSection = page.locator('h3', { hasText: 'Profile' }) + await expect(profileSection).toBeVisible() + + // Verify Security section is visible + const securitySection = page.locator('h3', { hasText: 'Security' }) + await expect(securitySection).toBeVisible() + + // ============================================ + // STEP 3: Verify user profile information display + // ============================================ + const profileContainer = profileSection.locator('..') // Parent element + + // Check that username is displayed + const usernameLabel = profileContainer.locator('text=Username') + await expect(usernameLabel).toBeVisible() + + const usernameValue = profileContainer.locator('text=testuser@gmail.com') + await expect(usernameValue).toBeVisible() + + // Check that roles are displayed + const rolesLabel = profileContainer.locator('text=Roles') + await expect(rolesLabel).toBeVisible() + + // The test user should have admin role + const adminRole = profileContainer.locator('text=admin') + await expect(adminRole).toBeVisible() + + // ============================================ + // STEP 4: Verify password reset form structure + // ============================================ + const passwordForm = page.locator('form[data-password-reset-form]') + await expect(passwordForm).toBeVisible() + + const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') + const newPasswordInput = passwordForm.locator('input[name="newPassword"]') + const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') + const updateButton = passwordForm.getByRole('button', { name: /update password/i }) + + await expect(currentPasswordInput).toBeVisible() + await expect(currentPasswordInput).toHaveAttribute('type', 'password') + + await expect(newPasswordInput).toBeVisible() + await expect(newPasswordInput).toHaveAttribute('type', 'password') + + await expect(confirmPasswordInput).toBeVisible() + await expect(confirmPasswordInput).toHaveAttribute('type', 'password') + + await expect(updateButton).toBeVisible() + await expect(updateButton).toBeEnabled() + + // ============================================ + // STEP 5: Test form input functionality + // ============================================ + // Fill in test values + await currentPasswordInput.fill('testCurrentPassword') + await newPasswordInput.fill('testNewPassword') + await confirmPasswordInput.fill('testConfirmPassword') + + // Verify values are retained + await expect(currentPasswordInput).toHaveValue('testCurrentPassword') + await expect(newPasswordInput).toHaveValue('testNewPassword') + await expect(confirmPasswordInput).toHaveValue('testConfirmPassword') + + // Test that inputs can be cleared + await currentPasswordInput.fill('') + await newPasswordInput.fill('') + await confirmPasswordInput.fill('') + + await expect(currentPasswordInput).toHaveValue('') + await expect(newPasswordInput).toHaveValue('') + await expect(confirmPasswordInput).toHaveValue('') + + // ============================================ + // STEP 6: Test basic password reset flow (form interaction only) + // ============================================ + // Fill form with valid-looking data (not actually submitting to change password) + await currentPasswordInput.fill('password') + await newPasswordInput.fill('newPassword123') + await confirmPasswordInput.fill('newPassword123') + + // Verify form inputs work correctly + await expect(currentPasswordInput).toHaveValue('password') + await expect(newPasswordInput).toHaveValue('newPassword123') + await expect(confirmPasswordInput).toHaveValue('newPassword123') + + // Verify update button is clickable (but don't click to avoid changing password) + await expect(updateButton).toBeEnabled() + + // Clear form to leave clean state + await currentPasswordInput.fill('') + await newPasswordInput.fill('') + await confirmPasswordInput.fill('') + }) }) From 20c7b6319d339932ec807fd12db5510a39e4dbaf Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 12:35:29 +0100 Subject: [PATCH 18/32] always use 1 worker --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 52745862..0d554ed3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,7 +36,7 @@ jobs: - run: yarn test:e2e:install name: 'Test Installer' - - run: yarn test:e2e + - run: yarn test:e2e --workers=1 name: 'E2E tests' env: E2E_TEMP: /home/node/app/tmp From def5b1c450b38326993c993952b385ffae68c5c8 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 13:48:50 +0100 Subject: [PATCH 19/32] e2e improvements --- .cursor/rules/TESTING_GUIDELINES.md | 74 +++++++ e2e/helpers.ts | 26 +++ e2e/user-management.spec.ts | 294 +++++++--------------------- 3 files changed, 167 insertions(+), 227 deletions(-) diff --git a/.cursor/rules/TESTING_GUIDELINES.md b/.cursor/rules/TESTING_GUIDELINES.md index 00d8c729..f6df381e 100644 --- a/.cursor/rules/TESTING_GUIDELINES.md +++ b/.cursor/rules/TESTING_GUIDELINES.md @@ -376,6 +376,80 @@ test('User can create, customize, and delete a resource', async ({ page }) => { - Use unique identifiers (timestamps, UUIDs) for test data - Helper functions should be local to the test file unless truly reusable across multiple test files +### Parallel Test Execution Between Projects + +When tests run in parallel across different browser projects (e.g., Chromium, Firefox), they share the same backend state. To avoid conflicts, **create project-specific resources** for each browser. + +**Pattern: Project-Specific Test Users** + +```typescript +import { test, expect } from '@playwright/test' +import { login, logout, registerUser } from './helpers.js' + +const TEST_USER_PASSWORD = 'testpassword123' + +// Generate unique username per project (browser) +const getTestUserName = (projectName: string) => `test-user-${projectName}@test.com` + +test.describe('User Management', () => { + test('Admin can edit user roles', async ({ page }, testInfo) => { + const projectName = testInfo.project.name // 'chromium', 'firefox', etc. + const testUserName = getTestUserName(projectName) + + // Register the project-specific test user inside the test + await registerUser(page, testUserName, TEST_USER_PASSWORD) + await logout(page) + + // Login as admin + await login(page) + + // Navigate to users list and find the project-specific user + await navigateToUsersSettings(page) + const testUserRow = page.locator('tbody tr', { hasText: testUserName }) + await testUserRow.getByRole('button', { name: 'Edit' }).click() + + // Perform operations on the project-specific user + // ... + }) +}) +``` + +**Note:** Register resources inside the test rather than in `beforeEach` to avoid conflicts when multiple tests would try to create the same resource. + +**Key Guidelines:** + +1. **Use `testInfo.project.name`** to get the current project (browser) name +2. **Create unique resource names** by incorporating the project name (e.g., `role-tester-chromium@test.com`) +3. **Register/create resources in `beforeEach` or `beforeAll`** hooks +4. **Operate on project-specific resources** instead of shared resources +5. **Expect clean application state** - don't handle "already exists" errors gracefully + +**When to Use This Pattern:** + +- Tests that create, modify, or delete shared resources (users, files, settings) +- Tests that depend on specific resource state +- Any test that could conflict with the same test running in another browser + +**Common Resource Types to Isolate:** + +- Users: `test-user-${projectName}@test.com` +- Files/Folders: `test-folder-${projectName}` +- Settings/Configs: Use project-specific identifiers +- Any entity with unique constraints + +**Available Helpers in `e2e/helpers.ts`:** + +```typescript +// Register a new user +export const registerUser = async (page: Page, username: string, password: string) => { ... } + +// Login with credentials +export const login = async (page: Page, username?: string, password?: string) => { ... } + +// Logout current user +export const logout = async (page: Page) => { ... } +``` + ## Unit Testing Best Practices ### Component Testing diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 17f4c873..816e5a5f 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -98,6 +98,32 @@ export const navigateToUsersSettings = async (page: Page) => { await expect(usersListPage).toBeVisible({ timeout: 10000 }) } +export const registerUser = async (page: Page, username: string, password: string) => { + await page.goto('/') + + // Navigate to registration page + const createAccountButton = page.locator('button', { hasText: 'Create Account' }) + await expect(createAccountButton).toBeVisible() + await createAccountButton.click() + + // Fill registration form + const registerForm = page.locator('shade-register form') + await expect(registerForm).toBeVisible() + + const usernameInput = registerForm.locator('input[name="userName"]') + const passwordInput = registerForm.locator('input[name="password"]') + const confirmPasswordInput = registerForm.locator('input[name="confirmPassword"]') + const createAccountSubmitButton = page.locator('shade-register button', { hasText: 'Create Account' }) + + await usernameInput.fill(username) + await passwordInput.fill(password) + await confirmPasswordInput.fill(password) + await createAccountSubmitButton.click() + + // Should be logged in automatically after successful registration + await assertAndDismissNoty(page, 'Account created successfully') +} + export const uploadFile = async (page: Page, filePath: string, mime: string) => { const fileContent = await readFile(filePath, { encoding: 'utf-8' }) const fileName = basename(filePath) diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts index 09245676..a01f36de 100644 --- a/e2e/user-management.spec.ts +++ b/e2e/user-management.spec.ts @@ -1,36 +1,10 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import { assertAndDismissNoty, login, navigateToAppSettings } from './helpers.js' +import { assertAndDismissNoty, login, logout, navigateToAppSettings, registerUser } from './helpers.js' -/** - * Helper: Verify the users table structure has all expected columns - */ -const verifyUsersTableStructure = async (page: Page) => { - const usersPage = page.locator('user-list-page') - - await expect(usersPage.locator('th', { hasText: 'Username' })).toBeVisible() - await expect(usersPage.locator('th', { hasText: 'Roles' })).toBeVisible() - await expect(usersPage.locator('th', { hasText: 'Created' })).toBeVisible() - await expect(usersPage.locator('th', { hasText: 'Actions' })).toBeVisible() -} - -/** - * Helper: Verify the user details form has all expected elements - */ -const verifyUserDetailsForm = async (page: Page) => { - const detailsPage = page.locator('user-details-page') +const TEST_USER_PASSWORD = 'testpassword123' - await expect(detailsPage.getByText('User Information')).toBeVisible() - await expect(detailsPage.getByText('Username:')).toBeVisible() - await expect(detailsPage.getByText('Created:')).toBeVisible() - await expect(detailsPage.getByText('Last Updated:')).toBeVisible() - await expect(detailsPage.getByText('Roles').first()).toBeVisible() - - // Verify action buttons exist - await expect(detailsPage.getByRole('button', { name: 'Save Changes' })).toBeVisible() - await expect(detailsPage.getByRole('button', { name: 'Cancel' })).toBeVisible() - await expect(detailsPage.getByRole('button', { name: /back/i })).toBeVisible() -} +const getTestUserName = (projectName: string) => `role-tester-${projectName}@test.com` /** * Helper: Add a role to the user via dropdown. Returns the role name added, or null if no roles available. @@ -39,7 +13,6 @@ const addRoleToUser = async (page: Page): Promise => { const detailsPage = page.locator('user-details-page') const roleSelect = detailsPage.locator('select') - // Check if dropdown is visible (only shown when roles available to add) const selectCount = await roleSelect.count() if (selectCount === 0) { return null @@ -47,11 +20,9 @@ const addRoleToUser = async (page: Page): Promise => { const optionsCount = await roleSelect.locator('option').count() if (optionsCount <= 1) { - // Only placeholder option, no roles available return null } - // Select the first available role option (not the placeholder) const options = roleSelect.locator('option') const roleValue = await options.nth(1).getAttribute('value') if (!roleValue) { @@ -63,275 +34,144 @@ const addRoleToUser = async (page: Page): Promise => { } /** - * Helper: Remove a specific role by name. Returns true if the role was removed. - * IMPORTANT: Avoids removing 'admin' role to prevent locking out the test user. + * Helper: Remove the first role from the user. Returns the role name removed, or null if none available. */ -const removeRoleByName = async (page: Page, roleName: string): Promise => { +const removeRole = async (page: Page): Promise => { const detailsPage = page.locator('user-details-page') - const roleTag = detailsPage.locator('role-tag', { hasText: roleName }) + const roleTags = detailsPage.locator('role-tag') - const roleCount = await roleTag.count() + const roleCount = await roleTags.count() if (roleCount === 0) { - return false + return null } - const removeButton = roleTag.first().locator('button', { hasText: '×' }) + const roleTag = roleTags.first() + const roleText = await roleTag.textContent() + + const removeButton = roleTag.locator('button', { hasText: '×' }) const removeButtonCount = await removeButton.count() - if (removeButtonCount === 0) { - return false + if (removeButtonCount > 0) { + await removeButton.click() + return roleText?.replace('×', '').replace('↩', '').trim() ?? null } - await removeButton.click() - return true -} - -/** - * Helper: Remove a non-admin role from the user. Returns the role name removed, or null if none available. - * IMPORTANT: Never removes 'admin' role to prevent locking out the test user. - */ -const removeNonAdminRole = async (page: Page): Promise => { - const detailsPage = page.locator('user-details-page') - const roleTags = detailsPage.locator('role-tag') - - const roleCount = await roleTags.count() - for (let i = 0; i < roleCount; i++) { - const roleTag = roleTags.nth(i) - const roleText = await roleTag.textContent() - // Skip admin role to avoid locking out the test user - if (roleText?.toLowerCase().includes('admin')) { - continue - } - - const removeButton = roleTag.locator('button', { hasText: '×' }) - const removeButtonCount = await removeButton.count() - if (removeButtonCount > 0) { - await removeButton.click() - // Extract role name (remove the × button text) - return roleText?.replace('×', '').replace('↩', '').trim() ?? null - } - } return null } test.describe('User Management', () => { - test('Admin can navigate to users, view user details, edit roles, and verify persistence', async ({ page }) => { + test('Admin can manage user roles', async ({ page }, testInfo) => { + const projectName = testInfo.project.name + const testUserName = getTestUserName(projectName) + // ============================================ - // STEP 1: Login as admin + // SETUP: Register a project-specific test user // ============================================ - await page.goto('/') - await login(page) + await registerUser(page, testUserName, TEST_USER_PASSWORD) + await logout(page) // ============================================ - // STEP 2: Navigate to app settings, verify Users menu is visible + // STEP 1: Login as admin and navigate to users // ============================================ + await login(page) await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') const usersMenuItem = page.getByText('Users') await expect(usersMenuItem).toBeVisible() - - // ============================================ - // STEP 3: Click Users, verify users table structure - // ============================================ await usersMenuItem.click() await page.waitForURL(/\/app-settings\/users/) const usersPage = page.locator('user-list-page') await expect(usersPage).toBeVisible() - - // Verify page heading await expect(page.locator('text=👥 Users').first()).toBeVisible() - await expect(page.getByText('Manage user accounts and their roles.')).toBeVisible() - - // Verify table structure - await verifyUsersTableStructure(page) - - // ============================================ - // STEP 4: Verify at least one user (admin) exists in the table - // ============================================ - const tableBody = usersPage.locator('tbody') - const rows = tableBody.locator('tr') - await expect(rows.first()).toBeVisible() - - // Verify role tags are rendered in the table - const roleTagInTable = usersPage.locator('role-tag').first() - await expect(roleTagInTable).toBeVisible() // ============================================ - // STEP 5: Open first user via Edit button, verify form elements + // STEP 2: Open the test user's details // ============================================ - const editButton = usersPage.getByRole('button', { name: 'Edit' }).first() - await editButton.click() + const testUserRow = usersPage.locator('tbody tr', { hasText: testUserName }) + await expect(testUserRow).toBeVisible() + await testUserRow.getByRole('button', { name: 'Edit' }).click() await expect(page).toHaveURL(/\/app-settings\/users\//) const detailsPage = page.locator('user-details-page') await expect(detailsPage).toBeVisible() - // Verify form structure - await verifyUserDetailsForm(page) - - // Verify Save button is disabled initially (no changes) const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) + + // Save should be disabled initially (no changes) await expect(saveButton).toBeDisabled() // ============================================ - // STEP 6: Record initial role count, then add or remove a role + // STEP 3: Test Cancel functionality // ============================================ const initialRoleCount = await detailsPage.locator('role-tag').count() expect(initialRoleCount, 'User should have at least one role').toBeGreaterThan(0) - // Try to add a role first - const addedRole = await addRoleToUser(page) - const roleWasAdded = addedRole !== null - let removedRole: string | null = null - - if (!roleWasAdded) { - // User has all roles - remove a non-admin role instead - removedRole = await removeNonAdminRole(page) - expect(removedRole, 'Should be able to remove a non-admin role').not.toBeNull() - } - - // Verify Save button is now enabled - await expect(saveButton).toBeEnabled() - - // ============================================ - // STEP 7: Save the changes - // ============================================ - await saveButton.click() - await assertAndDismissNoty(page, 'User roles updated successfully') - - // Verify Save button is disabled after saving (no pending changes) - await expect(saveButton).toBeDisabled() - - // ============================================ - // STEP 8: Reload page, verify role change persisted - // ============================================ - await page.reload() - await expect(detailsPage).toBeVisible() - - const newRoleCount = await detailsPage.locator('role-tag').count() - if (roleWasAdded) { - expect(newRoleCount).toBe(initialRoleCount + 1) - } else { - expect(newRoleCount).toBe(initialRoleCount - 1) - } - - // ============================================ - // STEP 9: Restore to original state - reverse the change we made - // ============================================ - if (roleWasAdded) { - // We added a role, now remove it by name (not removing admin!) - const removed = await removeRoleByName(page, addedRole) - expect(removed, 'Should be able to remove the added role').toBeTruthy() - } else { - // We removed a role, now add it back (use dropdown since it should be available) - const added = await addRoleToUser(page) - expect(added, 'Should be able to add a role back').not.toBeNull() - } - - // Save to restore original state - await expect(saveButton).toBeEnabled() - await saveButton.click() - await assertAndDismissNoty(page, 'User roles updated successfully') - - // ============================================ - // STEP 10: Verify clean state - back to original role count - // ============================================ - const finalRoleCount = await detailsPage.locator('role-tag').count() - expect(finalRoleCount).toBe(initialRoleCount) - - // ============================================ - // STEP 11: Test navigation back to users list - // ============================================ - const backButton = detailsPage.getByRole('button', { name: /back/i }) - await backButton.click() - - await expect(page).toHaveURL(/\/app-settings\/users$/) - await expect(usersPage).toBeVisible() - }) - - test('Admin can verify role editing UI behavior (add, remove, restore, cancel)', async ({ page }) => { - // ============================================ - // STEP 1: Setup - Login and navigate to user details - // ============================================ - await page.goto('/') - await login(page) - await navigateToAppSettings(page) - await page.getByText('Users').click() - await page.waitForURL(/\/app-settings\/users/) - - const usersPage = page.locator('user-list-page') - await usersPage.getByRole('button', { name: 'Edit' }).first().click() - - const detailsPage = page.locator('user-details-page') - await expect(detailsPage).toBeVisible() - - const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) - const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) - - // ============================================ - // STEP 2: Test Cancel functionality - make change then cancel - // ============================================ - const initialCount = await detailsPage.locator('role-tag').count() - - // Make a change (add or remove a non-admin role) + // Make a change (add a role) const addedRole = await addRoleToUser(page) - if (!addedRole) { - await removeNonAdminRole(page) - } - - // Verify Save is enabled after change + expect(addedRole, 'Should be able to add a role').not.toBeNull() await expect(saveButton).toBeEnabled() - // Click Cancel - should restore original state + // Cancel should restore original state await cancelButton.click() - - // Verify Save is disabled again (no changes) await expect(saveButton).toBeDisabled() - - // Verify role count is back to initial - const afterCancelCount = await detailsPage.locator('role-tag').count() - expect(afterCancelCount).toBe(initialCount) + expect(await detailsPage.locator('role-tag').count()).toBe(initialRoleCount) // ============================================ - // STEP 3: Test remove and restore UI behavior + // STEP 4: Test restore functionality after removing a role // ============================================ - // Remove a non-admin role to test restore functionality - const removed = await removeNonAdminRole(page) + const removed = await removeRole(page) expect(removed).not.toBeNull() - // Verify restore button appears for removed role + // Restore button should appear const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() await expect(restoreButton).toBeVisible() - - // Click restore await restoreButton.click() - // Verify Save is disabled (no net changes) + // No net changes, save should be disabled await expect(saveButton).toBeDisabled() // ============================================ - // STEP 4: Test validation - cannot save with zero roles + // STEP 5: Test validation - cannot save with zero roles // ============================================ - // Remove all roles one by one let roleCount = await detailsPage.locator('role-tag').count() while (roleCount > 0) { const removeBtn = detailsPage.locator('role-tag').first().locator('button', { hasText: '×' }) - const removeBtnCount = await removeBtn.count() - if (removeBtnCount === 0) break + if ((await removeBtn.count()) === 0) break await removeBtn.click() roleCount = await detailsPage.locator('role-tag').count() } - // Try to save with no roles await expect(saveButton).toBeEnabled() await saveButton.click() - - // Should show validation error await expect(detailsPage.getByText('User must have at least one role')).toBeVisible() - // Cancel to restore original state (don't persist invalid state) + // Cancel to restore await cancelButton.click() + + // ============================================ + // STEP 6: Add a role and verify persistence + // ============================================ + const roleToAdd = await addRoleToUser(page) + expect(roleToAdd, 'Should be able to add a role').not.toBeNull() + + await saveButton.click() + await assertAndDismissNoty(page, 'User roles updated successfully') + await expect(saveButton).toBeDisabled() + + // Reload and verify persistence + await page.reload() + await expect(detailsPage).toBeVisible() + expect(await detailsPage.locator('role-tag').count()).toBe(initialRoleCount + 1) + + // ============================================ + // STEP 7: Navigate back to users list + // ============================================ + const backButton = detailsPage.getByRole('button', { name: /back/i }) + await backButton.click() + + await expect(page).toHaveURL(/\/app-settings\/users$/) + await expect(usersPage).toBeVisible() }) }) From 14da1e00bce8dee1380577a9bab43965d8dac7b9 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 13:59:05 +0100 Subject: [PATCH 20/32] e2e improvements --- e2e/user-management.spec.ts | 80 +++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts index a01f36de..2f101a5f 100644 --- a/e2e/user-management.spec.ts +++ b/e2e/user-management.spec.ts @@ -103,70 +103,72 @@ test.describe('User Management', () => { await expect(saveButton).toBeDisabled() // ============================================ - // STEP 3: Test Cancel functionality + // STEP 3: Verify user starts with no roles // ============================================ - const initialRoleCount = await detailsPage.locator('role-tag').count() - expect(initialRoleCount, 'User should have at least one role').toBeGreaterThan(0) + expect(await detailsPage.locator('role-tag').count(), 'New user should have no roles').toBe(0) - // Make a change (add a role) + // ============================================ + // STEP 4: Test Cancel functionality - add a role then cancel + // ============================================ const addedRole = await addRoleToUser(page) expect(addedRole, 'Should be able to add a role').not.toBeNull() await expect(saveButton).toBeEnabled() - // Cancel should restore original state + // Cancel should restore original state (no roles) await cancelButton.click() await expect(saveButton).toBeDisabled() - expect(await detailsPage.locator('role-tag').count()).toBe(initialRoleCount) + expect(await detailsPage.locator('role-tag').count()).toBe(0) + + // ============================================ + // STEP 5: Add a role and save + // ============================================ + const roleToAdd = await addRoleToUser(page) + expect(roleToAdd, 'Should be able to add a role').not.toBeNull() + + await saveButton.click() + await assertAndDismissNoty(page, 'User roles updated successfully') + + // Wait for the details page to stabilize after save + await expect(detailsPage).toBeVisible() + await expect(detailsPage.getByRole('button', { name: 'Save Changes' })).toBeDisabled() + + // Reload and verify persistence + await page.reload() + await expect(detailsPage).toBeVisible() + expect(await detailsPage.locator('role-tag').count()).toBe(1) + + // Re-locate buttons after reload + const saveButtonAfterReload = detailsPage.getByRole('button', { name: 'Save Changes' }) + const cancelButtonAfterReload = detailsPage.getByRole('button', { name: 'Cancel' }) // ============================================ - // STEP 4: Test restore functionality after removing a role + // STEP 6: Test remove and restore functionality // ============================================ const removed = await removeRole(page) expect(removed).not.toBeNull() - // Restore button should appear + // Restore button should appear for removed role const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() await expect(restoreButton).toBeVisible() await restoreButton.click() - // No net changes, save should be disabled - await expect(saveButton).toBeDisabled() + // No net changes after restore, save should be disabled + await expect(saveButtonAfterReload).toBeDisabled() // ============================================ - // STEP 5: Test validation - cannot save with zero roles + // STEP 7: Test validation - cannot save with zero roles // ============================================ - let roleCount = await detailsPage.locator('role-tag').count() - while (roleCount > 0) { - const removeBtn = detailsPage.locator('role-tag').first().locator('button', { hasText: '×' }) - if ((await removeBtn.count()) === 0) break - await removeBtn.click() - roleCount = await detailsPage.locator('role-tag').count() - } - - await expect(saveButton).toBeEnabled() - await saveButton.click() + await removeRole(page) + await expect(saveButtonAfterReload).toBeEnabled() + await saveButtonAfterReload.click() await expect(detailsPage.getByText('User must have at least one role')).toBeVisible() - // Cancel to restore - await cancelButton.click() - - // ============================================ - // STEP 6: Add a role and verify persistence - // ============================================ - const roleToAdd = await addRoleToUser(page) - expect(roleToAdd, 'Should be able to add a role').not.toBeNull() - - await saveButton.click() - await assertAndDismissNoty(page, 'User roles updated successfully') - await expect(saveButton).toBeDisabled() - - // Reload and verify persistence - await page.reload() - await expect(detailsPage).toBeVisible() - expect(await detailsPage.locator('role-tag').count()).toBe(initialRoleCount + 1) + // Cancel to restore the role + await cancelButtonAfterReload.click() + expect(await detailsPage.locator('role-tag').count()).toBe(1) // ============================================ - // STEP 7: Navigate back to users list + // STEP 8: Navigate back to users list // ============================================ const backButton = detailsPage.getByRole('button', { name: /back/i }) await backButton.click() From d47e0a3bdb8c4b27f5b1d81d80f88dc31da92c46 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 25 Jan 2026 14:09:12 +0100 Subject: [PATCH 21/32] loading fix --- frontend/src/pages/admin/user-details.tsx | 5 +++-- frontend/src/services/users-service.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/admin/user-details.tsx b/frontend/src/pages/admin/user-details.tsx index fe18d7ff..b8f92725 100644 --- a/frontend/src/pages/admin/user-details.tsx +++ b/frontend/src/pages/admin/user-details.tsx @@ -1,3 +1,4 @@ +import { hasCacheValue, isFailedCacheResult } from '@furystack/cache' import { createComponent, LocationService, Shade } from '@furystack/shades' import { Button, NotyService, Paper } from '@furystack/shades-common-components' import { ObservableValue } from '@furystack/utils' @@ -215,7 +216,7 @@ export const UserDetailsPage = Shade({ )} - {userState.status === 'failed' && ( + {isFailedCacheResult(userState) && (

Error: {getErrorMessage(userState.error)}