diff --git a/apps/docs/package.json b/apps/docs/package.json index 9e8348ac1..0d63617a3 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,7 +10,7 @@ "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", - "clear": "docusaurus clear", + "clean": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", diff --git a/apps/ui-community/src/contexts/theme-context.tsx b/apps/ui-community/src/contexts/theme-context.tsx index 8a7a5a870..fa94b9e65 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import { loadStoredTheme, saveStoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -15,7 +16,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: 'light' | 'dark' | 'custom'; } | undefined; setTheme: (tokens: Partial, types: string) => void; @@ -91,13 +92,15 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { type: 'custom', }; } - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + if (valueToSet) { + saveStoredTheme(valueToSet); + } return valueToSet; }); }, []); useEffect(() => { - const extractFromLocal = JSON.parse(localStorage.getItem('themeProp') || '{}'); + const extractFromLocal = loadStoredTheme(); if (extractFromLocal && extractFromLocal.type === 'dark') { setTheme( { @@ -119,22 +122,22 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { } else if (extractFromLocal && extractFromLocal.type === 'custom') { setTheme( { - colorTextBase: extractFromLocal.hardCodedTokens.textColor, - colorBgBase: extractFromLocal.hardCodedTokens.backgroundColor, + colorTextBase: extractFromLocal.hardCodedTokens?.textColor, + colorBgBase: extractFromLocal.hardCodedTokens?.backgroundColor, }, 'custom', ); return; } else { const valueToSet = { - type: 'light', + type: 'light' as const, token: theme.defaultSeed, hardCodedTokens: { textColor: '#000000', backgroundColor: '#ffffff', }, }; - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + saveStoredTheme(valueToSet); setTheme(theme.defaultSeed, 'light'); return; } diff --git a/apps/ui-staff/.env b/apps/ui-staff/.env index b7fbe05a5..cd0300829 100644 --- a/apps/ui-staff/.env +++ b/apps/ui-staff/.env @@ -3,3 +3,4 @@ VITE_APP_UI_STAFF_AAD_CLIENTID=mock-client VITE_APP_UI_STAFF_AAD_REDIRECT_URI=https://staff.ownercommunity.localhost:1355/auth-redirect VITE_APP_UI_STAFF_AAD_SCOPES=openid VITE_COMMON_API_ENDPOINT=https://data-access.ownercommunity.localhost:1355/api/graphql +VITE_APP_UI_STAFF_BASE_URL=https://staff.ownercommunity.localhost:1355 diff --git a/apps/ui-staff/src/App.tsx b/apps/ui-staff/src/App.tsx index f7171e4a4..e26ad3e83 100644 --- a/apps/ui-staff/src/App.tsx +++ b/apps/ui-staff/src/App.tsx @@ -5,23 +5,89 @@ import { Root as Finance } from '@ocom/ui-staff-route-finance'; import { Root } from '@ocom/ui-staff-route-root'; import { Root as TechAdmin } from '@ocom/ui-staff-route-tech-admin'; import { Root as UserManagement } from '@ocom/ui-staff-route-user-management'; -import { StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { StaffAuthContext, StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { Spin } from 'antd'; +import { useContext } from 'react'; import { useAuth } from 'react-oidc-context'; -import { Outlet, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import './App.css'; import { AuthLanding } from './components/ui/molecules/auth-landing/index.tsx'; import { client } from './components/ui/organisms/apollo-connection/apollo-client-links.tsx'; import { ApolloConnection } from './components/ui/organisms/apollo-connection/index.tsx'; +import { useStaffPermissions } from './hooks/use-staff-permissions.ts'; import { Unauthorized } from './unauthorized.tsx'; +function StaffRoutes() { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageUsers = perms?.canManageUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + + let defaultStaffRoute = '/unauthorized'; + if (canManageTechAdmin) { + defaultStaffRoute = '/staff/tech'; + } else if (canManageFinance) { + defaultStaffRoute = '/staff/finance'; + } else if (canManageCommunities) { + defaultStaffRoute = '/staff/community-management'; + } else if (canManageUsers) { + defaultStaffRoute = '/staff/user-management'; + } + + return ( + + + } + /> + {canManageCommunities && ( + } + /> + )} + {canManageUsers && ( + } + /> + )} + {canManageFinance && ( + } + /> + )} + {canManageTechAdmin && ( + } + /> + )} + + } + /> + + ); +} + export default function App() { const rootSection = ; const auth = useAuth(); - // Build a best-effort identity object to supply to shared placeholders - - // Provide a best-effort raw profile to the shared staff shell. StaffRouteShell will - // attempt to extract display name and roles from this raw profile. const identity = { raw: (auth?.user?.profile as Record) ?? undefined, onLogout: () => HandleLogout(auth, client, globalThis.location.origin), @@ -33,13 +99,9 @@ export default function App() { ); - // Staff section acts as the parent route element and must render an Outlet so - // nested child routes declared in the top-level Routes are rendered in place. const staffSectionElement = ( - - - + ); @@ -59,34 +121,32 @@ export default function App() { element={} /> - {/* Parent staff route: child routes must be declared as nested Route elements - so relative paths like "users/*" resolve against /staff. */} + {/* StaffSection renders StaffAuthProvider + StaffRoutes which handles all + authenticated sub-routes with permission guards. No nested Route children + are needed here because StaffRoutes defines its own Routes block. */} - } - /> - } - /> - } - /> - } - /> - } - /> - + /> ); } + +function StaffSection({ identity }: { identity: Parameters[0]['value'] }) { + const { permissions, user, loading } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx index 251645e9b..41fc0aa53 100644 --- a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx +++ b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx @@ -1,5 +1,42 @@ +import { Spin } from 'antd'; import { Navigate } from 'react-router-dom'; +import { useStaffPermissions } from '../../../../hooks/use-staff-permissions.ts'; export const AuthLanding: React.FC = () => { - return ; + const { permissions, loading, error } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + let targetRoute = '/unauthorized'; + if (permissions?.canManageTechAdmin) { + targetRoute = '/staff/tech'; + } else if (permissions?.canManageFinance) { + targetRoute = '/staff/finance'; + } else if (permissions?.canManageCommunities) { + targetRoute = '/staff/community-management'; + } else if (permissions?.canManageUsers) { + targetRoute = '/staff/user-management'; + } + + return ( + + ); }; diff --git a/apps/ui-staff/src/contexts/theme-context.tsx b/apps/ui-staff/src/contexts/theme-context.tsx index 3bc79478b..0e511a0f4 100644 --- a/apps/ui-staff/src/contexts/theme-context.tsx +++ b/apps/ui-staff/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import type { StoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -10,7 +11,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: StoredTheme['type']; } | undefined; setTheme: (tokens: Partial, type: string) => void; diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.ts b/apps/ui-staff/src/hooks/use-staff-permissions.ts new file mode 100644 index 000000000..0c5f4e3d6 --- /dev/null +++ b/apps/ui-staff/src/hooks/use-staff-permissions.ts @@ -0,0 +1,101 @@ +import { gql, useQuery } from '@apollo/client'; + +const CURRENT_STAFF_USER_QUERY = gql` + query CurrentStaffUserAndCreateIfNotExists { + currentStaffUserAndCreateIfNotExists { + id + externalId + firstName + lastName + email + displayName + role { + id + roleName + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffPermissions { + canManageCommunities: boolean; + canManageUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; +} + +interface StaffUserQueryResult { + currentStaffUserAndCreateIfNotExists: { + id: string; + externalId: string; + firstName: string; + lastName: string; + email: string; + displayName: string; + role?: { + id: string; + roleName: string; + permissions: { + communityPermissions: { canManageCommunities: boolean }; + userPermissions: { canManageUsers: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const useStaffPermissions = (): { + permissions: StaffPermissions | undefined; + user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined; + loading: boolean; + error: Error | undefined; +} => { + const { data, loading, error } = useQuery(CURRENT_STAFF_USER_QUERY, { + fetchPolicy: 'cache-first', + }); + + const rolePermissions = data?.currentStaffUserAndCreateIfNotExists?.role?.permissions; + const currentUser = data?.currentStaffUserAndCreateIfNotExists; + + // Treat a TechAdmin as an implicit manager of all sections + const isTechAdmin = rolePermissions?.techAdminPermissions?.canManageTechAdmin ?? false; + + const permissions: StaffPermissions | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin, + canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, + canManageTechAdmin: isTechAdmin, + } + : undefined; + + return { + permissions, + user: currentUser + ? { + id: currentUser.id, + displayName: currentUser.displayName, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + email: currentUser.email, + } + : undefined, + loading, + error, + }; +}; diff --git a/apps/ui-staff/vitest.config.ts b/apps/ui-staff/vitest.config.ts index 17bec4371..198b98ee6 100644 --- a/apps/ui-staff/vitest.config.ts +++ b/apps/ui-staff/vitest.config.ts @@ -7,6 +7,7 @@ export default mergeConfig( test: { environment: 'jsdom', passWithNoTests: true, + exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'], }, }), ); diff --git a/codegen.yml b/codegen.yml index cd441b5ae..95e9f2dc3 100644 --- a/codegen.yml +++ b/codegen.yml @@ -72,6 +72,7 @@ generates: Community: "import('@ocom/domain').Domain.Contexts.Community.Community.CommunityEntityReference" EndUser: "import('@ocom/domain').Domain.Contexts.User.EndUser.EndUserEntityReference" EndUserRole: "import('@ocom/domain').Domain.Contexts.Community.Role.EndUserRole.EndUserRoleEntityReference" + StaffUser: "import('@ocom/domain').Domain.Contexts.User.StaffUser.StaffUserEntityReference" plugins: - typescript - typescript-resolvers @@ -140,6 +141,20 @@ generates: - typescript-operations - typed-document-node + # UI staff shared components client types + './packages/ocom/ui-staff-shared/src/generated.tsx': + documents: './packages/ocom/ui-staff-shared/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + # Cellix core base type defs (static array for rolldown bundling) './packages/cellix/graphql-core/src/schema/base-type-defs.generated.ts': plugins: diff --git a/knip.json b/knip.json index 130ffcb7f..e3de3d8c4 100644 --- a/knip.json +++ b/knip.json @@ -24,6 +24,11 @@ "project": ["src/**/*.ts"], "ignore": ["**/graphql-tools-scalars.ts"] }, + "packages/cellix/serenity-framework": { + "project": ["src/**/*.ts"], + "ignore": ["**/graphql-tools-scalars.ts"], + "ignoreDependencies": ["@testing-library/react", "playwright", "react"] + }, "packages/cellix/mongoose-seedwork": { "entry": ["src/index.ts"], "project": ["src/**/*.ts"], @@ -62,20 +67,11 @@ "project": ["src/**/*.{ts,cjs}"] }, "packages/ocom-verification/verification-shared": { - "entry": [ - "src/formatters/index.ts", - "src/helpers/index.ts", - "src/pages/index.ts", - "src/pages/adapters/jsdom-adapter.ts", - "src/pages/adapters/playwright-adapter.ts", - "src/servers/index.ts", - "src/settings/index.ts", - "src/test-data/index.ts" - ], + "entry": ["src/abilities/index.ts", "src/pages/index.ts", "src/test-data/index.ts"], "project": ["src/**/*.ts"] }, "packages/ocom-verification/acceptance-api": { - "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts"], + "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts", "src/shared/abilities/index.ts"], "project": ["src/**/*.ts"], "ignoreBinaries": ["report"], "ignoreUnresolved": ["progress-bar"] @@ -83,11 +79,11 @@ "packages/ocom-verification/acceptance-ui": { "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts"], "project": ["src/**/*.{ts,tsx,mjs}"], - "ignoreUnresolved": ["progress-bar"], - "ignore": ["src/shared/support/ui/**"] + "ignore": ["src/shared/support/ui/**"], + "ignoreUnresolved": ["progress-bar"] }, "packages/ocom-verification/e2e-tests": { - "entry": ["cucumber.js", "src/world.ts", "src/contexts/**/step-definitions/**/*.steps.ts", "src/shared/support/**/*.ts"], + "entry": ["cucumber.js", "src/world.ts", "src/contexts/**/step-definitions/**/*.steps.ts", "src/shared/environment/**/*.ts", "src/shared/abilities/**/*.ts"], "project": ["src/**/*.ts"], "ignoreUnresolved": ["progress-bar"] }, diff --git a/packages/cellix/config-vitest/package.json b/packages/cellix/config-vitest/package.json index bdc653832..5592556dc 100644 --- a/packages/cellix/config-vitest/package.json +++ b/packages/cellix/config-vitest/package.json @@ -16,7 +16,7 @@ "dependencies": { "@cellix/config-typescript": "workspace:*", "@storybook/addon-vitest": "^9.1.20", - "@vitest/browser-playwright": "^4.1.2", + "@vitest/browser-playwright": "catalog:", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/cellix/serenity-framework/.gitignore b/packages/cellix/serenity-framework/.gitignore new file mode 100644 index 000000000..53c37a166 --- /dev/null +++ b/packages/cellix/serenity-framework/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/packages/cellix/serenity-framework/README.md b/packages/cellix/serenity-framework/README.md new file mode 100644 index 000000000..bf628eeeb --- /dev/null +++ b/packages/cellix/serenity-framework/README.md @@ -0,0 +1,160 @@ +# @cellix/serenity-framework + +Reusable Serenity/JS verification framework primitives for Cellix packages. + +This package is intentionally app-agnostic. It provides adapters, a generic Serenity cast, Cucumber utilities, managed worlds, and server lifecycle infrastructure; consumers provide page objects, selectors, app paths, schemas, services, seed data, and environment-specific values. + +## Page adapters + +Page objects should depend on `PageAdapter`, not directly on happy-dom or Playwright: + +```ts +import { AdapterBackedPageObject, type PageAdapter } from '@cellix/serenity-framework/pages'; + +class CommunityPage extends AdapterBackedPageObject { + constructor(adapter: PageAdapter) { + super(adapter); + } + + async createCommunity(name: string): Promise { + await this.adapter.getByPlaceholder('Name').fill(name); + await this.adapter.getByRole('button', { name: /Create/i }).click(); + } +} +``` + +Use the runtime-specific adapter at the edge of the test package: + +```ts +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +``` + +## Server composition + +Compose only the servers a suite needs by chaining `addServer` and `addUiPortal`. The framework starts them in dependency order (servers whose `dependsOn` is satisfied start in parallel), resets per-scenario state, sets up the browser when a UI portal exists, and tears everything down. Each application registers its own set — fewer or more servers — without changing the framework. + +```ts +import { E2EInfrastructure } from '@cellix/serenity-framework/infrastructure/e2e'; +import { MongoMemoryTestServer, ProcessTestServer } from '@cellix/serenity-framework/servers'; + +export const infrastructure = E2EInfrastructure + .create({ + // Shared across every portal context; baseURL is supplied per portal. + browserContextOptions: { ignoreHTTPSErrors: true }, + }) + .addServer('mongo', () => new MongoMemoryTestServer({ dbName, port, replSetName, seedData }), { + resetForScenario: (server) => (server as MongoMemoryTestServer).resetForScenario(), + }) + .addServer('auth', () => createAuthServer()) + .addServer( + 'api', + (ctx) => + new ProcessTestServer({ + serverName: 'Api', + executable: 'pnpm', + spawnArgs: ['run', process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev'], + cwd: '/repo/apps/api', + extraEnv: () => ({ COSMOSDB_CONNECTION_STRING: ctx.server('mongo').getConnectionString() }), + getUrl: () => 'https://api.localhost:1355/api/graphql', + readyMarker: 'Functions:', + }), + { dependsOn: ['mongo'] }, + ) + .addUiPortal('community', () => createCommunityPortal()) + .addUiPortal('staff', () => createStaffPortal()); + +await infrastructure.ensureStarted(); +await infrastructure.resetScenarioState(); +await infrastructure.stopAll(); + +// Each portal carries its own context recipe — baseURL is that portal's URL — +// so a scenario opens a context for whichever portal it needs, symmetrically: +const staffContext = await infrastructure.newPortalContext('staff'); +``` + +A server factory receives a context so a dependent server (such as the API) can read a started dependency's runtime state (such as the database connection string). Each UI portal owns its browser-context recipe, so `newPortalContext(name)` scopes navigation to that portal without any portal being special-cased. The framework never imports app paths or app-specific environment helpers — pass those into the factories from the consumer package. + +## API acceptance infrastructure + +API-only acceptance suites use the smaller infrastructure manager. It composes any consumer-owned `TestServer` implementations without launching a browser, and owns dependency-ordered startup, scenario reset, and shutdown. + +```ts +import { ApiInfrastructure } from '@cellix/serenity-framework/infrastructure/api'; +import { MongoMemoryTestServer } from '@cellix/serenity-framework/servers'; + +export const infrastructure = ApiInfrastructure + .create() + .addServer('mongo', () => new MongoMemoryTestServer({ dbName, port, replSetName, seedData }), { + resetForScenario: (server) => (server as MongoMemoryTestServer).resetForScenario(), + }) + .addServer( + 'graphql', + (ctx) => createGraphqlServer(ctx.server('mongo').getConnectionString()), + { dependsOn: ['mongo'] }, + ); + +// Suites without a database can register only their API server. +const apiOnly = ApiInfrastructure.create() + .addServer('graphql', () => createGraphqlServer()); +``` + +## Managed worlds + +Use a managed world when the suite does not need custom Cucumber world methods. The framework starts infrastructure, validates state, engages the cast, and resets scenario state. + +```ts +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; + +export const ApiWorld = registerManagedSerenityWorld({ + infrastructure, + validateState: (state) => { + if (!state.apiUrl) throw new Error('API URL was not initialized'); + }, + createCast: (state) => + new SerenityCast({ + useNotepad: true, + abilities: [() => new GraphQLClient({ apiUrl: state.apiUrl ?? '' })], + }), +}); +``` + +## DOM (happy-dom) helpers + +Component-level acceptance tests run against an in-process DOM provided by +happy-dom. Preload the DOM setup and asset-loader hooks before any module that +imports `react-dom`, so React binds its event system to the happy-dom +environment. Both run as Node `--import` preloads, which is order-independent: + +```sh +NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' cucumber-js +``` + +Include `@cellix/serenity-framework/src/dom/css-module-types.d.ts` in tsconfig +when component imports include CSS modules. + +## Rendering components through actors + +Give actors the `RenderInDom` ability and let page objects read their root +element from the actor, instead of threading a container through world state or +task parameters. This is the in-process DOM counterpart to a browser +`BrowseTheWeb` ability, so component acceptance tests and browser E2E tests share +the same actor-centric shape. The ability unmounts the rendered tree when the +scenario ends. + +```ts +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; + +// cast: grant every actor the ability +new SerenityCast({ useNotepad: true, abilities: [() => new RenderInDom()] }); + +// Given: render through the actor +await actor.attemptsTo(Render.component(, { wrapper: withProviders() })); + +// task/question: build the page object from the actor's container +const page = new LoginPage(new DomPageAdapter(RenderInDom.as(actor).container)); +``` diff --git a/packages/cellix/serenity-framework/manifest.md b/packages/cellix/serenity-framework/manifest.md new file mode 100644 index 000000000..9dae46cf9 --- /dev/null +++ b/packages/cellix/serenity-framework/manifest.md @@ -0,0 +1,64 @@ +# manifest.md - @cellix/serenity-framework + +## Purpose + +Provide reusable Serenity/JS, Cucumber, page-adapter, and test-server framework primitives that Cellix consumers can compose with app-specific pages, step definitions, services, URLs, and data. + +## Scope + +This package owns generic verification infrastructure only: + +- Serenity task, cast, browser-ability, and in-process DOM render-ability primitives +- Cucumber data-table, lifecycle, screenshot, and managed-world helpers +- Runtime-agnostic page adapter contracts and in-process DOM (happy-dom) / Playwright adapter implementations +- DOM globals (via happy-dom), CSS module declarations, asset-loader hooks, and generic React render helpers for component acceptance tests +- Adapter-backed page-object base contracts +- Timeout utilities +- Configurable process, Apollo GraphQL, and Mongo memory server lifecycle utilities +- API acceptance and browser E2E infrastructure managers that compose consumer-owned server factories; the E2E manager starts a freely-composed, dependency-ordered server set and the browser + +## Non-goals + +- OCOM-specific page objects, selectors, scenarios, seed data, application services, GraphQL schemas, app paths, or environment variable names +- Opinionated Cucumber step definitions +- Production server orchestration + +## Public API shape + +- `@cellix/serenity-framework/serenity`: `TaskStep` +- `@cellix/serenity-framework/cucumber`: `ActorName`, `GherkinDataTable`, lifecycle hook helpers +- `@cellix/serenity-framework/cucumber/screenshot`: browser screenshot-on-failure hook helpers +- `@cellix/serenity-framework/pages`: adapter contracts and page-object base types +- `@cellix/serenity-framework/pages/dom`: `DomPageAdapter` +- `@cellix/serenity-framework/pages/playwright`: `PlaywrightPageAdapter` +- `@cellix/serenity-framework/clients/graphql`: `GraphQLClient` +- `@cellix/serenity-framework/dom/setup`: DOM global bootstrap side-effect module (happy-dom) +- `@cellix/serenity-framework/dom/register-asset-loader`: asset-loader registration side-effect module +- `@cellix/serenity-framework/dom/render-in-dom`: `RenderInDom` ability and `Render` interaction for rendering components through actors +- `@cellix/serenity-framework/dom/css-modules`: package-owned CSS module declaration target +- `@cellix/serenity-framework/serenity`: `TaskStep`, `SerenityCast` +- `@cellix/serenity-framework/serenity/browser`: `BrowseTheWeb` +- `@cellix/serenity-framework/infrastructure/api`: API acceptance infrastructure manager with MongoDB options, optional Mongoose service management, and an API server factory +- `@cellix/serenity-framework/infrastructure/e2e`: browser E2E infrastructure manager that composes a dependency-ordered server set via chainable `addServer`/`addUiPortal` and sets up the browser +- `@cellix/serenity-framework/servers`: generic server lifecycle classes and interfaces +- `@cellix/serenity-framework/settings`: timeout helpers + +## Package boundaries + +The package must not import from `@ocom/*`, `@ocom-verification/*`, `apps/*`, or local OCOM path helpers. Consumers pass app-specific values through options objects, descriptors, factories, or callbacks. + +## Dependencies / relationships + +Downstream consumers in this monorepo are expected to include `@ocom-verification/acceptance-api`, `@ocom-verification/acceptance-ui`, and `@ocom-verification/e2e-tests`. + +## Testing strategy + +Prefer public-entrypoint tests that exercise observable behavior through sectioned exports. Do not test private implementation details or deep-import package internals. + +## Documentation obligations + +Keep `README.md` consumer-facing and package-centric. Meaningful public exports require TSDoc that explains purpose, options, return values, side effects, errors, and usage where helpful. + +## Release-readiness standards + +Build and test this package plus affected verification consumers before treating the package as ready for external npm use. Any public export removal or behavioral incompatibility requires explicit semver review. diff --git a/packages/cellix/serenity-framework/package.json b/packages/cellix/serenity-framework/package.json new file mode 100644 index 000000000..50f49683c --- /dev/null +++ b/packages/cellix/serenity-framework/package.json @@ -0,0 +1,155 @@ +{ + "name": "@cellix/serenity-framework", + "version": "1.0.0", + "description": "Reusable Serenity/JS verification framework primitives for Cellix packages", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src/dom/css-module-types.d.ts" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./clients/graphql": { + "types": "./dist/clients/graphql-client.d.ts", + "default": "./dist/clients/graphql-client.js" + }, + "./cucumber": { + "types": "./dist/cucumber/index.d.ts", + "default": "./dist/cucumber/index.js" + }, + "./cucumber/actor-name": { + "types": "./dist/cucumber/actor-name.d.ts", + "default": "./dist/cucumber/actor-name.js" + }, + "./cucumber/gherkin-data-table": { + "types": "./dist/cucumber/gherkin-data-table.d.ts", + "default": "./dist/cucumber/gherkin-data-table.js" + }, + "./cucumber/screenshot": { + "types": "./dist/cucumber/screenshot-hooks.d.ts", + "default": "./dist/cucumber/screenshot-hooks.js" + }, + "./formatters/agent": { + "types": "./dist/formatters/agent-formatter.d.ts", + "default": "./dist/formatters/agent-formatter.js" + }, + "./infrastructure": { + "types": "./dist/infrastructure/index.d.ts", + "default": "./dist/infrastructure/index.js" + }, + "./infrastructure/api": { + "types": "./dist/infrastructure/api-infrastructure.d.ts", + "default": "./dist/infrastructure/api-infrastructure.js" + }, + "./infrastructure/e2e": { + "types": "./dist/infrastructure/e2e-infrastructure.d.ts", + "default": "./dist/infrastructure/e2e-infrastructure.js" + }, + "./pages": { + "types": "./dist/pages/index.d.ts", + "default": "./dist/pages/index.js" + }, + "./pages/dom": { + "types": "./dist/pages/adapters/dom-adapter.d.ts", + "default": "./dist/pages/adapters/dom-adapter.js" + }, + "./pages/playwright": { + "types": "./dist/pages/adapters/playwright-adapter.d.ts", + "default": "./dist/pages/adapters/playwright-adapter.js" + }, + "./dom/setup": { + "types": "./dist/dom/setup.d.ts", + "default": "./dist/dom/setup.js" + }, + "./dom/register-asset-loader": { + "types": "./dist/dom/register-asset-loader.d.ts", + "default": "./dist/dom/register-asset-loader.js" + }, + "./dom/asset-loader-hooks": { + "types": "./dist/dom/asset-loader-hooks.d.ts", + "default": "./dist/dom/asset-loader-hooks.js" + }, + "./dom/render-in-dom": { + "types": "./dist/dom/render-in-dom.d.ts", + "default": "./dist/dom/render-in-dom.js" + }, + "./dom/css-modules": { + "types": "./src/dom/css-module-types.d.ts", + "default": "./dist/dom/css-modules.js" + }, + "./serenity": { + "types": "./dist/serenity/index.d.ts", + "default": "./dist/serenity/index.js" + }, + "./serenity/browser": { + "types": "./dist/serenity/browser.d.ts", + "default": "./dist/serenity/browser.js" + }, + "./servers": { + "types": "./dist/servers/index.d.ts", + "default": "./dist/servers/index.js" + }, + "./settings": { + "types": "./dist/settings/index.d.ts", + "default": "./dist/settings/index.js" + } + }, + "scripts": { + "prebuild": "pnpm run lint", + "build": "tsgo --build", + "clean": "rimraf dist tsconfig.tsbuildinfo && tsgo --build --clean", + "lint": "biome lint", + "format": "biome format --write", + "format:check": "biome format .", + "test": "vitest run --silent --reporter=dot", + "test:coverage": "vitest run --coverage --silent --reporter=dot", + "test:watch": "vitest" + }, + "dependencies": { + "@apollo/server": "catalog:", + "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", + "@cucumber/cucumber": "catalog:", + "@cucumber/messages": "catalog:", + "@happy-dom/global-registrator": "^20.9.0", + "@serenity-js/core": "catalog:", + "graphql": "catalog:", + "graphql-depth-limit": "^1.1.0", + "happy-dom": "^20.9.0", + "mongodb": "catalog:" + }, + "peerDependencies": { + "@testing-library/react": ">=16.0.0", + "playwright": ">=1.50.0", + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@testing-library/react": { + "optional": true + }, + "playwright": { + "optional": true + }, + "react": { + "optional": true + } + }, + "devDependencies": { + "@cellix/config-typescript": "workspace:*", + "@cellix/config-vitest": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/graphql-depth-limit": "^1.1.0", + "@types/node": "catalog:", + "@types/react": "^19.1.8", + "@vitest/coverage-istanbul": "catalog:", + "playwright": "catalog:", + "react": "^19.1.0", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cellix/serenity-framework/src/clients/graphql-client.test.ts b/packages/cellix/serenity-framework/src/clients/graphql-client.test.ts new file mode 100644 index 000000000..67ee4081f --- /dev/null +++ b/packages/cellix/serenity-framework/src/clients/graphql-client.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GraphQLClient } from './index.ts'; + +describe('GraphQLClient', () => { + it('posts GraphQL operations with configured headers', async () => { + const fetcher = vi.fn(async () => Response.json({ data: { ok: true } })); + const client = new GraphQLClient({ + apiUrl: 'https://api.example.test/graphql', + fetch: fetcher as typeof fetch, + headers: { Authorization: 'Bearer test' }, + }); + + const response = await client.execute<{ ok: boolean }>('query Test { ok }', { id: 1 }); + + expect(response.data.ok).toBe(true); + expect(fetcher).toHaveBeenCalledWith( + 'https://api.example.test/graphql', + expect.objectContaining({ + body: JSON.stringify({ query: 'query Test { ok }', variables: { id: 1 } }), + headers: { + Authorization: 'Bearer test', + 'Content-Type': 'application/json', + }, + method: 'POST', + }), + ); + }); + + it('throws when GraphQL errors are returned', async () => { + const fetcher = vi.fn(async () => Response.json({ data: {}, errors: [{ message: 'Nope' }] })); + const client = new GraphQLClient({ + apiUrl: 'https://api.example.test/graphql', + fetch: fetcher as typeof fetch, + }); + + await expect(client.execute('query Test { ok }')).rejects.toThrow('Nope'); + }); +}); diff --git a/packages/cellix/serenity-framework/src/clients/graphql-client.ts b/packages/cellix/serenity-framework/src/clients/graphql-client.ts new file mode 100644 index 000000000..7aaa5076f --- /dev/null +++ b/packages/cellix/serenity-framework/src/clients/graphql-client.ts @@ -0,0 +1,107 @@ +import { Ability } from '@serenity-js/core'; + +/** GraphQL error shape returned by common GraphQL HTTP servers. */ +export interface GraphQLResponseError { + /** Human-readable error message. */ + message: string; +} + +/** Result returned from {@link GraphQLClient.execute}. */ +export interface GraphQLResponse = Record> { + /** GraphQL response data. */ + data: TData; + + /** Optional GraphQL errors returned by the server. */ + errors?: GraphQLResponseError[]; +} + +/** Options used to create a GraphQL Serenity ability. */ +export interface GraphQLClientOptions { + /** GraphQL HTTP endpoint URL. */ + apiUrl: string; + + /** Headers applied to every request. */ + headers?: Record | (() => Record); + + /** Fetch implementation. Defaults to `globalThis.fetch`. */ + fetch?: typeof fetch; +} + +/** + * Serenity ability for executing GraphQL operations over HTTP. + * + * Consumers provide the endpoint and any app-specific headers, such as test + * authorization tokens. GraphQL errors are raised as JavaScript `Error`s so + * Screenplay questions and tasks fail the scenario clearly. + */ +export class GraphQLClient extends Ability { + private readonly apiUrl: string; + private readonly fetcher: typeof fetch; + private readonly headers: Record | (() => Record) | undefined; + + /** + * @param options Endpoint, headers, and optional fetch implementation. + */ + constructor(options: GraphQLClientOptions) { + super(); + this.apiUrl = options.apiUrl; + this.headers = options.headers; + this.fetcher = options.fetch ?? globalThis.fetch; + } + + /** + * Create a GraphQL ability for a specific endpoint. + * + * @param apiUrl GraphQL HTTP endpoint URL. + * @param headers Optional static or lazy headers applied to each request. + */ + static at(apiUrl: string, headers?: Record | (() => Record)): GraphQLClient { + return new GraphQLClient({ apiUrl, ...(headers && { headers }) }); + } + + /** + * Execute a GraphQL query or mutation. + * + * @param query GraphQL document text. + * @param variables Variables supplied to the operation. + * @throws Error when the HTTP response is not OK or the GraphQL result contains errors. + */ + async execute = Record>(query: string, variables: Record = {}): Promise> { + const response = await this.fetcher(this.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.resolveHeaders(), + }, + body: JSON.stringify({ query, variables }), + }); + + let result: GraphQLResponse; + try { + result = (await response.json()) as GraphQLResponse; + } catch (parseError) { + if (!response.ok) { + throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`); + } + throw parseError; + } + + if (result.errors?.length) { + throw new Error(result.errors.map((error) => error.message ?? 'Unknown error').join('; ')); + } + + if (!response.ok) { + throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`); + } + + return result; + } + + private resolveHeaders(): Record { + if (!this.headers) { + return {}; + } + + return typeof this.headers === 'function' ? this.headers() : this.headers; + } +} diff --git a/packages/cellix/serenity-framework/src/clients/index.ts b/packages/cellix/serenity-framework/src/clients/index.ts new file mode 100644 index 000000000..c30e09bc1 --- /dev/null +++ b/packages/cellix/serenity-framework/src/clients/index.ts @@ -0,0 +1,2 @@ +export type { GraphQLClientOptions, GraphQLResponse, GraphQLResponseError } from './graphql-client.ts'; +export { GraphQLClient } from './graphql-client.ts'; diff --git a/packages/cellix/serenity-framework/src/cucumber/actor-name.ts b/packages/cellix/serenity-framework/src/cucumber/actor-name.ts new file mode 100644 index 000000000..1cc4dba15 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/actor-name.ts @@ -0,0 +1,22 @@ +/** Options used when resolving actor references from Gherkin text. */ +export interface ActorNameResolutionOptions { + /** Name used when a pronoun is supplied. Defaults to `Alice`. */ + defaultName?: string; +} + +const pronounPattern = /^(she|he|they)$/i; + +/** + * Resolver object for actor names found in Gherkin steps. + */ +export const ActorName = { + /** + * Resolve pronouns such as `she`, `he`, or `they` to a default actor name. + * + * @param actorName Name or pronoun captured from a Gherkin step. + * @param options Optional default name configuration. + */ + resolve(actorName: string, options: ActorNameResolutionOptions = {}): string { + return pronounPattern.test(actorName) ? (options.defaultName ?? 'Alice') : actorName; + }, +} as const; diff --git a/packages/cellix/serenity-framework/src/cucumber/cucumber.test.ts b/packages/cellix/serenity-framework/src/cucumber/cucumber.test.ts new file mode 100644 index 000000000..a800484fc --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/cucumber.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { ActorName, GherkinDataTable } from './index.ts'; + +describe('ActorName', () => { + it('resolves pronouns to the configured default actor name', () => { + expect(ActorName.resolve('she', { defaultName: 'Casey' })).toBe('Casey'); + expect(ActorName.resolve('Morgan', { defaultName: 'Casey' })).toBe('Morgan'); + }); +}); + +describe('GherkinDataTable', () => { + it('returns a typed rows hash', () => { + const dataTable = { + rowsHash: () => ({ name: 'Evergreen', status: 'Active' }), + }; + + const row = GherkinDataTable.from(dataTable as never).rowsHash<{ name: string; status: string }>(); + + expect(row.name).toBe('Evergreen'); + expect(row.status).toBe('Active'); + }); +}); diff --git a/packages/cellix/serenity-framework/src/cucumber/gherkin-data-table.ts b/packages/cellix/serenity-framework/src/cucumber/gherkin-data-table.ts new file mode 100644 index 000000000..dd084aa0a --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/gherkin-data-table.ts @@ -0,0 +1,29 @@ +import type { DataTable } from '@cucumber/cucumber'; + +/** + * Typed wrapper around a Cucumber `DataTable`. + */ +export class GherkinDataTable { + /** + * @param dataTable Cucumber data table received by a step definition. + */ + constructor(private readonly dataTable: DataTable) {} + + /** + * Return `rowsHash()` as a caller-provided object shape. + * + * @typeParam T Shape expected by the step definition. + */ + rowsHash(): T { + return this.dataTable.rowsHash() as T; + } + + /** + * Wrap a Cucumber data table. + * + * @param dataTable Cucumber data table received by a step definition. + */ + static from(dataTable: DataTable): GherkinDataTable { + return new GherkinDataTable(dataTable); + } +} diff --git a/packages/cellix/serenity-framework/src/cucumber/index.ts b/packages/cellix/serenity-framework/src/cucumber/index.ts new file mode 100644 index 000000000..8f284ebb2 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/index.ts @@ -0,0 +1,7 @@ +export type { ActorNameResolutionOptions } from './actor-name.ts'; +export { ActorName } from './actor-name.ts'; +export { GherkinDataTable } from './gherkin-data-table.ts'; +export type { WorldLifecycleHooks } from './lifecycle-hooks.ts'; +export { registerWorldLifecycleHooks } from './lifecycle-hooks.ts'; +export type { ManagedSerenityWorldInfrastructure, ManagedSerenityWorldOptions } from './world.ts'; +export { createManagedSerenityWorldClass, ManagedSerenityWorld, registerManagedSerenityWorld } from './world.ts'; diff --git a/packages/cellix/serenity-framework/src/cucumber/lifecycle-hooks.ts b/packages/cellix/serenity-framework/src/cucumber/lifecycle-hooks.ts new file mode 100644 index 000000000..25ed667eb --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/lifecycle-hooks.ts @@ -0,0 +1,47 @@ +import { After, AfterAll, Before, type ITestCaseHookParameter, type IWorld, setDefaultTimeout } from '@cucumber/cucumber'; + +/** Lifecycle callbacks used by {@link registerWorldLifecycleHooks}. */ +export interface WorldLifecycleHooks { + /** Scenario timeout in milliseconds. */ + scenarioTimeout?: number; + + /** Optional timeout for the before hook. */ + beforeTimeout?: number; + + /** Optional timeout for the after hook. */ + afterTimeout?: number; + + /** Called before each scenario. */ + before?: (world: TWorld) => Promise | void; + + /** Called after each scenario. */ + after?: (world: TWorld, parameter: ITestCaseHookParameter) => Promise | void; + + /** Called once after all scenarios complete. */ + afterAll?: () => Promise | void; +} + +/** + * Register common Cucumber world lifecycle hooks. + * + * @param hooks Hook callbacks and timeouts for a test package. + */ +export function registerWorldLifecycleHooks(hooks: WorldLifecycleHooks): void { + if (hooks.scenarioTimeout) { + setDefaultTimeout(hooks.scenarioTimeout); + } + + Before(hooks.beforeTimeout === undefined ? {} : { timeout: hooks.beforeTimeout }, async function (this: IWorld) { + await hooks.before?.(this as TWorld); + }); + + After(hooks.afterTimeout === undefined ? {} : { timeout: hooks.afterTimeout }, async function (this: IWorld, parameter: ITestCaseHookParameter) { + await hooks.after?.(this as TWorld, parameter); + }); + + if (hooks.afterAll) { + AfterAll(async () => { + await hooks.afterAll?.(); + }); + } +} diff --git a/packages/cellix/serenity-framework/src/cucumber/screenshot-hooks.ts b/packages/cellix/serenity-framework/src/cucumber/screenshot-hooks.ts new file mode 100644 index 000000000..3743da140 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/screenshot-hooks.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { After, type ITestCaseHookParameter, type IWorld, Status } from '@cucumber/cucumber'; +import { BrowseTheWeb } from '../serenity/browse-the-web.ts'; + +/** Options used by {@link registerScreenshotOnFailureHook}. */ +export interface ScreenshotOnFailureOptions { + /** Directory where screenshots should be written. */ + reportsDir: string; + + /** Whether to capture screenshots on failure. Defaults to `false`. */ + enabled?: boolean; + + /** Ability provider. Defaults to `BrowseTheWeb.current()`. */ + getBrowseTheWeb?: () => BrowseTheWeb | undefined; +} + +/** + * Register a Cucumber `After` hook that captures a Playwright screenshot on failure. + * + * The hook is best-effort: screenshot failures are swallowed so the original + * scenario failure remains the primary signal. + * + * @param options Screenshot directory and optional browser ability provider. + */ +export function registerScreenshotOnFailureHook(options: ScreenshotOnFailureOptions): void { + if (!options.enabled) return; + + After(async function (this: IWorld, { result, pickle }: ITestCaseHookParameter) { + if (result?.status !== Status.FAILED) { + return; + } + + try { + const browseTheWeb = options.getBrowseTheWeb?.() ?? BrowseTheWeb.current(); + if (!browseTheWeb) { + return; + } + + const reportsDir = resolve(options.reportsDir); + mkdirSync(reportsDir, { recursive: true }); + + const safeName = pickle.name.replaceAll(/[^a-zA-Z0-9-_]/g, '_').slice(0, 80); + const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); + const screenshotPath = join(reportsDir, `${safeName}-${timestamp}.png`); + + await browseTheWeb.page.screenshot({ path: screenshotPath, fullPage: true }); + this.attach(readFileSync(screenshotPath), 'image/png'); + } catch { + /* Screenshot capture is best-effort. */ + } + }); +} diff --git a/packages/cellix/serenity-framework/src/cucumber/world.test.ts b/packages/cellix/serenity-framework/src/cucumber/world.test.ts new file mode 100644 index 000000000..ae6f7ff67 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/world.test.ts @@ -0,0 +1,25 @@ +import type { Cast } from '@serenity-js/core'; +import { describe, expect, it, vi } from 'vitest'; +import { ManagedSerenityWorld } from './world.ts'; + +describe('ManagedSerenityWorld', () => { + it('starts infrastructure, validates state, creates the cast, and resets during cleanup', async () => { + const state = { apiUrl: 'https://api.test/graphql' }; + const infrastructure = { + ensureStarted: vi.fn(), + getState: vi.fn(() => state), + resetScenarioState: vi.fn(), + }; + const validateState = vi.fn(); + const createCast = vi.fn(() => ({ prepare: vi.fn() }) as unknown as Cast); + const world = new ManagedSerenityWorld({ attach: vi.fn(), log: vi.fn(), link: vi.fn(), parameters: {} }, { createCast, infrastructure, validateState }); + + await world.init(); + await world.cleanup(); + + expect(infrastructure.ensureStarted).toHaveBeenCalledOnce(); + expect(validateState).toHaveBeenCalledWith(state); + expect(createCast).toHaveBeenCalledWith(state); + expect(infrastructure.resetScenarioState).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cellix/serenity-framework/src/cucumber/world.ts b/packages/cellix/serenity-framework/src/cucumber/world.ts new file mode 100644 index 000000000..23e40cacf --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/world.ts @@ -0,0 +1,88 @@ +import { type IWorldOptions, setWorldConstructor, World } from '@cucumber/cucumber'; +import { type Cast, engage } from '@serenity-js/core'; + +/** Infrastructure shape consumed by managed Serenity worlds. */ +export interface ManagedSerenityWorldInfrastructure { + /** Start the suite infrastructure before the scenario uses actors. */ + ensureStarted: () => Promise; + + /** Reset mutable scenario state after each scenario. */ + resetScenarioState?: () => Promise; + + /** Stop suite infrastructure after all scenarios. */ + stopAll?: () => Promise; + + /** Return state needed to construct the scenario cast. */ + getState: () => TState; +} + +/** Options used by {@link ManagedSerenityWorld}. */ +export interface ManagedSerenityWorldOptions { + /** Infrastructure object that owns server and browser lifecycle. */ + infrastructure: ManagedSerenityWorldInfrastructure; + + /** Builds the cast after infrastructure has started. */ + createCast: (state: TState) => Cast; + + /** Optional state assertion run before the cast is engaged. */ + validateState?: (state: TState) => void; +} + +/** + * Base Cucumber world that wires infrastructure state into Serenity/JS. + * + * Extend this class when a suite needs app-specific world methods, or use + * {@link createManagedSerenityWorldClass} when the suite only needs `init` and + * `cleanup`. Consumers supply configuration; the repeated startup, cast + * engagement, and scenario reset pattern stays in the framework. + */ +export class ManagedSerenityWorld extends World { + /** + * @param options Cucumber world options. + * @param config Infrastructure and cast configuration. + */ + constructor( + options: IWorldOptions, + private readonly config: ManagedSerenityWorldOptions, + ) { + super(options); + } + + /** Start infrastructure and engage a Serenity cast for the scenario. */ + async init(): Promise { + await this.config.infrastructure.ensureStarted(); + const state = this.config.infrastructure.getState(); + this.config.validateState?.(state); + engage(this.config.createCast(state)); + } + + /** Reset scenario state through the configured infrastructure. */ + async cleanup(): Promise { + await this.config.infrastructure.resetScenarioState?.(); + } +} + +/** + * Create a Cucumber world class from managed Serenity world configuration. + * + * @param config Infrastructure and cast configuration. + */ +export function createManagedSerenityWorldClass(config: ManagedSerenityWorldOptions): typeof ManagedSerenityWorld { + return class ConfiguredManagedSerenityWorld extends ManagedSerenityWorld { + /** Create the configured world. */ + constructor(options: IWorldOptions) { + super(options, config); + } + }; +} + +/** + * Register a managed Serenity world with Cucumber and return the class. + * + * @param config Infrastructure and cast configuration. + */ +export function registerManagedSerenityWorld(config: ManagedSerenityWorldOptions): typeof ManagedSerenityWorld { + const WorldClass = createManagedSerenityWorldClass(config); + setWorldConstructor(WorldClass); + return WorldClass; +} diff --git a/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts new file mode 100644 index 000000000..f6652fb22 --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts @@ -0,0 +1,54 @@ +/** + * ESM loader hooks that intercept CSS, image, and other non-JS imports so + * they resolve to empty modules instead of throwing in Node.js. + * + * Usage: `NODE_OPTIONS='--import @cellix/serenity-framework/dom/register-asset-loader' cucumber-js` + */ + +const ASSET_RE = /\.(css|less|scss|sass|svg|png|jpe?g|gif|webp|woff2?|ttf|eot|ico)$/i; + +/** Minimal loader context needed by the asset hook. */ +export interface AssetLoaderResolveContext { + /** URL of the module importing the asset, supplied by Node's loader API. */ + parentURL?: string; +} + +/** Result returned by Node's ESM resolve hook. */ +export interface AssetLoaderResolveResult { + /** Whether the loader chain should stop at this result. */ + shortCircuit?: boolean; + + /** Resolved module URL. */ + url: string; +} + +/** Next resolver supplied by Node's ESM loader chain. */ +export type NextAssetLoaderResolve = (specifier: string, context: AssetLoaderResolveContext) => Promise; + +/** + * Resolve CSS, image, font, and Ant Design ESM imports for component acceptance tests. + * + * Asset imports resolve to empty JavaScript modules. Ant Design `antd/es/*` + * imports are redirected to `antd/lib/*` when possible because many Node-based + * component tests execute through CommonJS-compatible package output. + */ +export async function resolve(specifier: string, context: AssetLoaderResolveContext, nextResolve: NextAssetLoaderResolve): Promise { + if (ASSET_RE.test(specifier)) { + return { + shortCircuit: true, + url: `data:text/javascript,export default ''`, + }; + } + + // Redirect antd/es/* to antd/lib/* for CJS/ESM compatibility in Node.js + if (specifier.includes('antd/es/')) { + const redirected = specifier.replace('antd/es/', 'antd/lib/'); + try { + return await nextResolve(redirected, context); + } catch { + // fall through to default + } + } + + return nextResolve(specifier, context); +} diff --git a/packages/cellix/serenity-framework/src/dom/css-module-types.d.ts b/packages/cellix/serenity-framework/src/dom/css-module-types.d.ts new file mode 100644 index 000000000..6b7d0666d --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/css-module-types.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record; + export default classes; +} diff --git a/packages/cellix/serenity-framework/src/dom/css-modules.ts b/packages/cellix/serenity-framework/src/dom/css-modules.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/css-modules.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cellix/serenity-framework/src/dom/register-asset-loader.ts b/packages/cellix/serenity-framework/src/dom/register-asset-loader.ts new file mode 100644 index 000000000..9601fa9eb --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/register-asset-loader.ts @@ -0,0 +1,10 @@ +/** + * Registers the asset-loader ESM hooks so CSS/image imports resolve + * without errors in Node.js. + * + * Use via NODE_OPTIONS: + * `NODE_OPTIONS='--import @cellix/serenity-framework/dom/register-asset-loader'`. + */ +import { register } from 'node:module'; + +register(new URL('./asset-loader-hooks.js', import.meta.url).href, import.meta.url); diff --git a/packages/cellix/serenity-framework/src/dom/render-in-dom.ts b/packages/cellix/serenity-framework/src/dom/render-in-dom.ts new file mode 100644 index 000000000..066c7acc6 --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/render-in-dom.ts @@ -0,0 +1,116 @@ +import { Ability, type Discardable, Interaction } from '@serenity-js/core'; +import { type RenderResult, render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +/** Wraps a rendered React element before it is mounted (providers, routing, theme). */ +export type ReactRenderWrapper = (children: ReactElement) => ReactElement; + +/** Options accepted when rendering a component through {@link RenderInDom}. */ +export interface RenderComponentOptions { + /** Optional wrapper supplying providers such as routing, theme, or GraphQL. */ + wrapper?: ReactRenderWrapper; +} + +/** + * Serenity ability that renders React components into the active in-process DOM + * and owns the resulting container for the lifetime of a scenario. + * + * This is the in-process DOM counterpart to a browser `BrowseTheWeb` ability: + * page objects obtain their root element from the actor's ability rather than + * receiving a container through world state or task parameters, so component + * acceptance tests and browser E2E tests share the same actor-centric shape. + * + * The ability is {@link Discardable}; Serenity unmounts the rendered tree when + * the actor is dismissed. Suites that do not rely on Serenity actor dismissal + * can also call {@link unmount} from a test-runner teardown hook. + * + * @example + * ```ts + * const cast = new SerenityCast({ useNotepad: true, abilities: [() => new RenderInDom()] }); + * + * // in a step: + * await actor.attemptsTo(Render.component(, { wrapper: withProviders() })); + * const page = new LoginPage(new DomPageAdapter(RenderInDom.as(actor).container)); + * ``` + */ +export class RenderInDom extends Ability implements Discardable { + private rendered: RenderResult | undefined; + + /** + * Create a render ability with no component mounted yet. + * + * Declared explicitly to widen Serenity's `protected` ability constructor to + * `public`, so the cast can instantiate it and `RenderInDom.as(actor)` resolves. + */ + // biome-ignore lint/complexity/noUselessConstructor: widens the inherited protected constructor to public. + constructor() { + super(); + } + + /** + * Render a React element, unmounting any element previously rendered by this + * ability so scenarios do not leak DOM state. + * + * @param ui React element to render. + * @param options Optional provider wrapper. + * @returns Testing Library render result for the mounted component. + */ + render(ui: ReactElement, options?: RenderComponentOptions): RenderResult { + this.unmount(); + this.rendered = render(options?.wrapper ? options.wrapper(ui) : ui); + return this.rendered; + } + + /** + * Root element that scopes all page-object selections for the current render. + * + * @throws Error when no component has been rendered yet. + */ + get container(): HTMLElement { + return this.currentResult().container; + } + + /** + * Testing Library render result for the current render. + * + * @throws Error when no component has been rendered yet. + */ + get result(): RenderResult { + return this.currentResult(); + } + + /** Unmount the current render, if one exists. */ + unmount(): void { + this.rendered?.unmount(); + this.rendered = undefined; + } + + /** Unmount on actor dismissal. Invoked by Serenity when the scene finishes. */ + discard(): void { + this.unmount(); + } + + private currentResult(): RenderResult { + if (!this.rendered) { + throw new Error('RenderInDom: no component has been rendered — did the Given step run?'); + } + return this.rendered; + } +} + +/** + * Screenplay interactions for rendering components through {@link RenderInDom}. + */ +export const Render = { + /** + * Render a React component into the active DOM via the actor's + * {@link RenderInDom} ability. + * + * @param ui React element to render. + * @param options Optional provider wrapper. + */ + component: (ui: ReactElement, options?: RenderComponentOptions): Interaction => + Interaction.where('#actor renders a component', (actor) => { + RenderInDom.as(actor).render(ui, options); + }), +} as const; diff --git a/packages/cellix/serenity-framework/src/dom/setup.ts b/packages/cellix/serenity-framework/src/dom/setup.ts new file mode 100644 index 000000000..a88bc972c --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/setup.ts @@ -0,0 +1,28 @@ +/** + * DOM environment bootstrap for component-level acceptance tests. + * + * Registers a complete set of browser globals — `window`, `document`, + * `navigator`, the DOM constructor classes, and modern layout APIs such as + * `matchMedia`, `ResizeObserver`, and `IntersectionObserver` — onto the Node + * global object via + * {@link https://github.com/capricorn86/happy-dom | happy-dom}'s global + * registrator. + * + * happy-dom implements the layout/visual APIs that Ant Design and React Router + * touch at import or render time, so unlike jsdom this setup needs no manual + * polyfills. + * + * Load this module for its side effects before any module that imports + * `react-dom`, so React binds its event system to the happy-dom environment. + * Prefer a Node `--import` preload, which is order-independent: + * + * ```sh + * NODE_OPTIONS='--import @cellix/serenity-framework/dom/setup' cucumber-js + * ``` + * + * @packageDocumentation + */ + +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +GlobalRegistrator.register({ url: 'http://localhost:3000' }); diff --git a/packages/ocom-verification/verification-shared/src/formatters/agent-formatter.ts b/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts similarity index 77% rename from packages/ocom-verification/verification-shared/src/formatters/agent-formatter.ts rename to packages/cellix/serenity-framework/src/formatters/agent-formatter.ts index b2f8153fa..c06a3b36c 100644 --- a/packages/ocom-verification/verification-shared/src/formatters/agent-formatter.ts +++ b/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts @@ -4,27 +4,37 @@ import type { Envelope, TestCaseFinished, TestRunFinished, TestRunStarted, Times type ParsedTestSteps = ReturnType['testSteps']; const STATUS_ICONS: Record = { - PASSED: 'PASS', + AMBIGUOUS: 'AMBIG', FAILED: 'FAIL', - SKIPPED: 'SKIP', + PASSED: 'PASS', PENDING: 'PEND', + SKIPPED: 'SKIP', UNDEFINED: 'UNDEF', - AMBIGUOUS: 'AMBIG', UNKNOWN: '?', }; -function timestampToMs(ts: Timestamp): number { - return (ts.seconds ?? 0) * 1000 + Math.round((ts.nanos ?? 0) / 1_000_000); +function timestampToMs(timestamp: Timestamp): number { + return (timestamp.seconds ?? 0) * 1000 + Math.round((timestamp.nanos ?? 0) / 1_000_000); } +/** + * Condensed Cucumber formatter intended for agent-readable test output. + * + * The formatter logs failed and warning scenarios with a compact summary, then + * emits aggregate scenario counts and duration at the end of the run. + */ export default class AgentFormatter extends Formatter { - static override readonly documentation = 'Condensed formatter for AI coding agents — minimal, token-efficient output.'; + /** Formatter documentation shown by Cucumber. */ + static override readonly documentation = 'Condensed formatter for AI coding agents: minimal, token-efficient output.'; private testRunStarted: TestRunStarted | undefined; private issueCount = 0; private scenarioCount = 0; private readonly statusCounts: Record = {}; + /** + * @param options Cucumber formatter options. + */ constructor(options: IFormatterOptions) { super(options); options.eventBroadcaster.on('envelope', (envelope: Envelope) => this.parseEnvelope(envelope)); @@ -48,20 +58,19 @@ export default class AgentFormatter extends Formatter { this.statusCounts[statusKey] = (this.statusCounts[statusKey] ?? 0) + 1; const parsed = formatterHelpers.parseTestCaseAttempt({ - testCaseAttempt: attempt, snippetBuilder: this.snippetBuilder, supportCodeLibrary: this.supportCodeLibrary, + testCaseAttempt: attempt, }); const icon = STATUS_ICONS[statusKey] ?? '?'; const { name, sourceLocation } = parsed.testCase; - const loc = sourceLocation ? `${sourceLocation.uri}:${sourceLocation.line}` : ''; - + const location = sourceLocation ? `${sourceLocation.uri}:${sourceLocation.line}` : ''; const isIssue = formatterHelpers.isFailure(attempt.worstTestStepResult, testCaseFinished.willBeRetried) || formatterHelpers.isWarning(attempt.worstTestStepResult, testCaseFinished.willBeRetried); if (isIssue) { this.issueCount++; - this.log(`[${icon}] ${name} (${loc})\n`); + this.log(`[${icon}] ${name} (${location})\n`); this.logFailedSteps(parsed.testSteps); } } @@ -69,7 +78,9 @@ export default class AgentFormatter extends Formatter { private logFailedSteps(testSteps: ParsedTestSteps): void { for (const step of testSteps) { const stepStatus = String(step.result.status); - if (stepStatus === 'PASSED' || stepStatus === 'SKIPPED') continue; + if (stepStatus === 'PASSED' || stepStatus === 'SKIPPED') { + continue; + } const stepIcon = STATUS_ICONS[stepStatus] ?? '?'; const stepText = step.text ?? step.keyword?.trim() ?? '(hook)'; @@ -95,10 +106,7 @@ export default class AgentFormatter extends Formatter { private onTestRunFinished(testRunFinished: TestRunFinished): void { this.log('\n--- (Agent) Results ---\n'); - const parts: string[] = []; - for (const [status, count] of Object.entries(this.statusCounts)) { - parts.push(`${status}: ${count}`); - } + const parts = Object.entries(this.statusCounts).map(([status, count]) => `${status}: ${count}`); this.log(`Scenarios: ${this.scenarioCount} (${parts.join(', ')})\n`); if (this.testRunStarted?.timestamp && testRunFinished.timestamp) { @@ -106,10 +114,6 @@ export default class AgentFormatter extends Formatter { this.log(`Duration: ${ms}ms\n`); } - if (this.issueCount === 0) { - this.log('All scenarios passed.\n'); - } else { - this.log(`Issues: ${this.issueCount}\n`); - } + this.log(this.issueCount === 0 ? 'All scenarios passed.\n' : `Issues: ${this.issueCount}\n`); } } diff --git a/packages/cellix/serenity-framework/src/index.ts b/packages/cellix/serenity-framework/src/index.ts new file mode 100644 index 000000000..c9cd12c6c --- /dev/null +++ b/packages/cellix/serenity-framework/src/index.ts @@ -0,0 +1,11 @@ +export type { GraphQLClientOptions, GraphQLResponse, GraphQLResponseError } from './clients/index.ts'; +export { GraphQLClient } from './clients/index.ts'; +export type { ActorNameResolutionOptions } from './cucumber/actor-name.ts'; +export { ActorName } from './cucumber/actor-name.ts'; +export { GherkinDataTable } from './cucumber/gherkin-data-table.ts'; +export type { ManagedSerenityWorldInfrastructure, ManagedSerenityWorldOptions } from './cucumber/world.ts'; +export { createManagedSerenityWorldClass, ManagedSerenityWorld, registerManagedSerenityWorld } from './cucumber/world.ts'; +export * from './pages/index.ts'; +export * from './serenity/index.ts'; +export * from './servers/index.ts'; +export * from './settings/index.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/api-infrastructure.ts b/packages/cellix/serenity-framework/src/infrastructure/api-infrastructure.ts new file mode 100644 index 000000000..4c47b4817 --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/api-infrastructure.ts @@ -0,0 +1,257 @@ +import type { TestServer } from '../servers/test-server.ts'; + +/** Suite-level environment hooks for {@link ApiInfrastructure}. */ +export interface ApiInfrastructureOptions { + /** Suite environment setup, such as starting a local proxy. Runs before any server. */ + setupEnvironment?: () => Promise | void; + + /** Suite environment cleanup. Runs after all servers stop. */ + cleanupEnvironment?: () => Promise | void; +} + +/** Lookup passed to server factories so a server can read its already-started dependencies. */ +export interface ApiServerContext { + /** + * Resolve an already-started server by name. + * + * @typeParam TServer Concrete server type, for example `MongoMemoryTestServer`. + * @param name Name the dependency was registered with. + * @throws Error when the named server has not been created yet. Declare it in + * `dependsOn` so the framework starts it first. + */ + server(name: string): TServer; +} + +/** Factory that creates a server, optionally reading its dependencies from {@link ApiServerContext}. */ +export type ApiServerFactory = (context: ApiServerContext) => TestServer; + +/** Options accepted when registering a server with {@link ApiInfrastructure.addServer}. */ +export interface ApiServerOptions { + /** Names of servers that must be started before this one. */ + dependsOn?: string[]; + + /** Reset this server between scenarios, e.g. clear and reseed a database. */ + resetForScenario?: (server: TestServer) => Promise | void; +} + +/** State exposed by {@link ApiInfrastructure}. */ +export interface ApiInfrastructureState { + /** Every registered server, keyed by the name it was added with. */ + servers: Readonly>; +} + +interface ServerRegistration { + name: string; + factory: ApiServerFactory; + dependsOn: string[]; + resetForScenario?: (server: TestServer) => Promise | void; +} + +/** + * Lifecycle manager for API acceptance test suites. + * + * Servers are composed fluently with {@link addServer} rather than being fixed up + * front, so each consuming suite registers only the servers it needs — a database + * here, an ORM connection there, a GraphQL server on top — and the framework owns + * startup ordering, scenario reset, and shutdown. The manager is ignorant of what + * each server is: a Mongo memory server, a SQL server, an Apollo GraphQL server, + * or anything else implementing {@link TestServer}. A suite with no database, a + * non-Mongo database, or no GraphQL layer simply registers a different server set. + * + * Servers start in dependency waves: every server whose `dependsOn` is satisfied + * starts in parallel, then the next wave, and so on. A factory receives an + * {@link ApiServerContext} so a dependent server (for example a GraphQL server) can + * read a dependency's runtime state (for example a database connection string). + * + * @example + * ```ts + * // With a database and a GraphQL server: + * ApiInfrastructure.create() + * .addServer('mongo', () => new MongoMemoryTestServer({ dbName, port, replSetName, seedData }), { + * resetForScenario: (server) => (server as MongoMemoryTestServer).resetForScenario(), + * }) + * .addServer('graphql', (ctx) => createGraphqlServer(() => ctx.server('mongo').getUrl()), { + * dependsOn: ['mongo'], + * }); + * + * // Without a database: + * ApiInfrastructure.create().addServer('graphql', () => createGraphqlServer()); + * ``` + */ +export class ApiInfrastructure { + private readonly registrations: ServerRegistration[] = []; + private readonly created = new Map(); + private readonly startOrder: string[] = []; + private readonly context: ApiServerContext = { + server: (name: string): TServer => { + const server = this.created.get(name); + if (!server) { + throw new Error(`ApiInfrastructure: server '${name}' is not available — declare it in dependsOn so it starts first`); + } + return server as TServer; + }, + }; + + private environmentReady = false; + private shutdownHandlersRegistered = false; + + private constructor(private readonly options: ApiInfrastructureOptions) {} + + /** + * Create an API acceptance infrastructure manager. + * + * @param options Suite-environment setup. Defaults to an empty object. + */ + static create(options: ApiInfrastructureOptions = {}): ApiInfrastructure { + return new ApiInfrastructure(options); + } + + /** + * Register a server. + * + * @param name Unique server name, used by `dependsOn` and {@link ApiServerContext.server}. + * @param factory Creates the server, with access to already-started dependencies. + * @param options Dependencies and optional per-scenario reset. + */ + addServer(name: string, factory: ApiServerFactory, options: ApiServerOptions = {}): this { + if (this.registrations.some((registration) => registration.name === name)) { + throw new Error(`ApiInfrastructure: server '${name}' is already registered`); + } + + this.registrations.push({ + name, + factory, + dependsOn: options.dependsOn ?? [], + ...(options.resetForScenario && { resetForScenario: options.resetForScenario }), + }); + return this; + } + + /** Start the environment and all servers (in dependency order) if they are not already running. */ + async ensureStarted(): Promise { + this.assertDependenciesResolvable(); + + try { + await this.ensureEnvironment(); + await this.startServers(); + } catch (error) { + await this.stopAll(); + throw error; + } + } + + /** Reset mutable scenario state for every running server that opted into a per-scenario reset. */ + async resetScenarioState(): Promise { + for (const registration of this.registrations) { + const server = this.created.get(registration.name); + if (registration.resetForScenario && server?.isRunning()) { + await registration.resetForScenario(server); + } + } + } + + /** Stop every created server, including partial starts, then clean up the suite environment. */ + async stopAll(): Promise { + const tracked = new Set(this.startOrder); + const createdButUntracked = [...this.created.keys()].filter((name) => !tracked.has(name)).reverse(); + const stopOrder = [...createdButUntracked, ...[...this.startOrder].reverse()]; + + for (const name of stopOrder) { + await this.created + .get(name) + ?.stop() + .catch(() => undefined); + } + this.created.clear(); + this.startOrder.length = 0; + + if (this.environmentReady) { + await this.options.cleanupEnvironment?.(); + this.environmentReady = false; + } + } + + /** Return the current infrastructure state. */ + getState(): ApiInfrastructureState { + return { + servers: Object.fromEntries(this.created), + }; + } + + /** Register SIGINT and SIGTERM handlers that stop infrastructure before exiting. */ + registerProcessShutdownHandlers(): this { + if (this.shutdownHandlersRegistered) { + return this; + } + + this.shutdownHandlersRegistered = true; + const shutdown = (signal: NodeJS.Signals) => { + void this.stopAll().finally(() => { + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + return this; + } + + private assertDependenciesResolvable(): void { + const names = new Set(this.registrations.map((registration) => registration.name)); + for (const registration of this.registrations) { + for (const dependency of registration.dependsOn) { + if (!names.has(dependency)) { + throw new Error(`ApiInfrastructure: server '${registration.name}' depends on unknown server '${dependency}'`); + } + } + } + } + + private async ensureEnvironment(): Promise { + if (this.environmentReady) { + return; + } + + await this.options.setupEnvironment?.(); + this.environmentReady = true; + } + + private async startServers(): Promise { + const started = new Set(this.startOrder); + let remaining = this.registrations.filter((registration) => !started.has(registration.name)); + + while (remaining.length > 0) { + const wave = remaining.filter((registration) => registration.dependsOn.every((dependency) => started.has(dependency))); + if (wave.length === 0) { + throw new Error(`ApiInfrastructure: circular or unresolved dependencies among ${remaining.map((registration) => registration.name).join(', ')}`); + } + + const results = await Promise.allSettled(wave.map((registration) => this.startServer(registration))); + const failure = results.find((result) => result.status === 'rejected'); + if (failure) { + throw failure.reason; + } + + for (const registration of wave) { + started.add(registration.name); + } + remaining = remaining.filter((registration) => !started.has(registration.name)); + } + } + + private async startServer(registration: ServerRegistration): Promise { + let server = this.created.get(registration.name); + if (!server) { + server = registration.factory(this.context); + this.created.set(registration.name, server); + } + + if (!server.isRunning()) { + await server.start(); + } + + if (!this.startOrder.includes(registration.name)) { + this.startOrder.push(registration.name); + } + } +} diff --git a/packages/cellix/serenity-framework/src/infrastructure/e2e-infrastructure.ts b/packages/cellix/serenity-framework/src/infrastructure/e2e-infrastructure.ts new file mode 100644 index 000000000..d9d9e9514 --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/e2e-infrastructure.ts @@ -0,0 +1,405 @@ +import { type Browser, type BrowserContext, type BrowserContextOptions, chromium } from 'playwright'; +import { BrowseTheWeb } from '../serenity/browse-the-web.ts'; +import type { TestServer } from '../servers/test-server.ts'; + +/** State exposed by {@link E2EInfrastructure}. */ +export interface E2EInfrastructureState { + /** Every registered server, keyed by the name it was added with. */ + servers: Readonly>; + + /** Base URLs for every registered UI portal that has started. */ + uiPortalBaseUrls: Readonly>; + + /** Browser launched for the suite, when at least one UI portal is registered. */ + browser: Browser | undefined; + + /** Browser ability assigned to Serenity actors. */ + browseTheWeb: BrowseTheWeb | undefined; +} + +/** Lookup passed to server factories so a server can read its already-started dependencies. */ +export interface E2EServerContext { + /** + * Resolve an already-started server by name. + * + * @typeParam TServer Concrete server type, for example `MongoMemoryTestServer`. + * @param name Name the dependency was registered with. + * @throws Error when the named server has not been created yet. Declare it in + * `dependsOn` so the framework starts it first. + */ + server(name: string): TServer; +} + +/** Factory that creates a server, optionally reading its dependencies from {@link E2EServerContext}. */ +export type E2EServerFactory = (context: E2EServerContext) => TestServer; + +/** Options accepted when registering a server with {@link E2EInfrastructure.addServer}. */ +export interface E2EServerOptions { + /** Names of servers that must be started before this one. */ + dependsOn?: string[]; + + /** Reset this server between scenarios, e.g. clear and reseed a database. */ + resetForScenario?: (server: TestServer) => Promise | void; +} + +/** Options accepted when registering a UI portal with {@link E2EInfrastructure.addUiPortal}. */ +export interface E2EUiPortalOptions extends E2EServerOptions { + /** + * Playwright browser-context options used whenever this portal is browsed. + * `baseURL` defaults to the portal server's own URL, so navigation is scoped + * to this portal automatically. Merged over the suite-wide + * {@link E2EInfrastructureOptions.browserContextOptions}. + */ + contextOptions?: BrowserContextOptions; +} + +/** Options used by {@link E2EInfrastructure.create}. */ +export interface E2EInfrastructureOptions { + /** Suite environment setup, such as starting a local proxy. Runs before any server. */ + setupEnvironment?: () => Promise | void; + + /** Suite environment cleanup. Runs after all servers stop. */ + cleanupEnvironment?: () => Promise | void; + + /** Launch the browser in headless mode. Defaults to `true`. */ + headless?: boolean; + + /** + * Browser-context options shared by every portal context, such as + * `ignoreHTTPSErrors`. Each portal's own `baseURL` and `contextOptions` are + * merged over these, so this should not set a portal-specific `baseURL`. + */ + browserContextOptions?: BrowserContextOptions; +} + +interface ServerRegistration { + name: string; + factory: E2EServerFactory; + dependsOn: string[]; + resetForScenario?: (server: TestServer) => Promise | void; + contextOptions?: BrowserContextOptions; + isUiPortal: boolean; +} + +/** + * Lifecycle manager for browser E2E test suites. + * + * Servers are composed fluently with {@link addServer} and {@link addUiPortal} + * instead of being fixed up front, so each consuming application registers only + * the servers it needs — a database here, an auth server there, one or many UI + * portals — and the framework owns startup ordering, scenario reset, browser + * setup, and shutdown. + * + * Servers start in dependency waves: every server whose `dependsOn` is satisfied + * starts in parallel, then the next wave, and so on. A factory receives an + * {@link E2EServerContext} so a dependent server (for example an API) can read a + * dependency's runtime state (for example a database connection string). The + * browser is launched only when at least one UI portal is registered, and each + * portal carries its own browser-context recipe — its `baseURL` is the portal's + * own URL — so {@link newPortalContext} opens a context for any portal without a + * caller naming the URL. The first registered portal backs the default + * `BrowseTheWeb` ability exposed on the state. + * + * @example + * ```ts + * export const infrastructure = E2EInfrastructure + * .create({ browserContextOptions: { ignoreHTTPSErrors: true } }) + * .addServer('mongo', () => new MongoMemoryTestServer({ dbName, port, replSetName, seedData }), { + * resetForScenario: (server) => (server as MongoMemoryTestServer).resetForScenario(), + * }) + * .addServer('auth', () => createAuthServer()) + * .addServer('api', (ctx) => createApiServer(() => ctx.server('mongo').getConnectionString()), { + * dependsOn: ['mongo'], + * }) + * .addUiPortal('community', () => createCommunityPortal()) + * .addUiPortal('staff', () => createStaffPortal()); + * + * // Browse any portal — baseURL is that portal's own URL: + * const staffContext = await infrastructure.newPortalContext('staff'); + * ``` + */ +export class E2EInfrastructure { + private readonly registrations: ServerRegistration[] = []; + private readonly created = new Map(); + private readonly startOrder: string[] = []; + private readonly context: E2EServerContext = { + server: (name: string): TServer => { + const server = this.created.get(name); + if (!server) { + throw new Error(`E2EInfrastructure: server '${name}' is not available — declare it in dependsOn so it starts first`); + } + return server as TServer; + }, + }; + + private environmentReady = false; + private browser: Browser | undefined; + private browserContext: BrowserContext | undefined; + private browseTheWeb: BrowseTheWeb | undefined; + private shutdownHandlersRegistered = false; + + private constructor(private readonly options: E2EInfrastructureOptions) {} + + /** + * Create a browser E2E infrastructure manager. + * + * @param options Browser and suite-environment setup. Defaults to an empty object. + */ + static create(options: E2EInfrastructureOptions = {}): E2EInfrastructure { + return new E2EInfrastructure(options); + } + + /** + * Register a server. + * + * @param name Unique server name, used by `dependsOn` and {@link E2EServerContext.server}. + * @param factory Creates the server, with access to already-started dependencies. + * @param options Dependencies and optional per-scenario reset. + */ + addServer(name: string, factory: E2EServerFactory, options: E2EServerOptions = {}): this { + return this.register(name, factory, options, false); + } + + /** + * Register a UI portal server. Portals contribute a base URL to + * {@link E2EInfrastructureState.uiPortalBaseUrls} and gate browser startup. + * + * @param name Stable logical portal name, such as `community` or `staff`. + * @param factory Creates the portal server. + * @param options Dependencies, per-scenario reset, and portal-scoped browser-context options. + */ + addUiPortal(name: string, factory: E2EServerFactory, options: E2EUiPortalOptions = {}): this { + return this.register(name, factory, options, true); + } + + /** + * Open a fresh browser context scoped to a UI portal. `baseURL` is the + * portal's own URL, merged with the suite-wide and portal-specific context + * options, so callers navigate relative paths without naming the portal URL. + * + * @param name Name of a registered, started UI portal. + */ + async newPortalContext(name: string): Promise { + const browser = await this.ensureBrowser(); + return browser.newContext(this.portalContextOptions(name)); + } + + /** Start the environment, all servers (in dependency order), and the browser ability. */ + async ensureStarted(): Promise { + this.assertDependenciesResolvable(); + + try { + await this.ensureEnvironment(); + await this.startServers(); + if (this.hasUiPortal()) { + await this.ensureBrowserAbility(); + } + } catch (error) { + await this.stopAll(); + throw error; + } + } + + /** Reset mutable scenario state for every server that opted into a per-scenario reset. */ + async resetScenarioState(): Promise { + for (const registration of this.registrations) { + const server = this.created.get(registration.name); + if (registration.resetForScenario && server?.isRunning()) { + await registration.resetForScenario(server); + } + } + } + + /** Stop browser resources, every created server including partial starts, and the suite environment. */ + async stopAll(): Promise { + await this.closeBrowser(); + + const tracked = new Set(this.startOrder); + const createdButUntracked = [...this.created.keys()].filter((name) => !tracked.has(name)).reverse(); + const stopOrder = [...createdButUntracked, ...[...this.startOrder].reverse()]; + + for (const name of stopOrder) { + await this.created + .get(name) + ?.stop() + .catch(() => undefined); + } + this.created.clear(); + this.startOrder.length = 0; + + if (this.environmentReady) { + await this.options.cleanupEnvironment?.(); + this.environmentReady = false; + } + } + + /** Return the current infrastructure state. */ + getState(): E2EInfrastructureState { + const uiPortalBaseUrls: Record = {}; + for (const registration of this.registrations) { + const server = this.created.get(registration.name); + if (registration.isUiPortal && server) { + uiPortalBaseUrls[registration.name] = server.getUrl(); + } + } + + return { + servers: Object.fromEntries(this.created), + uiPortalBaseUrls, + browser: this.browser, + browseTheWeb: this.browseTheWeb, + }; + } + + /** Register SIGINT and SIGTERM handlers that stop infrastructure before exiting. */ + registerProcessShutdownHandlers(): this { + if (this.shutdownHandlersRegistered) { + return this; + } + + this.shutdownHandlersRegistered = true; + const shutdown = (signal: NodeJS.Signals) => { + void this.stopAll().finally(() => { + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + return this; + } + + private register(name: string, factory: E2EServerFactory, options: E2EUiPortalOptions, isUiPortal: boolean): this { + if (this.registrations.some((registration) => registration.name === name)) { + throw new Error(`E2EInfrastructure: server '${name}' is already registered`); + } + + this.registrations.push({ + name, + factory, + dependsOn: options.dependsOn ?? [], + ...(options.resetForScenario && { resetForScenario: options.resetForScenario }), + ...(options.contextOptions && { contextOptions: options.contextOptions }), + isUiPortal, + }); + return this; + } + + private hasUiPortal(): boolean { + return this.registrations.some((registration) => registration.isUiPortal); + } + + private assertDependenciesResolvable(): void { + const names = new Set(this.registrations.map((registration) => registration.name)); + for (const registration of this.registrations) { + for (const dependency of registration.dependsOn) { + if (!names.has(dependency)) { + throw new Error(`E2EInfrastructure: server '${registration.name}' depends on unknown server '${dependency}'`); + } + } + } + } + + private async ensureEnvironment(): Promise { + if (this.environmentReady) { + return; + } + + await this.options.setupEnvironment?.(); + this.environmentReady = true; + } + + private async startServers(): Promise { + const started = new Set(this.startOrder); + let remaining = this.registrations.filter((registration) => !started.has(registration.name)); + + while (remaining.length > 0) { + const wave = remaining.filter((registration) => registration.dependsOn.every((dependency) => started.has(dependency))); + if (wave.length === 0) { + throw new Error(`E2EInfrastructure: circular or unresolved dependencies among ${remaining.map((registration) => registration.name).join(', ')}`); + } + + const results = await Promise.allSettled(wave.map((registration) => this.startServer(registration))); + const failure = results.find((result) => result.status === 'rejected'); + if (failure) { + throw failure.reason; + } + + for (const registration of wave) { + started.add(registration.name); + } + remaining = remaining.filter((registration) => !started.has(registration.name)); + } + } + + private async startServer(registration: ServerRegistration): Promise { + let server = this.created.get(registration.name); + if (!server) { + server = registration.factory(this.context); + this.created.set(registration.name, server); + } + + if (!server.isRunning()) { + await server.start(); + } + + if (!this.startOrder.includes(registration.name)) { + this.startOrder.push(registration.name); + } + } + + private async closeBrowser(): Promise { + if (this.browseTheWeb) { + await this.browseTheWeb.close().catch(() => undefined); + this.browseTheWeb = undefined; + this.browserContext = undefined; + } else if (this.browserContext) { + await this.browserContext.close().catch(() => undefined); + this.browserContext = undefined; + } + + if (this.browser) { + await this.browser.close().catch(() => undefined); + this.browser = undefined; + } + } + + private async ensureBrowser(): Promise { + this.browser ??= await chromium.launch({ headless: this.options.headless ?? true }); + return this.browser; + } + + private portalContextOptions(name: string): BrowserContextOptions { + const server = this.created.get(name); + const registration = this.registrations.find((entry) => entry.name === name && entry.isUiPortal); + if (!server || !registration) { + throw new Error(`E2EInfrastructure: UI portal '${name}' is not a started portal`); + } + + return { baseURL: server.getUrl(), ...this.options.browserContextOptions, ...registration.contextOptions }; + } + + private async ensureBrowserAbility(): Promise { + await this.ensureBrowser(); + + if (this.browseTheWeb) { + return; + } + + const primaryPortal = this.registrations.find((registration) => registration.isUiPortal); + if (!primaryPortal) { + return; + } + + this.browserContext = await this.newPortalContext(primaryPortal.name); + const page = await this.browserContext.newPage(); + + try { + this.browseTheWeb = BrowseTheWeb.using(page, this.browserContext); + } catch (error) { + await this.browserContext.close().catch(() => undefined); + this.browserContext = undefined; + throw error; + } + } +} diff --git a/packages/cellix/serenity-framework/src/infrastructure/index.ts b/packages/cellix/serenity-framework/src/infrastructure/index.ts new file mode 100644 index 000000000..ffd562641 --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/index.ts @@ -0,0 +1,4 @@ +export type { ApiInfrastructureOptions, ApiInfrastructureState, ApiServerContext, ApiServerFactory, ApiServerOptions } from './api-infrastructure.ts'; +export { ApiInfrastructure } from './api-infrastructure.ts'; +export type { E2EInfrastructureOptions, E2EInfrastructureState, E2EServerContext, E2EServerFactory, E2EServerOptions, E2EUiPortalOptions } from './e2e-infrastructure.ts'; +export { E2EInfrastructure } from './e2e-infrastructure.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/infrastructure.test.ts b/packages/cellix/serenity-framework/src/infrastructure/infrastructure.test.ts new file mode 100644 index 000000000..7b32b5bcf --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/infrastructure.test.ts @@ -0,0 +1,254 @@ +import { type Browser, type BrowserContext, chromium, type Page } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('playwright', () => ({ + chromium: { launch: vi.fn() }, +})); + +import type { MongoMemoryTestServer } from '../servers/mongo-memory-test-server.ts'; +import type { TestServer } from '../servers/test-server.ts'; +import { ApiInfrastructure, E2EInfrastructure } from './index.ts'; + +class FakeServer implements TestServer { + startCalls = 0; + stopCalls = 0; + resetCalls = 0; + protected running = false; + + constructor(private readonly url: string) {} + + start(): Promise { + this.startCalls += 1; + this.running = true; + return Promise.resolve(); + } + + stop(): Promise { + this.stopCalls += 1; + this.running = false; + return Promise.resolve(); + } + + isRunning(): boolean { + return this.running; + } + + getUrl(): string { + return this.url; + } + + resetForScenario(): Promise { + this.resetCalls += 1; + return Promise.resolve(); + } +} + +class DeferredServer extends FakeServer { + constructor( + url: string, + private readonly startGate: Promise, + ) { + super(url); + } + + override async start(): Promise { + this.startCalls += 1; + await this.startGate; + this.running = true; + } +} + +class FailingServer extends FakeServer { + override start(): Promise { + this.startCalls += 1; + return Promise.reject(new Error('startup failed')); + } +} + +function mongoServer(url = 'mongodb://test'): FakeServer & MongoMemoryTestServer { + return new FakeServer(url) as FakeServer & MongoMemoryTestServer; +} + +function browserStubs() { + const page = { + close: vi.fn(), + isClosed: vi.fn(() => false), + } as unknown as Page; + const context = { + close: vi.fn(), + newPage: vi.fn(async () => page), + } as unknown as BrowserContext; + const browser = { + close: vi.fn(), + newContext: vi.fn(async () => context), + } as unknown as Browser; + + return { browser, context, page }; +} + +describe('ApiInfrastructure', () => { + it('starts servers in dependency order and exposes them by name, wiring the factory context', async () => { + const mongo = mongoServer(); + const graphQL = new FakeServer('http://127.0.0.1:4000/graphql'); + + let graphqlSawMongoUrl: string | undefined; + const infrastructure = ApiInfrastructure.create() + .addServer('mongo', () => mongo, { resetForScenario: (server) => (server as FakeServer).resetForScenario() }) + .addServer( + 'graphql', + (ctx) => { + graphqlSawMongoUrl = ctx.server('mongo').getUrl(); + return graphQL; + }, + { dependsOn: ['mongo'] }, + ); + + await infrastructure.ensureStarted(); + + expect(graphqlSawMongoUrl).toBe('mongodb://test'); + expect(Object.keys(infrastructure.getState().servers)).toEqual(['mongo', 'graphql']); + expect(graphQL.getUrl()).toBe('http://127.0.0.1:4000/graphql'); + expect(graphQL.startCalls).toBe(1); + }); + + it('runs a single server without a database when none is registered', async () => { + const graphQL = new FakeServer('http://127.0.0.1:4000/graphql'); + + const infrastructure = ApiInfrastructure.create().addServer('graphql', () => graphQL); + + await infrastructure.ensureStarted(); + await infrastructure.resetScenarioState(); + + expect(Object.keys(infrastructure.getState().servers)).toEqual(['graphql']); + expect(graphQL.getUrl()).toBe('http://127.0.0.1:4000/graphql'); + expect(graphQL.startCalls).toBe(1); + }); + + it('resets a server without restarting others between scenarios', async () => { + const mongo = mongoServer(); + const graphQL = new FakeServer('http://127.0.0.1:4000/graphql'); + const infrastructure = ApiInfrastructure.create() + .addServer('mongo', () => mongo, { resetForScenario: (server) => (server as FakeServer).resetForScenario() }) + .addServer('graphql', () => graphQL, { dependsOn: ['mongo'] }); + + await infrastructure.ensureStarted(); + await infrastructure.resetScenarioState(); + await infrastructure.ensureStarted(); + + expect(mongo.resetCalls).toBe(1); + expect(graphQL.startCalls).toBe(1); + }); + + it('rejects duplicate server names', () => { + const infrastructure = ApiInfrastructure.create().addServer('graphql', () => new FakeServer('http://127.0.0.1:4000/graphql')); + + expect(() => infrastructure.addServer('graphql', () => new FakeServer('http://127.0.0.1:4001/graphql'))).toThrow(/already registered/); + }); + + it('rejects an unknown dependency', async () => { + const infrastructure = ApiInfrastructure.create().addServer('graphql', () => new FakeServer('http://127.0.0.1:4000/graphql'), { dependsOn: ['mongo'] }); + + await expect(infrastructure.ensureStarted()).rejects.toThrow(/unknown server 'mongo'/); + }); + + it('waits for a failed startup wave to settle and stops every created server', async () => { + let releaseStart: () => void = () => undefined; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const slow = new DeferredServer('http://slow.test', startGate); + const failing = new FailingServer('http://failing.test'); + const infrastructure = ApiInfrastructure.create() + .addServer('slow', () => slow) + .addServer('failing', () => failing); + + const starting = expect(infrastructure.ensureStarted()).rejects.toThrow('startup failed'); + await vi.waitFor(() => expect(failing.startCalls).toBe(1)); + releaseStart(); + + await starting; + expect(slow.stopCalls).toBe(1); + expect(failing.stopCalls).toBe(1); + expect(slow.isRunning()).toBe(false); + expect(infrastructure.getState().servers).toEqual({}); + }); +}); + +describe('E2EInfrastructure', () => { + it('starts servers in dependency order, wires the factory context, and exposes all portal URLs', async () => { + const mongo = mongoServer(); + const azurite = new FakeServer('http://127.0.0.1:10000'); + const auth = new FakeServer('https://auth.test'); + const api = new FakeServer('https://api.test/api/graphql'); + const community = new FakeServer('https://community.test'); + const staff = new FakeServer('https://staff.test'); + const { browser } = browserStubs(); + vi.mocked(chromium.launch).mockResolvedValue(browser); + + let apiSawMongoUrl: string | undefined; + const infrastructure = E2EInfrastructure.create() + .addServer('mongo', () => mongo, { resetForScenario: (server) => (server as FakeServer).resetForScenario() }) + .addServer('azurite', () => azurite) + .addServer('auth', () => auth) + .addServer( + 'api', + (ctx) => { + apiSawMongoUrl = ctx.server('mongo').getUrl(); + return api; + }, + { dependsOn: ['mongo'] }, + ) + .addUiPortal('community', () => community) + .addUiPortal('staff', () => staff); + + await infrastructure.ensureStarted(); + await infrastructure.resetScenarioState(); + + expect(apiSawMongoUrl).toBe('mongodb://test'); + expect(mongo.resetCalls).toBe(1); + expect(infrastructure.getState().uiPortalBaseUrls).toEqual({ + community: 'https://community.test', + staff: 'https://staff.test', + }); + expect(api.startCalls).toBe(1); + expect(staff.startCalls).toBe(1); + }); + + it('creates the browser ability for the registered server set, without an app-specific shape', async () => { + const { browser } = browserStubs(); + vi.mocked(chromium.launch).mockResolvedValue(browser); + + // Deliberately a leaner set than the suite above: no Azurite server. + const infrastructure = E2EInfrastructure.create() + .addServer('mongo', () => mongoServer()) + .addServer('auth', () => new FakeServer('https://auth.test')) + .addServer('api', () => new FakeServer('https://api.test/api/graphql'), { dependsOn: ['mongo'] }) + .addUiPortal('community', () => new FakeServer('https://community.test')); + + await infrastructure.ensureStarted(); + + expect(infrastructure.getState().browseTheWeb).toBeDefined(); + }); + + it('waits for a failed startup wave to settle and stops every created server', async () => { + let releaseStart: () => void = () => undefined; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const slow = new DeferredServer('http://slow.test', startGate); + const failing = new FailingServer('http://failing.test'); + const infrastructure = E2EInfrastructure.create() + .addServer('slow', () => slow) + .addServer('failing', () => failing); + + const starting = expect(infrastructure.ensureStarted()).rejects.toThrow('startup failed'); + await vi.waitFor(() => expect(failing.startCalls).toBe(1)); + releaseStart(); + + await starting; + expect(slow.stopCalls).toBe(1); + expect(failing.stopCalls).toBe(1); + expect(slow.isRunning()).toBe(false); + expect(infrastructure.getState().servers).toEqual({}); + }); +}); diff --git a/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts similarity index 50% rename from packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts rename to packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts index 59c5cf3e1..8ce76571a 100644 --- a/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts @@ -1,5 +1,5 @@ import { act, fireEvent } from '@testing-library/react'; -import type { ElementHandle, PageAdapter, PageNavigationWaitUntil, PageUrlMatcher } from '../page-adapter.ts'; +import type { ElementHandle, ElementWaitOptions, PageAdapter, PageNavigationOptions, PageUrlMatcher } from '../page-adapter.ts'; function getGlobalDocument(container: Element): Document { return container.ownerDocument ?? document; @@ -12,8 +12,7 @@ function findLabelControl(container: Element, text: string): Element | null { if (matchingLabel) { const forId = matchingLabel.getAttribute('for'); if (forId) { - const doc = getGlobalDocument(container); - return doc.getElementById(forId); + return getGlobalDocument(container).getElementById(forId); } const wrappedControl = matchingLabel.querySelector('input, textarea, select, [role="textbox"], [role="combobox"], [role="checkbox"]'); @@ -22,21 +21,24 @@ function findLabelControl(container: Element, text: string): Element | null { } } - const ariaMatch = container.querySelector(`[aria-label="${text}"], [aria-label*="${text}"]`); - return ariaMatch; + return container.querySelector(`[aria-label="${text}"], [aria-label*="${text}"]`); } -class JsdomElementHandle implements ElementHandle { - constructor(private readonly el: Element | null) {} +/** + * Element handle backed by an in-process DOM `Element` (happy-dom or jsdom). + */ +export class DomElementHandle implements ElementHandle { + /** + * @param element Element to adapt, or `null` for a missing selection. + */ + constructor(private readonly element: Element | null) {} fill(value: string): Promise { - if (!this.el) return Promise.resolve(); - - if (!(this.el instanceof HTMLInputElement || this.el instanceof HTMLTextAreaElement)) { + if (!(this.element instanceof HTMLInputElement || this.element instanceof HTMLTextAreaElement)) { return Promise.resolve(); } - const input = this.el; + const input = this.element; const proto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; act(() => { @@ -51,150 +53,148 @@ class JsdomElementHandle implements ElementHandle { fireEvent.input(input, { target: { value } }); fireEvent.change(input, { target: { value } }); }); + return Promise.resolve(); } click(): Promise { - if (this.el) { - const el = this.el; + if (this.element) { + const element = this.element; act(() => { - fireEvent.click(el); + fireEvent.click(element); }); } return Promise.resolve(); } check(): Promise { - if (this.el instanceof HTMLInputElement) { - const el = this.el; + if (this.element instanceof HTMLInputElement) { + const element = this.element; act(() => { - fireEvent.click(el, { target: { checked: true } }); + fireEvent.click(element, { target: { checked: true } }); }); return Promise.resolve(); } - if (this.el) { - const el = this.el; - act(() => { - fireEvent.click(el); - }); - } - return Promise.resolve(); + return this.click(); } textContent(): Promise { - return Promise.resolve(this.el?.textContent ?? null); + return Promise.resolve(this.element?.textContent ?? null); } getAttribute(name: string): Promise { - return Promise.resolve(this.el?.getAttribute(name) ?? null); + return Promise.resolve(this.element?.getAttribute(name) ?? null); } isVisible(): Promise { - return Promise.resolve(this.el !== null); + return Promise.resolve(this.element !== null); } - waitFor(_options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise { + waitFor(_options?: ElementWaitOptions): Promise { return Promise.resolve(); } querySelector(selector: string): Promise { - const child = this.el?.querySelector(selector) ?? null; - return Promise.resolve(child ? new JsdomElementHandle(child) : null); + const child = this.element?.querySelector(selector) ?? null; + return Promise.resolve(child ? new DomElementHandle(child) : null); } querySelectorAll(selector: string): Promise { - if (!this.el) return Promise.resolve([]); - return Promise.resolve(Array.from(this.el.querySelectorAll(selector)).map((el) => new JsdomElementHandle(el))); + if (!this.element) { + return Promise.resolve([]); + } + return Promise.resolve(Array.from(this.element.querySelectorAll(selector)).map((element) => new DomElementHandle(element))); } } -export class JsdomPageAdapter implements PageAdapter { +/** + * Page adapter backed by an in-process DOM container element. + * + * Use this adapter in component-level Cucumber tests that render React into an + * in-process DOM (happy-dom or jsdom) while reusing the same page-object + * classes used by browser E2E tests. + */ +export class DomPageAdapter implements PageAdapter { + /** + * @param container Root element that scopes all selections for this page. + */ constructor(private readonly container: Element) {} getByPlaceholder(text: string): ElementHandle { - const el = this.container.querySelector(`[placeholder="${text}"], [placeholder*="${text}"]`); - return new JsdomElementHandle(el); + return new DomElementHandle(this.container.querySelector(`[placeholder="${text}"], [placeholder*="${text}"]`)); } getByLabel(text: string): ElementHandle { - return new JsdomElementHandle(findLabelControl(this.container, text)); + return new DomElementHandle(findLabelControl(this.container, text)); } getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { const candidates = Array.from(this.container.querySelectorAll(`[role="${role}"], ${role}`)); - const semanticMap: Record = { button: 'button', - textbox: 'input[type="text"], input:not([type]), textarea', - combobox: 'select, [role="combobox"]', checkbox: 'input[type="checkbox"], [role="checkbox"]', + combobox: 'select, [role="combobox"]', table: 'table', + textbox: 'input[type="text"], input:not([type]), textarea', }; const semanticSelector = semanticMap[role]; + if (semanticSelector) { - const semantic = Array.from(this.container.querySelectorAll(semanticSelector)); - for (const el of semantic) { - if (!candidates.includes(el)) candidates.push(el); + for (const element of Array.from(this.container.querySelectorAll(semanticSelector))) { + if (!candidates.includes(element)) { + candidates.push(element); + } } } const nameFilter = options?.name; if (nameFilter) { - const match = candidates.find((el) => { - const text = el.textContent ?? ''; - const ariaLabel = el.getAttribute('aria-label') ?? ''; - if (nameFilter instanceof RegExp) { - return nameFilter.test(text) || nameFilter.test(ariaLabel); - } - return text.includes(nameFilter) || ariaLabel.includes(nameFilter); + const match = candidates.find((element) => { + const textContent = element.textContent ?? ''; + const ariaLabel = element.getAttribute('aria-label') ?? ''; + return nameFilter instanceof RegExp ? nameFilter.test(textContent) || nameFilter.test(ariaLabel) : textContent.includes(nameFilter) || ariaLabel.includes(nameFilter); }); - return new JsdomElementHandle(match ?? null); + return new DomElementHandle(match ?? null); } - return new JsdomElementHandle(candidates[0] ?? null); + return new DomElementHandle(candidates[0] ?? null); } locator(selector: string): ElementHandle { - const el = this.container.querySelector(selector); - return new JsdomElementHandle(el); + return new DomElementHandle(this.container.querySelector(selector)); } locatorAll(selector: string): Promise { - return Promise.resolve(Array.from(this.container.querySelectorAll(selector)).map((el) => new JsdomElementHandle(el))); + return Promise.resolve(Array.from(this.container.querySelectorAll(selector)).map((element) => new DomElementHandle(element))); } getByText(text: string | RegExp, options?: { selector?: string }): ElementHandle { const scope = options?.selector ? (this.container.querySelector(options.selector) ?? this.container) : this.container; const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); let node: Node | null; - // biome-ignore lint/suspicious/noAssignInExpressions: walker pattern + // biome-ignore lint/suspicious/noAssignInExpressions: tree walkers expose the next node through assignment. while ((node = walker.nextNode())) { const content = node.textContent ?? ''; const matches = text instanceof RegExp ? text.test(content) : content.includes(text); if (matches && node.parentElement) { - return new JsdomElementHandle(node.parentElement); + return new DomElementHandle(node.parentElement); } } - return new JsdomElementHandle(null); + return new DomElementHandle(null); } - goto(url: string, _options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { - if (typeof globalThis !== 'undefined') { - globalThis.history.pushState({}, '', url); - } + goto(url: string, _options?: PageNavigationOptions): Promise { + globalThis.history?.pushState({}, '', url); return Promise.resolve(); } - waitForURL(_url: PageUrlMatcher, _options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { + waitForURL(_url: PageUrlMatcher, _options?: PageNavigationOptions): Promise { return Promise.resolve(); } url(): string { - if (typeof globalThis !== 'undefined') { - return globalThis.location.href; - } - return 'about:blank'; + return globalThis.location?.href ?? 'about:blank'; } waitForTimeout(_timeout: number): Promise { diff --git a/packages/ocom-verification/verification-shared/src/pages/adapters/playwright-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts similarity index 76% rename from packages/ocom-verification/verification-shared/src/pages/adapters/playwright-adapter.ts rename to packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts index fbb071706..8c4563fa6 100644 --- a/packages/ocom-verification/verification-shared/src/pages/adapters/playwright-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts @@ -1,7 +1,13 @@ import type { Locator as PlaywrightLocator, Page as PlaywrightPage } from 'playwright'; -import type { ElementHandle, PageAdapter, PageNavigationWaitUntil, PageUrlMatcher } from '../page-adapter.ts'; - -class PlaywrightElementHandle implements ElementHandle { +import type { ElementHandle, ElementWaitOptions, PageAdapter, PageNavigationOptions, PageUrlMatcher } from '../page-adapter.ts'; + +/** + * Element handle backed by a Playwright `Locator`. + */ +export class PlaywrightElementHandle implements ElementHandle { + /** + * @param locator Playwright locator to adapt to the framework element contract. + */ constructor(private readonly locator: PlaywrightLocator) {} async fill(value: string): Promise { @@ -28,7 +34,7 @@ class PlaywrightElementHandle implements ElementHandle { return this.locator.isVisible(); } - async waitFor(options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise { + async waitFor(options?: ElementWaitOptions): Promise { await this.locator.waitFor(options); } @@ -49,7 +55,16 @@ class PlaywrightElementHandle implements ElementHandle { } } +/** + * Page adapter backed by a Playwright `Page`. + * + * Use this adapter at the edge of an E2E test package, then pass it into + * app-specific page objects that depend only on {@link PageAdapter}. + */ export class PlaywrightPageAdapter implements PageAdapter { + /** + * @param page Playwright page used to resolve locators and navigation. + */ constructor(private readonly page: PlaywrightPage) {} getByPlaceholder(text: string): ElementHandle { @@ -84,11 +99,11 @@ export class PlaywrightPageAdapter implements PageAdapter { return new PlaywrightElementHandle(root.getByText(text).first()); } - async goto(url: string, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { + async goto(url: string, options?: PageNavigationOptions): Promise { await this.page.goto(url, options); } - async waitForURL(url: PageUrlMatcher, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { + async waitForURL(url: PageUrlMatcher, options?: PageNavigationOptions): Promise { await this.page.waitForURL(url as Parameters[0], options); } diff --git a/packages/cellix/serenity-framework/src/pages/index.ts b/packages/cellix/serenity-framework/src/pages/index.ts new file mode 100644 index 000000000..2daf84b4e --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/index.ts @@ -0,0 +1,12 @@ +export type { + ElementHandle, + ElementWaitOptions, + ElementWaitState, + PageAdapter, + PageAdapterMode, + PageNavigationOptions, + PageNavigationWaitUntil, + PageUrlMatcher, +} from './page-adapter.ts'; +export type { PageObject } from './page-object.ts'; +export { AdapterBackedPageObject } from './page-object.ts'; diff --git a/packages/cellix/serenity-framework/src/pages/page-adapter.ts b/packages/cellix/serenity-framework/src/pages/page-adapter.ts new file mode 100644 index 000000000..de24f5a37 --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/page-adapter.ts @@ -0,0 +1,103 @@ +/** + * Cross-runtime handle for a single element selected by a {@link PageAdapter}. + * + * Implementations may wrap a Playwright locator, a jsdom element, or another + * browser automation primitive. Page objects should use this interface instead + * of depending on a concrete runtime. + */ +export interface ElementHandle { + /** Fill an editable control with the supplied value. */ + fill(value: string): Promise; + + /** Click the element. */ + click(): Promise; + + /** Check a checkbox-like control. */ + check(): Promise; + + /** Read the element text content, or `null` when no element is available. */ + textContent(): Promise; + + /** Read an element attribute, or `null` when the attribute is missing. */ + getAttribute(name: string): Promise; + + /** Return whether the element is currently visible to the adapter runtime. */ + isVisible(): Promise; + + /** Wait for the element to enter a runtime-supported state. */ + waitFor(options?: ElementWaitOptions): Promise; + + /** Find the first child matching a CSS selector. */ + querySelector(selector: string): Promise; + + /** Find all children matching a CSS selector. */ + querySelectorAll(selector: string): Promise; +} + +/** Wait states supported by cross-runtime element handles. */ +export type ElementWaitState = 'visible' | 'hidden' | 'attached' | 'detached'; + +/** Options used when waiting for an element state. */ +export interface ElementWaitOptions { + /** Runtime-specific element state to wait for. */ + state?: ElementWaitState; + + /** Maximum wait time in milliseconds. */ + timeout?: number; +} + +/** Navigation lifecycle values shared with Playwright-compatible adapters. */ +export type PageNavigationWaitUntil = 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; + +/** URL matcher accepted by cross-runtime page adapters. */ +export type PageUrlMatcher = string | RegExp | ((url: URL) => boolean); + +/** Options used when navigating or waiting for a URL. */ +export interface PageNavigationOptions { + /** Maximum wait time in milliseconds. */ + timeout?: number; + + /** Navigation lifecycle state to wait for. */ + waitUntil?: PageNavigationWaitUntil; +} + +/** + * Runtime-neutral page API for app-specific page objects. + * + * Page objects consume this interface so the same page-object class can run in + * fast jsdom acceptance tests and full Playwright E2E tests. + */ +export interface PageAdapter { + /** Select an element by placeholder text. */ + getByPlaceholder(text: string): ElementHandle; + + /** Select a form control by visible or ARIA label text. */ + getByLabel(text: string): ElementHandle; + + /** Select an element by accessible role and optional accessible name. */ + getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle; + + /** Select the first element matching a CSS selector. */ + locator(selector: string): ElementHandle; + + /** Select all elements matching a CSS selector. */ + locatorAll(selector: string): Promise; + + /** Select the first element containing text, optionally scoped by selector. */ + getByText(text: string | RegExp, options?: { selector?: string }): ElementHandle; + + /** Navigate the current page-like runtime to a URL. */ + goto(url: string, options?: PageNavigationOptions): Promise; + + /** Wait for the current URL to match a string, regex, or predicate. */ + waitForURL(url: PageUrlMatcher, options?: PageNavigationOptions): Promise; + + /** Read the current page URL. */ + url(): string; + + /** Wait for a fixed duration. Prefer runtime events when possible. */ + waitForTimeout(timeout: number): Promise; +} + +/** Supported adapter runtime labels. */ +export type PageAdapterMode = 'dom' | 'playwright'; diff --git a/packages/cellix/serenity-framework/src/pages/page-object.test.ts b/packages/cellix/serenity-framework/src/pages/page-object.test.ts new file mode 100644 index 000000000..d2a7f2280 --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/page-object.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { AdapterBackedPageObject, type PageAdapter } from './index.ts'; + +class TestPage extends AdapterBackedPageObject { + get currentUrl(): string { + return this.adapter.url(); + } +} + +describe('AdapterBackedPageObject', () => { + it('keeps page objects bound to their runtime adapter', () => { + const adapter = { + url: () => 'https://example.test', + } as PageAdapter; + + const page = new TestPage(adapter); + + expect(page.adapter).toBe(adapter); + expect(page.currentUrl).toBe('https://example.test'); + }); +}); diff --git a/packages/cellix/serenity-framework/src/pages/page-object.ts b/packages/cellix/serenity-framework/src/pages/page-object.ts new file mode 100644 index 000000000..1b7f70801 --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/page-object.ts @@ -0,0 +1,38 @@ +import type { PageAdapter } from './page-adapter.ts'; + +/** + * Contract for page objects backed by a {@link PageAdapter}. + * + * The interface intentionally requires only the adapter relationship. Consumer + * packages define domain-specific methods and locators on their own page object + * classes while preserving the common adapter-based pattern. + */ +export interface PageObject { + /** Runtime-neutral adapter used by the page object. */ + readonly adapter: TAdapter; +} + +/** + * Base class for adapter-backed page objects. + * + * Extend this class when a page object should work against multiple runtimes, + * such as jsdom for acceptance UI tests and Playwright for browser E2E tests. + * + * @example + * ```ts + * class LoginPage extends AdapterBackedPageObject { + * async submit(email: string): Promise { + * await this.adapter.getByLabel('Email').fill(email); + * await this.adapter.getByRole('button', { name: /Sign in/i }).click(); + * } + * } + * ``` + */ +export abstract class AdapterBackedPageObject implements PageObject { + /** + * Create a page object backed by a runtime-specific adapter. + * + * @param adapter Adapter that performs DOM or browser operations. + */ + constructor(public readonly adapter: TAdapter) {} +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/abilities/browse-the-web.ts b/packages/cellix/serenity-framework/src/serenity/browse-the-web.ts similarity index 61% rename from packages/ocom-verification/e2e-tests/src/shared/abilities/browse-the-web.ts rename to packages/cellix/serenity-framework/src/serenity/browse-the-web.ts index 6763e0dea..5eb11f69d 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/abilities/browse-the-web.ts +++ b/packages/cellix/serenity-framework/src/serenity/browse-the-web.ts @@ -4,28 +4,54 @@ import type { BrowserContext, Page } from 'playwright'; const actorBrowserMap = new Map(); let fallbackInstance: BrowseTheWeb | undefined; +/** + * Serenity ability that exposes a Playwright page and browser context. + * + * The ability supports a current fallback page for single-browser test suites + * and optional actor registration for multi-actor scenarios. + */ export class BrowseTheWeb extends Ability { + /** Playwright page used by tasks and page adapters. */ readonly page: Page; private readonly context: BrowserContext; private actorName: string | undefined; + /** + * Create and activate a browser ability. + * + * @param page Playwright page assigned to the ability. + * @param context Playwright browser context that owns the page. + */ static using(page: Page, context: BrowserContext): BrowseTheWeb { const ability = new BrowseTheWeb(page, context); fallbackInstance = ability; return ability; } + /** + * Register this ability for a named actor. + * + * @param name Actor name used by Serenity/JS. + */ registerForActor(name: string): this { this.actorName = name; actorBrowserMap.set(name, this); return this; } + /** + * Resolve the browser ability for an actor. + * + * @param actor Serenity actor or ability host. + * @throws Error when no actor-specific or fallback browser ability exists. + */ static withActor(actor: UsesAbilities): BrowseTheWeb { const actorName = 'name' in actor ? (actor as Actor).name : undefined; if (actorName) { const perActor = actorBrowserMap.get(actorName); - if (perActor) return perActor; + if (perActor) { + return perActor; + } } if (!fallbackInstance) { @@ -34,6 +60,9 @@ export class BrowseTheWeb extends Ability { return fallbackInstance; } + /** + * Return the active fallback browser ability, if one has been registered. + */ static current(): BrowseTheWeb | undefined { return fallbackInstance; } @@ -44,10 +73,14 @@ export class BrowseTheWeb extends Ability { this.context = context; } + /** Browser context that owns the page. */ get browserContext(): BrowserContext { return this.context; } + /** + * Close only the current page and detach this ability from the registry. + */ async closePageOnly(): Promise { if (!this.page.isClosed()) { await this.page.close(); @@ -55,6 +88,9 @@ export class BrowseTheWeb extends Ability { this.detach(); } + /** + * Close the page and browser context, then detach this ability. + */ async close(): Promise { if (!this.page.isClosed()) { await this.page.close(); diff --git a/packages/cellix/serenity-framework/src/serenity/browser.ts b/packages/cellix/serenity-framework/src/serenity/browser.ts new file mode 100644 index 000000000..591c56df4 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/browser.ts @@ -0,0 +1 @@ +export { BrowseTheWeb } from './browse-the-web.ts'; diff --git a/packages/cellix/serenity-framework/src/serenity/cast.ts b/packages/cellix/serenity-framework/src/serenity/cast.ts new file mode 100644 index 000000000..16e694681 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/cast.ts @@ -0,0 +1,44 @@ +import { type Ability, type Actor, Cast, Notepad, TakeNotes } from '@serenity-js/core'; + +/** Factory that creates a Serenity ability for an actor. */ +export type SerenityAbilityFactory = (actor: Actor) => Ability; + +/** Options used by {@link SerenityCast}. */ +export interface SerenityCastOptions { + /** Ability factories added to every prepared actor. */ + abilities?: SerenityAbilityFactory[]; + + /** Whether each actor receives a Serenity notepad. */ + useNotepad: boolean; +} + +/** + * Generic Serenity cast for Cellix verification suites. + * + * Consumers provide the ability factories their suite needs. The framework + * supplies a single cast implementation so suites do not need local cast + * subclasses for common GraphQL, browser, or notepad-only actor setup. + */ +export class SerenityCast extends Cast { + private readonly abilities: SerenityAbilityFactory[]; + private readonly useNotepad: boolean; + + /** + * @param options Ability factories and notepad behavior. + */ + constructor(options: SerenityCastOptions) { + super(); + this.abilities = options.abilities ?? []; + this.useNotepad = options.useNotepad; + } + + /** + * Prepare an actor with the configured abilities. + * + * @param actor Actor created by Serenity/JS. + */ + prepare(actor: Actor): Actor { + const abilities = this.abilities.map((factory) => factory(actor)); + return this.useNotepad ? actor.whoCan(TakeNotes.using(Notepad.empty()), ...abilities) : actor.whoCan(...abilities); + } +} diff --git a/packages/cellix/serenity-framework/src/serenity/index.ts b/packages/cellix/serenity-framework/src/serenity/index.ts new file mode 100644 index 000000000..51a735837 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/index.ts @@ -0,0 +1,3 @@ +export type { SerenityAbilityFactory, SerenityCastOptions } from './cast.ts'; +export { SerenityCast } from './cast.ts'; +export { TaskStep } from './task-step.ts'; diff --git a/packages/cellix/serenity-framework/src/serenity/task-step.test.ts b/packages/cellix/serenity-framework/src/serenity/task-step.test.ts new file mode 100644 index 000000000..669d0b392 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/task-step.test.ts @@ -0,0 +1,16 @@ +import type { PerformsActivities } from '@serenity-js/core'; +import { describe, expect, it } from 'vitest'; +import { TaskStep } from './index.ts'; + +describe('TaskStep', () => { + it('executes the supplied action with the actor', async () => { + const actor = { name: 'Alice' } as unknown as PerformsActivities & { name: string }; + let observedActor: typeof actor | undefined; + + await new TaskStep('#actor does something useful', (currentActor) => { + observedActor = currentActor; + }).performAs(actor); + + expect(observedActor).toBe(actor); + }); +}); diff --git a/packages/cellix/serenity-framework/src/serenity/task-step.ts b/packages/cellix/serenity-framework/src/serenity/task-step.ts new file mode 100644 index 000000000..dbd38f41b --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/task-step.ts @@ -0,0 +1,29 @@ +import { type PerformsActivities, Task } from '@serenity-js/core'; + +/** + * Serenity task backed by an inline async action. + * + * Use `TaskStep` to keep domain step/task code expressive while avoiding small + * helper functions that only bridge Serenity's `Task` contract. + */ +export class TaskStep extends Task { + /** + * @param description Serenity report description for the task. + * @param action Action executed when the actor performs the task. + */ + constructor( + description: string, + private readonly action: (actor: TActor) => Promise | void, + ) { + super(description); + } + + /** + * Execute the configured action for the supplied actor. + * + * @param actor Actor provided by Serenity/JS. + */ + async performAs(actor: PerformsActivities): Promise { + await this.action(actor as TActor); + } +} diff --git a/packages/cellix/serenity-framework/src/servers/apollo-graphql-test-server.ts b/packages/cellix/serenity-framework/src/servers/apollo-graphql-test-server.ts new file mode 100644 index 000000000..625b57e5e --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/apollo-graphql-test-server.ts @@ -0,0 +1,94 @@ +import { ApolloServer, type BaseContext } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import type { GraphQLSchema, ValidationRule } from 'graphql'; +import depthLimit from 'graphql-depth-limit'; +import type { TestServer } from './test-server.ts'; + +const MAX_QUERY_DEPTH = 25; + +/** Options used by {@link ApolloGraphQLTestServer}. */ +export interface ApolloGraphQLTestServerOptions { + /** GraphQL schema served by Apollo. */ + schema: GraphQLSchema; + + /** Context factory passed to Apollo's standalone server. */ + context: Parameters>[1]['context']; + + /** Optional GraphQL validation rules. */ + validationRules?: ValidationRule[]; + + /** Whether batched HTTP requests are allowed. Defaults to `true`. */ + allowBatchedHttpRequests?: boolean; + + /** Whether Apollo introspection is enabled. Defaults to `false`. */ + introspection?: boolean; +} + +/** + * Generic in-process Apollo GraphQL server for acceptance tests. + * + * Consumers provide schema, context, validation rules, and app-specific service + * factories from outside the Cellix framework package. + */ +export class ApolloGraphQLTestServer implements TestServer { + private server: ApolloServer | null = null; + private url: string | null = null; + + /** + * @param options Apollo server contract supplied by the consumer. + */ + constructor(private readonly options: ApolloGraphQLTestServerOptions) {} + + /** + * Start the GraphQL server on the specified port, or a random port by default. + * + * @param port TCP port. Use `0` for any available port. + */ + async start(port = 0): Promise { + if (this.server) { + throw new Error('ApolloGraphQLTestServer already started'); + } + + this.server = new ApolloServer({ + allowBatchedHttpRequests: this.options.allowBatchedHttpRequests ?? true, + introspection: this.options.introspection ?? false, + schema: this.options.schema, + validationRules: [depthLimit(MAX_QUERY_DEPTH), ...(this.options.validationRules ?? [])], + }); + + const { url } = await startStandaloneServer(this.server, { + context: this.options.context, + listen: { port }, + }); + + this.url = url; + } + + /** Stop the Apollo server. */ + async stop(): Promise { + if (!this.server) { + return; + } + + await this.server.stop(); + this.server = null; + this.url = null; + } + + /** + * Return the server URL. + * + * @throws Error when the server has not started. + */ + getUrl(): string { + if (!this.url) { + throw new Error('ApolloGraphQLTestServer not started'); + } + return this.url; + } + + /** Return whether the server is active. */ + isRunning(): boolean { + return this.server !== null; + } +} diff --git a/packages/cellix/serenity-framework/src/servers/index.ts b/packages/cellix/serenity-framework/src/servers/index.ts new file mode 100644 index 000000000..995ce4500 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/index.ts @@ -0,0 +1,8 @@ +export type { ApolloGraphQLTestServerOptions } from './apollo-graphql-test-server.ts'; +export { ApolloGraphQLTestServer } from './apollo-graphql-test-server.ts'; +export type { MongoMemorySeedContext, MongoMemorySeedDataFunction, MongoMemoryTestServerOptions } from './mongo-memory-test-server.ts'; +export { MongoMemoryTestServer } from './mongo-memory-test-server.ts'; +export { createSpawnEnvironment } from './process-environment.ts'; +export type { ProcessHealthProbe, ProcessTestServerOptions } from './process-test-server.ts'; +export { ProcessTestServer } from './process-test-server.ts'; +export type { SeedDataFunction, TestServer } from './test-server.ts'; diff --git a/packages/cellix/serenity-framework/src/servers/mongo-memory-test-server.ts b/packages/cellix/serenity-framework/src/servers/mongo-memory-test-server.ts new file mode 100644 index 000000000..f75cae48f --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/mongo-memory-test-server.ts @@ -0,0 +1,122 @@ +import { type MongoMemoryReplicaSetConfig, type MongoMemoryReplicaSetDisposer, startMongoMemoryReplicaSet } from '@cellix/server-mongodb-memory-mock-seedwork'; +import { MongoClient } from 'mongodb'; +import type { SeedDataFunction, TestServer } from './test-server.ts'; + +/** Context supplied to Mongo seed functions. */ +export interface MongoMemorySeedContext { + /** MongoDB connection string. */ + connectionString: string; + + /** Database name used by the test server. */ + dbName: string; +} + +/** Seed function used by {@link MongoMemoryTestServer}. */ +export type MongoMemorySeedDataFunction = SeedDataFunction; + +/** Options used by {@link MongoMemoryTestServer}. */ +export interface MongoMemoryTestServerOptions { + /** Database name. */ + dbName: string; + + /** MongoDB port. */ + port: number; + + /** Replica set name. */ + replSetName: string; + + /** MongoDB binary version. */ + binaryVersion?: string; + + /** Optional seed function called after startup and reset. */ + seedData?: MongoMemorySeedDataFunction; +} + +/** + * Reusable in-memory MongoDB replica set for verification tests. + * + * The server is Cellix-only and does not attach application-specific Mongoose + * services. Consumers can seed data through the supplied callback. + */ +export class MongoMemoryTestServer implements TestServer { + private disposer: MongoMemoryReplicaSetDisposer | null = null; + private connectionString = ''; + + /** + * @param options Complete MongoDB memory replica set configuration. + */ + constructor(private readonly options: MongoMemoryTestServerOptions) {} + + /** Start the Mongo memory replica set. */ + async start(): Promise { + const config: MongoMemoryReplicaSetConfig = { + dbName: this.options.dbName, + port: this.options.port, + replSetName: this.options.replSetName, + ...(this.options.binaryVersion && { binaryVersion: this.options.binaryVersion }), + }; + + const { connectionString, disposer } = await startMongoMemoryReplicaSet(config); + this.disposer = disposer; + this.connectionString = connectionString; + await this.seed(); + } + + /** Return the MongoDB connection string. */ + getConnectionString(): string { + if (!this.connectionString) { + throw new Error('MongoMemoryTestServer not started'); + } + return this.connectionString; + } + + /** Alias for {@link getConnectionString}. */ + getUrl(): string { + return this.getConnectionString(); + } + + /** + * Clear all collections and re-run seed data. + * + * @param seedData Optional seed override for this reset. + */ + async resetForScenario(seedData?: MongoMemorySeedDataFunction): Promise { + if (!this.connectionString) { + throw new Error('MongoMemoryTestServer not started'); + } + + await clearDatabase({ connectionString: this.connectionString, dbName: this.options.dbName }); + await this.seed(seedData); + } + + /** Stop the replica set. */ + async stop(): Promise { + if (this.disposer) { + const disposer = this.disposer; + this.disposer = null; + await disposer.stop(); + } + this.connectionString = ''; + } + + /** Return whether the replica set is active. */ + isRunning(): boolean { + return this.disposer !== null; + } + + private async seed(seedData = this.options.seedData): Promise { + await seedData?.({ connectionString: this.connectionString, dbName: this.options.dbName }); + } +} + +async function clearDatabase(context: MongoMemorySeedContext): Promise { + const client = new MongoClient(context.connectionString); + try { + await client.connect(); + const db = client.db(context.dbName); + const collections = await db.listCollections({}, { nameOnly: true }).toArray(); + await Promise.all(collections.map((collection) => db.collection(collection.name).deleteMany({}))); + } finally { + await client.close(); + } +} diff --git a/packages/cellix/serenity-framework/src/servers/process-environment.ts b/packages/cellix/serenity-framework/src/servers/process-environment.ts new file mode 100644 index 000000000..7668a31df --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/process-environment.ts @@ -0,0 +1,11 @@ +/** + * Build a child-process environment while removing inherited Node loader hooks. + * + * This prevents test runner `NODE_OPTIONS` from leaking into app dev servers. + * + * @param overrides Environment variables applied after the current process env. + */ +export function createSpawnEnvironment(overrides: Record = {}): NodeJS.ProcessEnv { + const { NODE_OPTIONS: _ignored, ...baseEnv } = process.env; + return { ...baseEnv, ...overrides }; +} diff --git a/packages/cellix/serenity-framework/src/servers/process-test-server.test.ts b/packages/cellix/serenity-framework/src/servers/process-test-server.test.ts new file mode 100644 index 000000000..51f05e970 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/process-test-server.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { ProcessTestServer } from './index.ts'; + +async function waitUntil(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error(`Condition not met within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('ProcessTestServer', () => { + it('starts a process and trusts the ready marker when probing is disabled', async () => { + const server = new ProcessTestServer({ + serverName: 'marker-only server', + executable: process.execPath, + spawnArgs: ['-e', "console.log('READY'); setInterval(() => undefined, 1_000)"], + cwd: process.cwd(), + readyMarker: 'READY', + getUrl: () => 'http://unused.test', + probe: false, + shutdownTimeoutMs: 500, + }); + + try { + await server.start(); + + expect(server.isRunning()).toBe(true); + } finally { + await server.stop(); + } + }); + + it('stops reporting a process as running after it exits', async () => { + const server = new ProcessTestServer({ + serverName: 'short-lived server', + executable: process.execPath, + spawnArgs: ['-e', "console.log('READY'); setTimeout(() => process.exit(0), 20)"], + cwd: process.cwd(), + readyMarker: 'READY', + getUrl: () => 'http://unused.test', + probe: false, + shutdownTimeoutMs: 500, + }); + + await server.start(); + await waitUntil(() => !server.isRunning()); + + expect(server.isRunning()).toBe(false); + }); +}); diff --git a/packages/cellix/serenity-framework/src/servers/process-test-server.ts b/packages/cellix/serenity-framework/src/servers/process-test-server.ts new file mode 100644 index 000000000..6491e0732 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/process-test-server.ts @@ -0,0 +1,303 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { createSpawnEnvironment } from './process-environment.ts'; +import type { TestServer } from './test-server.ts'; + +/** Configuration for health probes used by {@link ProcessTestServer}. */ +export interface ProcessHealthProbe { + /** URL to probe after the ready marker is observed. */ + url: string | (() => string); + + /** Request options supplied to `fetch`. */ + requestInit?: RequestInit | (() => RequestInit); + + /** Predicate that decides whether the probe response is healthy. Defaults to `response.ok`. */ + isHealthy?: (response: Response) => boolean | Promise; +} + +/** Options used by {@link ProcessTestServer}. */ +export interface ProcessTestServerOptions { + /** Human-readable name used in error messages. */ + serverName: string; + + /** Executable to spawn. */ + executable: string | (() => string); + + /** Arguments supplied to the executable. */ + spawnArgs: string[] | (() => string[]); + + /** Working directory for the process. */ + cwd: string; + + /** Marker expected on stdout before health probing begins. */ + readyMarker: string | RegExp; + + /** URL exposed by the server. */ + getUrl: () => string; + + /** Additional process environment values. */ + extraEnv?: Record | (() => Record); + + /** Health probe configuration. Defaults to probing `getUrl()`. Use `false` to trust the ready marker. */ + probe?: ProcessHealthProbe | false; + + /** Maximum startup time in milliseconds. */ + startupTimeoutMs?: number | (() => number); + + /** Maximum graceful shutdown time in milliseconds. */ + shutdownTimeoutMs?: number | (() => number); + + /** Individual health probe timeout in milliseconds. */ + healthProbeTimeoutMs?: number | (() => number); + + /** Delay between health probes in milliseconds. */ + healthProbeIntervalMs?: number | (() => number); + + /** Return true when the server is already reachable before spawning. */ + isAlreadyRunning?: () => Promise; + + /** Treat an early process exit as an existing reusable server. */ + isReusableExit?: (stderrOutput: string) => boolean; +} + +/** + * Configurable child-process test server. + * + * Consumers pass app-specific commands, paths, URLs, and probes through the + * descriptor. The framework owns lifecycle, readiness, probing, and shutdown. + */ +export class ProcessTestServer implements TestServer { + private process: ChildProcess | null = null; + private startedByUs = false; + private readonly useDetachedProcessGroup = process.platform !== 'win32'; + + /** + * @param options Process descriptor and lifecycle settings. + */ + constructor(private readonly options: ProcessTestServerOptions) {} + + /** + * Start the process and wait for readiness. + */ + async start(): Promise { + if (this.process || this.startedByUs) { + return; + } + + if (await this.isAlreadyRunning()) { + return; + } + + const executable = this.value(this.options.executable); + const spawnArgs = this.value(this.options.spawnArgs); + if (!executable || !spawnArgs) { + throw new Error(`${this.options.serverName} requires an executable and spawn arguments`); + } + + this.process = spawn(executable, spawnArgs, { + cwd: this.options.cwd, + env: createSpawnEnvironment(this.value(this.options.extraEnv) ?? {}), + detached: this.useDetachedProcessGroup, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.startedByUs = true; + + try { + await this.waitForReady(); + } catch (error) { + await this.stop().catch(() => undefined); + throw error; + } + } + + /** + * Stop a process started by this instance. + */ + async stop(): Promise { + if (!this.process || !this.startedByUs) { + return; + } + + const childProcess = this.process; + this.process = null; + this.startedByUs = false; + + this.killProcess(childProcess, 'SIGINT'); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.killProcess(childProcess, 'SIGKILL'); + resolve(); + }, this.value(this.options.shutdownTimeoutMs) ?? 10_000); + + childProcess.on('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + + /** + * Return whether this instance currently owns a running child process. + */ + isRunning(): boolean { + return this.process !== null; + } + + /** + * Return the descriptor URL. + */ + getUrl(): string { + return this.options.getUrl(); + } + + private async isAlreadyRunning(): Promise { + if (this.options.isAlreadyRunning) { + return await this.options.isAlreadyRunning(); + } + if (this.options.probe === false) { + return false; + } + return await this.isProbeReadyWithin(this.value(this.options.healthProbeTimeoutMs) ?? 3_000); + } + + private waitForReady(): Promise { + return new Promise((resolve, reject) => { + const childProcess = this.process; + if (!childProcess) { + reject(new Error(`${this.options.serverName} process not started`)); + return; + } + + const startupTimeout = this.value(this.options.startupTimeoutMs) ?? 120_000; + const startupDeadline = Date.now() + startupTimeout; + const timeout = setTimeout(() => { + reject(new Error(`${this.options.serverName} did not start within ${startupTimeout}ms`)); + }, startupTimeout); + + let stderrOutput = ''; + let ready = false; + + const resolveWhenReachable = () => { + if (ready) { + return; + } + ready = true; + + this.waitForProbeReady(startupDeadline, startupTimeout) + .then(() => { + clearTimeout(timeout); + resolve(); + }) + .catch((error: unknown) => { + clearTimeout(timeout); + reject(error); + }); + }; + + childProcess.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + if (this.matchesReadyMarker(text)) { + resolveWhenReachable(); + } + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + stderrOutput += data.toString(); + }); + + childProcess.on('error', (error: Error) => { + clearTimeout(timeout); + this.process = null; + this.startedByUs = false; + reject(new Error(`${this.options.serverName} failed to start: ${error.message}`)); + }); + + childProcess.on('exit', (code, signal) => { + if (this.process === childProcess) { + this.process = null; + this.startedByUs = false; + } + if (ready) { + return; + } + clearTimeout(timeout); + + if (this.options.isReusableExit?.(stderrOutput)) { + resolve(); + return; + } + + reject(new Error(`${this.options.serverName} exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); + }); + }); + } + + private async waitForProbeReady(startupDeadline: number, startupTimeout: number): Promise { + const probeInterval = this.value(this.options.healthProbeIntervalMs) ?? 500; + const timeoutError = () => new Error(`${this.options.serverName} did not become healthy within ${startupTimeout}ms`); + + while (true) { + const remainingMs = startupDeadline - Date.now(); + if (remainingMs <= 0) { + throw timeoutError(); + } + + if (await this.isProbeReadyWithin(Math.min(this.value(this.options.healthProbeTimeoutMs) ?? 3_000, remainingMs))) { + return; + } + + const retryDelay = Math.min(probeInterval, startupDeadline - Date.now()); + if (retryDelay <= 0) { + throw timeoutError(); + } + + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + + private async isProbeReadyWithin(timeoutMs: number): Promise { + if (this.options.probe === false) { + return true; + } + + let timeout: ReturnType | undefined; + try { + const controller = new AbortController(); + timeout = setTimeout(() => controller.abort(), timeoutMs); + const probe = this.options.probe; + const response = await fetch(this.value(probe?.url) ?? this.getUrl(), { + ...(this.value(probe?.requestInit) ?? {}), + signal: controller.signal, + }); + return probe?.isHealthy ? await probe.isHealthy(response) : response.ok; + } catch { + return false; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + } + + private matchesReadyMarker(text: string): boolean { + const marker = this.options.readyMarker; + return typeof marker === 'string' ? text.includes(marker) : marker.test(text); + } + + private killProcess(childProcess: ChildProcess, signal: NodeJS.Signals): void { + if (this.useDetachedProcessGroup && childProcess.pid) { + try { + process.kill(-childProcess.pid, signal); + return; + } catch { + /* Fall back to killing the direct child. */ + } + } + + childProcess.kill(signal); + } + + private value(value: T | (() => T) | undefined): T | undefined { + return typeof value === 'function' ? (value as () => T)() : value; + } +} diff --git a/packages/cellix/serenity-framework/src/servers/test-server.ts b/packages/cellix/serenity-framework/src/servers/test-server.ts new file mode 100644 index 000000000..1d337d2d0 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/test-server.ts @@ -0,0 +1,21 @@ +/** + * Common contract for in-process and subprocess test servers. + */ +export interface TestServer { + /** Start the server and resolve when it is ready. */ + start(): Promise; + + /** Stop the server gracefully. */ + stop(): Promise; + + /** Return whether this server instance is currently running. */ + isRunning(): boolean; + + /** Return the URL exposed by the server. */ + getUrl(): string; +} + +/** + * Seed function used by database-oriented test servers. + */ +export type SeedDataFunction = (context: TContext) => Promise | void; diff --git a/packages/cellix/serenity-framework/src/settings/index.ts b/packages/cellix/serenity-framework/src/settings/index.ts new file mode 100644 index 000000000..2fac27525 --- /dev/null +++ b/packages/cellix/serenity-framework/src/settings/index.ts @@ -0,0 +1,2 @@ +export type { DefaultVerificationTimeoutKey, VerificationTimeoutMap, VerificationTimeoutOptions } from './timeout-settings.ts'; +export { defaultVerificationTimeouts, getTimeout, VerificationTimeouts } from './timeout-settings.ts'; diff --git a/packages/cellix/serenity-framework/src/settings/settings.test.ts b/packages/cellix/serenity-framework/src/settings/settings.test.ts new file mode 100644 index 000000000..e3c2afc31 --- /dev/null +++ b/packages/cellix/serenity-framework/src/settings/settings.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { VerificationTimeouts } from './index.ts'; + +describe('VerificationTimeouts', () => { + it('uses positive integer environment overrides', () => { + const timeouts = new VerificationTimeouts({ + defaults: { serverStartup: 100 }, + env: { TIMEOUT_SERVER_STARTUP: '250' }, + }); + + expect(timeouts.get('serverStartup')).toBe(250); + }); + + it('falls back to defaults for invalid overrides', () => { + const timeouts = new VerificationTimeouts({ + defaults: { serverStartup: 100 }, + env: { TIMEOUT_SERVER_STARTUP: 'nope' }, + }); + + expect(timeouts.get('serverStartup')).toBe(100); + }); +}); diff --git a/packages/cellix/serenity-framework/src/settings/timeout-settings.ts b/packages/cellix/serenity-framework/src/settings/timeout-settings.ts new file mode 100644 index 000000000..17abeeaac --- /dev/null +++ b/packages/cellix/serenity-framework/src/settings/timeout-settings.ts @@ -0,0 +1,93 @@ +/** + * Default timeout map used by Cellix verification packages. + */ +export const defaultVerificationTimeouts = { + /** Default Cucumber scenario timeout. */ + scenario: 120_000, + + /** Server startup timeout. */ + serverStartup: 120_000, + + /** Graceful server shutdown timeout. */ + serverShutdown: 10_000, + + /** Health probe timeout. */ + healthProbe: 3_000, + + /** Health probe retry interval. */ + healthProbeInterval: 500, + + /** UI initialization timeout. */ + uiInit: 30_000, + + /** UI cleanup timeout. */ + uiCleanup: 10_000, +} as const; + +/** Keys accepted by the default timeout map. */ +export type DefaultVerificationTimeoutKey = keyof typeof defaultVerificationTimeouts; + +/** Timeout map accepted by {@link VerificationTimeouts}. */ +export type VerificationTimeoutMap = Record; + +/** Options used by {@link VerificationTimeouts}. */ +export interface VerificationTimeoutOptions { + /** Default timeout values. */ + defaults: TTimeouts; + + /** Environment source. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; +} + +/** + * Reads verification timeouts with optional environment overrides. + * + * Environment variable names are generated from keys: `serverStartup` becomes + * `TIMEOUT_SERVER_STARTUP`. + */ +export class VerificationTimeouts { + private readonly defaults: TTimeouts; + private readonly env: NodeJS.ProcessEnv; + + /** + * @param options Timeout defaults and optional environment source. + */ + constructor(options: VerificationTimeoutOptions) { + this.defaults = options.defaults; + this.env = options.env ?? process.env; + } + + /** + * Get a timeout value, honoring a positive integer environment override. + * + * @param key Timeout key. + */ + get(key: TKey): TTimeouts[TKey] { + const envName = timeoutEnvName(key); + const envOverride = this.env[envName]; + + if (envOverride) { + const parsed = Number(envOverride); + if (Number.isInteger(parsed) && parsed > 0) { + return parsed as TTimeouts[TKey]; + } + } + + return this.defaults[key]; + } +} + +const defaultTimeoutReader = new VerificationTimeouts({ defaults: defaultVerificationTimeouts }); + +/** + * Read a timeout from the default Cellix verification timeout map. + * + * @param key Timeout key. + */ +export function getTimeout(key: DefaultVerificationTimeoutKey): number { + return defaultTimeoutReader.get(key); +} + +function timeoutEnvName(key: string): string { + return `TIMEOUT_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}`; +} diff --git a/packages/cellix/serenity-framework/tsconfig.json b/packages/cellix/serenity-framework/tsconfig.json new file mode 100644 index 000000000..39ba28f8b --- /dev/null +++ b/packages/cellix/serenity-framework/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@cellix/config-typescript/node", + "compilerOptions": { + "erasableSyntaxOnly": false, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cellix/serenity-framework/tsconfig.vitest.json b/packages/cellix/serenity-framework/tsconfig.vitest.json new file mode 100644 index 000000000..4f806efbc --- /dev/null +++ b/packages/cellix/serenity-framework/tsconfig.vitest.json @@ -0,0 +1,3 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"] +} diff --git a/packages/cellix/serenity-framework/turbo.json b/packages/cellix/serenity-framework/turbo.json new file mode 100644 index 000000000..5f90b32dd --- /dev/null +++ b/packages/cellix/serenity-framework/turbo.json @@ -0,0 +1,3 @@ +{ + "extends": ["//"] +} diff --git a/packages/cellix/serenity-framework/vitest.config.ts b/packages/cellix/serenity-framework/vitest.config.ts new file mode 100644 index 000000000..e6ec21dc3 --- /dev/null +++ b/packages/cellix/serenity-framework/vitest.config.ts @@ -0,0 +1,8 @@ +import { nodeConfig } from '@cellix/config-vitest'; +import { mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, { + test: { + exclude: ['dist/**', 'node_modules/**'], + }, +}); diff --git a/packages/cellix/ui-core/package.json b/packages/cellix/ui-core/package.json index 9e70880a6..1f7df129b 100644 --- a/packages/cellix/ui-core/package.json +++ b/packages/cellix/ui-core/package.json @@ -45,8 +45,8 @@ "@storybook/react-vite": "^9.1.3", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.6", - "@vitest/browser": "^4.1.2", - "@vitest/browser-playwright": "^4.1.2", + "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", "@vitest/coverage-istanbul": "catalog:", "jsdom": "catalog:", "@testing-library/react": "^16.3.0", diff --git a/packages/cellix/ui-core/src/index.ts b/packages/cellix/ui-core/src/index.ts index 9edf82f72..227b5c2f4 100644 --- a/packages/cellix/ui-core/src/index.ts +++ b/packages/cellix/ui-core/src/index.ts @@ -1 +1,2 @@ export * from './components/index.ts'; +export * from './theme-storage.ts'; diff --git a/packages/cellix/ui-core/src/theme-storage.ts b/packages/cellix/ui-core/src/theme-storage.ts new file mode 100644 index 000000000..0ff4303e8 --- /dev/null +++ b/packages/cellix/ui-core/src/theme-storage.ts @@ -0,0 +1,20 @@ +export type StoredTheme = { + type?: 'light' | 'dark' | 'custom'; + hardCodedTokens?: { textColor?: string; backgroundColor?: string }; + token?: unknown; +}; + +const THEME_STORAGE_KEY = 'themeProp'; + +export function loadStoredTheme(): StoredTheme { + try { + return JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) ?? '{}') as StoredTheme; + } catch { + localStorage.removeItem(THEME_STORAGE_KEY); + return {}; + } +} + +export function saveStoredTheme(value: StoredTheme): void { + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value)); +} diff --git a/packages/ocom-verification/acceptance-api/cucumber.js b/packages/ocom-verification/acceptance-api/cucumber.js index d92e97982..b5fb90c59 100644 --- a/packages/ocom-verification/acceptance-api/cucumber.js +++ b/packages/ocom-verification/acceptance-api/cucumber.js @@ -3,7 +3,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], import: ['src/world.ts', 'src/step-definitions/index.ts'], - format: [...(isAgent ? ['../verification-shared/src/formatters/agent-formatter.ts'] : ['progress-bar']), 'json:./reports/cucumber-report-api.json', 'html:./reports/cucumber-report-api.html'], + format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-api.json', 'html:./reports/cucumber-report-api.html'], formatOptions: { snippetInterface: 'async-await', }, diff --git a/packages/ocom-verification/acceptance-api/package.json b/packages/ocom-verification/acceptance-api/package.json index 5887447d4..c633606d9 100644 --- a/packages/ocom-verification/acceptance-api/package.json +++ b/packages/ocom-verification/acceptance-api/package.json @@ -18,17 +18,23 @@ "@serenity-js/core": "catalog:", "@serenity-js/cucumber": "catalog:", "@serenity-js/serenity-bdd": "catalog:", + "@cellix/serenity-framework": "workspace:*", + "graphql": "catalog:", + "graphql-depth-limit": "^1.1.0", + "graphql-middleware": "^6.1.35", "std-env": "^4.0.0" }, "devDependencies": { "@cellix/config-typescript": "workspace:*", "@ocom/application-services": "workspace:*", "@ocom/context-spec": "workspace:*", + "@ocom/graphql": "workspace:*", "@ocom/persistence": "workspace:*", "@ocom/service-apollo-server": "workspace:*", "@ocom/service-mongoose": "workspace:*", "@ocom/service-token-validation": "workspace:*", "@ocom-verification/verification-shared": "workspace:*", + "@types/graphql-depth-limit": "^1.1.6", "@types/node": "catalog:", "c8": "^11.0.0", "rimraf": "^6.0.1", diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/notes/header-notes.ts similarity index 100% rename from packages/ocom-verification/acceptance-api/src/contexts/authentication/abilities/header-types.ts rename to packages/ocom-verification/acceptance-api/src/contexts/authentication/notes/header-notes.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts index d204f57f9..f58908f5a 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -1,6 +1,6 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actorCalled, notes } from '@serenity-js/core'; -import type { HeaderApiNotes } from '../abilities/header-types.ts'; +import type { HeaderApiNotes } from '../notes/header-notes.ts'; import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; let lastActorName = 'Alex'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts index b0e46f773..04fc28721 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,6 +1,6 @@ -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; import { type Activity, type Actor, notes, Task } from '@serenity-js/core'; -import type { HeaderApiNotes } from '../abilities/header-types.ts'; +import type { HeaderApiNotes } from '../notes/header-notes.ts'; export const ClickHeaderSignIn = () => Task.where( diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/abilities/community-types.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts similarity index 100% rename from packages/ocom-verification/acceptance-api/src/contexts/community/abilities/community-types.ts rename to packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts index c815549eb..c997a105d 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts @@ -1,7 +1,7 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; -import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; import { GET_COMMUNITY_QUERY } from '../../../shared/graphql/community-operations.ts'; -import type { CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityNotes } from '../notes/community-notes.ts'; export class CommunityName extends Question> { constructor() { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts index 598bfffd9..0da828fe0 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts @@ -1,5 +1,5 @@ import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; -import type { CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityNotes } from '../notes/community-notes.ts'; export class CommunityStatus extends Question> { constructor() { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts index c1f727e2c..a8a7bddce 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts @@ -1,9 +1,9 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; -import { Ensure, equals } from '@serenity-js/assertions'; import { actorCalled, notes } from '@serenity-js/core'; -import { resolveActorName } from '../../../shared/support/domain-test-helpers.ts'; -import type { CommunityDetails, CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityDetails, CommunityNotes } from '../notes/community-notes.ts'; import { CommunityName } from '../questions/community-name.ts'; import { CommunityStatus } from '../questions/community-status.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; @@ -18,7 +18,7 @@ Given('{word} is an authenticated community owner', (actorName: string) => { When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash() as unknown as CommunityDetails; + const details = GherkinDataTable.from(dataTable).rowsHash(); await actor.attemptsTo(CreateCommunity.with(details)); }); @@ -26,7 +26,7 @@ When('{word} creates a community with:', async (actorName: string, dataTable: Da When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash() as unknown as CommunityDetails; + const details = GherkinDataTable.from(dataTable).rowsHash(); await actor.attemptsTo(notes().set('lastCommunityId', undefined as unknown as string), notes().set('lastValidationError', undefined as unknown as string)); @@ -40,18 +40,24 @@ When('{word} attempts to create a community with:', async (actorName: string, da Then('the community should be created successfully', async () => { const actor = actorCalled(lastActorName); + const status = await actor.answer(CommunityStatus.of()); - await actor.attemptsTo(Ensure.that(CommunityStatus.of(), equals('SUCCESS'))); + if (status !== 'SUCCESS') { + throw new Error(`Expected community status "SUCCESS" but got "${status}"`); + } }); Then('the community name should be {string}', async (expectedName: string) => { const actor = actorCalled(lastActorName); + const actualName = await actor.answer(CommunityName.displayed()); - await actor.attemptsTo(Ensure.that(CommunityName.displayed(), equals(expectedName))); + if (actualName !== expectedName) { + throw new Error(`Expected community name "${expectedName}" but got "${actualName}"`); + } }); Then('{word} should see a community error for {string}', async (actorName: string, fieldName: string) => { - const resolvedActorName = resolveActorName(actorName, lastActorName); + const resolvedActorName = ActorName.resolve(actorName, { defaultName: lastActorName }); const actor = actorCalled(resolvedActorName); let storedError: string | undefined; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts index 0f530b08f..118c7fbee 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts @@ -1,7 +1,6 @@ +import { CreateCommunity as CreateCommunityAbility } from '@ocom-verification/verification-shared/abilities'; import { type Actor, notes, Task } from '@serenity-js/core'; -import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; -import { COMMUNITY_CREATE_MUTATION, GET_COMMUNITY_QUERY } from '../../../shared/graphql/community-operations.ts'; -import type { CommunityDetails, CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityDetails, CommunityNotes } from '../notes/community-notes.ts'; export class CreateCommunity extends Task { static withName(name: string) { @@ -17,43 +16,9 @@ export class CreateCommunity extends Task { } async performAs(actor: Actor): Promise { - const graphql = GraphQLClient.as(actor); + const community = await CreateCommunityAbility.as(actor).performAs(actor, this.details); - const response = await graphql.execute(COMMUNITY_CREATE_MUTATION, { - input: { name: this.details.name }, - }); - - const mutationResult = response.data['communityCreate'] as Record; - const status = mutationResult?.['status'] as Record | undefined; - const community = mutationResult?.['community'] as Record | undefined; - - if (status?.['success'] !== true) { - throw new Error(String(status?.['errorMessage'] ?? 'Failed to create community')); - } - - const communityId = String(community?.['id'] ?? ''); - const communityName = String(community?.['name'] ?? ''); - - if (!communityId) { - throw new Error('API communityCreate returned a community without an id'); - } - if (communityName !== this.details.name) { - throw new Error(`API communityCreate returned name "${communityName}", expected "${this.details.name}"`); - } - - const persistedResponse = await graphql.execute(GET_COMMUNITY_QUERY, { - id: communityId, - }); - const persistedData = persistedResponse.data['communityById'] as Record | undefined; - if (!persistedData) { - throw new Error(`Community ${communityId} was not found on re-query; API backend did not persist the community`); - } - const persistedName = String(persistedData['name'] ?? ''); - if (persistedName !== this.details.name) { - throw new Error(`Re-queried community name "${persistedName}" does not match created name "${this.details.name}"`); - } - - await actor.attemptsTo(notes().set('lastCommunityId', communityId), notes().set('lastCommunityName', communityName), notes().set('lastCommunityStatus', 'SUCCESS')); + await actor.attemptsTo(notes().set('lastCommunityId', community.id ?? ''), notes().set('lastCommunityName', community.name), notes().set('lastCommunityStatus', 'SUCCESS')); } override toString = () => `creates a community named "${this.details.name}"`; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..954f3a337 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-landing.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts new file mode 100644 index 000000000..c05505539 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -0,0 +1,62 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +interface StaffApiNotes { + targetRoute: string; +} + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + actorRoles.set(actorName, normalizeRole(roleName)); + await actorCalled(actorName).attemptsTo(notes().set('targetRoute', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('targetRoute', defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('targetRoute', resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const targetRoute = await actor.answer(notes().get('targetRoute')); + + if (targetRoute !== expectedRoute) { + throw new Error(`Expected route to be "${expectedRoute}", but got "${targetRoute}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts new file mode 100644 index 000000000..66f7c3462 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts @@ -0,0 +1,28 @@ +import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; +import { getTimeout } from '@cellix/serenity-framework/settings'; +import type { IWorld } from '@cucumber/cucumber'; +import { isAgent } from 'std-env'; +import * as infra from './infrastructure.ts'; +import type { CellixApiWorld } from './world.ts'; + +let printedSuiteHeader = false; + +/** Register the Cucumber Before/After/AfterAll hooks for the API acceptance suite. */ +export function registerLifecycleHooks(): void { + registerWorldLifecycleHooks({ + scenarioTimeout: getTimeout('scenario'), + before: async (world) => { + if (!printedSuiteHeader && !isAgent) { + printedSuiteHeader = true; + console.log('\nAPI acceptance tests'); + console.log(' - Community context\n'); + } + + await world.init(); + }, + after: async (world) => { + await world.cleanup(); + }, + afterAll: () => infra.stopAll(), + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/infrastructure.ts b/packages/ocom-verification/acceptance-api/src/infrastructure.ts new file mode 100644 index 000000000..d16350f87 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/infrastructure.ts @@ -0,0 +1,42 @@ +import { ApiInfrastructure } from '@cellix/serenity-framework/infrastructure/api'; +import { MongoMemoryTestServer } from '@cellix/serenity-framework/servers'; +import { ServiceMongoose } from '@ocom/service-mongoose'; +import { getMongoPort } from '@ocom-verification/verification-shared/environment'; +import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; +import { ApiGraphQLTestServer, MongooseTestServer } from './test-server-factories.ts'; + +const apiDbName = 'owner-community'; + +const infrastructure = ApiInfrastructure.create() + .addServer('mongo', () => new MongoMemoryTestServer({ dbName: apiDbName, port: getMongoPort(), replSetName: 'globaldb', seedData: seedDatabase }), { + resetForScenario: (server) => (server as MongoMemoryTestServer).resetForScenario(), + }) + .addServer('mongoose', (ctx) => new MongooseTestServer(() => new ServiceMongoose(ctx.server('mongo').getConnectionString(), { autoCreate: true, autoIndex: true, dbName: apiDbName })), { + dependsOn: ['mongo'], + }) + .addServer('graphql', (ctx) => new ApiGraphQLTestServer(() => ctx.server('mongoose').getService()), { dependsOn: ['mongoose'] }); + +interface ApiAcceptanceState { + graphqlUrl: string | undefined; +} + +export function getState(): ApiAcceptanceState { + // biome-ignore lint:useLiteralKeys - servers is an index-signature record; bracket access required by noPropertyAccessFromIndexSignature + const graphqlServer = infrastructure.getState().servers['graphql']; + return { + graphqlUrl: graphqlServer?.isRunning() ? graphqlServer.getUrl() : undefined, + }; +} + +export async function stopAll(): Promise { + await infrastructure.stopAll(); +} + +export async function ensureApiServers(): Promise { + infrastructure.registerProcessShutdownHandlers(); + await infrastructure.ensureStarted(); +} + +export async function resetMongoForScenario(): Promise { + await infrastructure.resetScenarioState(); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/application-services/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts similarity index 100% rename from packages/ocom-verification/acceptance-api/src/shared/support/application-services/mock-application-services.ts rename to packages/ocom-verification/acceptance-api/src/mock-application-services.ts diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts new file mode 100644 index 000000000..840208333 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts @@ -0,0 +1,47 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { CreateCommunity } from '@ocom-verification/verification-shared/abilities'; +import { COMMUNITY_CREATE_MUTATION, GET_COMMUNITY_QUERY } from '../graphql/community-operations.ts'; + +export function createCommunityAbility(): CreateCommunity { + return CreateCommunity.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(COMMUNITY_CREATE_MUTATION, { + input: { name: details.name }, + }); + + const mutationResult = response.data['communityCreate'] as Record; + const status = mutationResult?.['status'] as Record | undefined; + const community = mutationResult?.['community'] as Record | undefined; + + if (status?.['success'] !== true) { + throw new Error(String(status?.['errorMessage'] ?? 'Failed to create community')); + } + + const communityId = String(community?.['id'] ?? ''); + const communityName = String(community?.['name'] ?? ''); + + if (!communityId) { + throw new Error('API communityCreate returned a community without an id'); + } + if (communityName !== details.name) { + throw new Error(`API communityCreate returned name "${communityName}", expected "${details.name}"`); + } + + const persistedResponse = await graphql.execute(GET_COMMUNITY_QUERY, { + id: communityId, + }); + const persistedData = persistedResponse.data['communityById'] as Record | undefined; + if (!persistedData) { + throw new Error(`Community ${communityId} was not found on re-query; API backend did not persist the community`); + } + const persistedName = String(persistedData['name'] ?? ''); + if (persistedName !== details.name) { + throw new Error(`Re-queried community name "${persistedName}" does not match created name "${details.name}"`); + } + + return { + id: communityId, + name: communityName, + }; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts index 2e6a8beb4..6dd954aea 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts @@ -1,40 +1,10 @@ -import { Ability } from '@serenity-js/core'; - -interface GraphQLResponse { - data: Record; - errors?: Array<{ message: string }>; -} - -export class GraphQLClient extends Ability { - constructor(private readonly apiUrl: string) { - super(); - } - - static at(apiUrl: string): GraphQLClient { - return new GraphQLClient(apiUrl); - } - - async execute(query: string, variables: Record = {}): Promise { - const response = await fetch(this.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer test-token', - }, - body: JSON.stringify({ query, variables }), - }); - - const result = (await response.json()) as GraphQLResponse; - - if (result.errors && Array.isArray(result.errors)) { - const errorMessage = result.errors.map((err) => err.message ?? 'Unknown error').join('; '); - throw new Error(errorMessage); - } - - if (!response.ok) { - throw new Error(`GraphQL error: ${response.status} ${response.statusText}`); - } - - return result; - } +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; + +export function createGraphQLClientAbility(apiUrl: string): GraphQLClient { + return new GraphQLClient({ + apiUrl, + headers: { + Authorization: 'Bearer test-token', + }, + }); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts new file mode 100644 index 000000000..2ed5ecf3a --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -0,0 +1,2 @@ +export { createCommunityAbility } from './create-community.ts'; +export { createGraphQLClientAbility } from './graphql-client.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/application-services/index.ts b/packages/ocom-verification/acceptance-api/src/shared/support/application-services/index.ts deleted file mode 100644 index 8f18e4ea1..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/application-services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createMockApplicationServicesFactory } from './mock-application-services.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/cast.ts b/packages/ocom-verification/acceptance-api/src/shared/support/cast.ts deleted file mode 100644 index 1e92882bb..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/cast.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; -import { GraphQLClient } from '../abilities/graphql-client.ts'; - -export class CellixApiCast implements Cast { - constructor(private readonly apiUrl: string) {} - - prepare(actor: Actor): Actor { - return actor.whoCan(TakeNotes.using(Notepad.empty()), GraphQLClient.at(this.apiUrl)); - } -} diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/domain-test-helpers.ts b/packages/ocom-verification/acceptance-api/src/shared/support/domain-test-helpers.ts deleted file mode 100644 index fe02a06ae..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/domain-test-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export { resolveActorName } from '@ocom-verification/verification-shared/helpers'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts b/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts deleted file mode 100644 index 9e919f49d..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { IWorld } from '@cucumber/cucumber'; -import { After, AfterAll, Before, setDefaultTimeout } from '@cucumber/cucumber'; -import { getTimeout } from '@ocom-verification/verification-shared/settings'; -import { isAgent } from 'std-env'; -import { type CellixApiWorld, stopSharedServers } from '../../world.ts'; - -let printedSuiteHeader = false; - -/** Default scenario timeout from centralized configuration */ -setDefaultTimeout(getTimeout('scenario')); - -Before(async function (this: IWorld) { - const world = this as IWorld & CellixApiWorld; - - if (!printedSuiteHeader && !isAgent) { - printedSuiteHeader = true; - console.log('\nAPI acceptance tests'); - console.log(' - Community context\n'); - } - - await world.init(); -}); - -After(async function (this: IWorld) { - const world = this as IWorld & CellixApiWorld; - await world.cleanup(); -}); - -AfterAll(async () => { - await stopSharedServers(); -}); diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts b/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts deleted file mode 100644 index 799cf064b..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { GraphQLTestServer, MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; -import { createMockApplicationServicesFactory } from './application-services/index.ts'; - -// Shared infrastructure — persists across scenarios within a single test run -let mongoDBServer: MongoDBTestServer | undefined; -let graphQLServer: GraphQLTestServer | undefined; -let apiUrl: string | undefined; - -interface InfrastructureState { - apiUrl: string | undefined; -} - -export function getState(): InfrastructureState { - return { apiUrl }; -} - -export async function stopAll(): Promise { - if (graphQLServer) { - await graphQLServer.stop(); - graphQLServer = undefined; - } - if (mongoDBServer) { - await mongoDBServer.stop(); - mongoDBServer = undefined; - } - apiUrl = undefined; -} - -async function ensureMongoDBServer(): Promise { - if (mongoDBServer) return mongoDBServer; - - mongoDBServer = new MongoDBTestServer(); - await mongoDBServer.start({ attachMongoose: true }); - return mongoDBServer; -} - -export async function ensureApiServers(): Promise { - if (graphQLServer) return; - - const mongo = await ensureMongoDBServer(); - - const mockApplicationServicesFactory = createMockApplicationServicesFactory(mongo.getServiceMongoose()); - graphQLServer = new GraphQLTestServer(mockApplicationServicesFactory); - await graphQLServer.start(); - apiUrl = graphQLServer.getUrl(); -} - -export async function resetMongoForScenario(): Promise { - if (!mongoDBServer) return; - await mongoDBServer.resetForScenario(); -} diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts index 395b3f752..cba25d6cc 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -5,3 +5,4 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/test-server-factories.ts b/packages/ocom-verification/acceptance-api/src/test-server-factories.ts new file mode 100644 index 000000000..cb7526acc --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/test-server-factories.ts @@ -0,0 +1,103 @@ +import { ApolloGraphQLTestServer, type TestServer } from '@cellix/serenity-framework/servers'; +import type { ApplicationServices } from '@ocom/application-services'; +import { combinedSchema } from '@ocom/graphql'; +import type { ServiceMongoose } from '@ocom/service-mongoose'; +import depthLimit from 'graphql-depth-limit'; +import { applyMiddleware } from 'graphql-middleware'; +import { createMockApplicationServicesFactory } from './mock-application-services.ts'; + +/** + * {@link TestServer} adapter that owns the Mongoose connection lifecycle for the + * acceptance suite. The framework is database-ignorant, so connecting, clearing + * registered models, and disconnecting all live here. Exposes the started + * {@link ServiceMongoose} to dependent servers (e.g. the GraphQL server) via + * {@link getService}. + */ +export class MongooseTestServer implements TestServer { + private serviceInternal: ServiceMongoose | undefined; + + constructor(private readonly createService: () => ServiceMongoose) {} + + async start(): Promise { + const service = this.createService(); + await service.startUp(); + // Clear any models registered on a previous connection so schemas re-register cleanly. + const { connection } = service.service; + for (const modelName of Object.keys(connection.models)) { + try { + connection.deleteModel(modelName); + } catch { + /* already deleted */ + } + } + this.serviceInternal = service; + } + + async stop(): Promise { + if (this.serviceInternal) { + await this.serviceInternal.shutDown(); + this.serviceInternal = undefined; + } + } + + isRunning(): boolean { + return this.serviceInternal !== undefined; + } + + /** Not a network server; no URL is exposed. */ + getUrl(): string { + return ''; + } + + /** The started Mongoose service. Throws if accessed before {@link start}. */ + getService(): ServiceMongoose { + if (!this.serviceInternal) { + throw new Error('MongooseTestServer not started'); + } + return this.serviceInternal; + } +} + +/** + * {@link TestServer} that owns the Apollo GraphQL server lifecycle for the + * acceptance suite. It wires the app's schema and permissions middleware and + * builds a request-scoped mock application-services factory lazily on the first + * request, caching it for the lifetime of the running server. The cache is + * instance-scoped, so it is discarded when the server stops. + */ +export class ApiGraphQLTestServer implements TestServer { + private readonly server: ApolloGraphQLTestServer<{ applicationServices: ApplicationServices }>; + private applicationServicesFactory: ReturnType | undefined; + + constructor(getMongooseService: () => ServiceMongoose) { + this.server = new ApolloGraphQLTestServer<{ applicationServices: ApplicationServices }>({ + schema: applyMiddleware(combinedSchema), + validationRules: [depthLimit(10)], + context: async ({ req }) => { + this.applicationServicesFactory ??= createMockApplicationServicesFactory(getMongooseService()); + const applicationServices = await this.applicationServicesFactory.forRequest(req.headers.authorization ?? undefined); + if (!applicationServices) { + throw new Error('ApplicationServicesFactory required for test server'); + } + return { applicationServices }; + }, + }); + } + + start(): Promise { + return this.server.start(); + } + + async stop(): Promise { + await this.server.stop(); + this.applicationServicesFactory = undefined; + } + + isRunning(): boolean { + return this.server.isRunning(); + } + + getUrl(): string { + return this.server.getUrl(); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index 757aebe3b..f1fed41d3 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -1,31 +1,29 @@ -import { setWorldConstructor, World } from '@cucumber/cucumber'; -import { engage } from '@serenity-js/core'; -import './shared/support/hooks.ts'; -import { CellixApiCast } from './shared/support/cast.ts'; -import * as infra from './shared/support/shared-infrastructure.ts'; +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; +import { registerLifecycleHooks } from './cucumber-lifecycle-hooks.ts'; +import * as infra from './infrastructure.ts'; +import { createCommunityAbility } from './shared/abilities/create-community.ts'; +import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; -export async function stopSharedServers(): Promise { - await infra.stopAll(); -} - -export class CellixApiWorld extends World { - private apiUrl = ''; - - async init(): Promise { - await infra.ensureApiServers(); - await infra.resetMongoForScenario(); - - const { apiUrl } = infra.getState(); - if (apiUrl) { - this.apiUrl = apiUrl; +export const CellixApiWorld = registerManagedSerenityWorld({ + infrastructure: { + ensureStarted: infra.ensureApiServers, + getState: infra.getState, + resetScenarioState: infra.resetMongoForScenario, + stopAll: infra.stopAll, + }, + validateState: (state) => { + if (!state.graphqlUrl) { + throw new Error('API acceptance infrastructure did not expose a graphqlUrl'); } + }, + createCast: (state) => + new SerenityCast({ + useNotepad: true, + abilities: [() => createGraphQLClientAbility(state.graphqlUrl ?? ''), () => createCommunityAbility()], + }), +}); - engage(new CellixApiCast(this.apiUrl)); - } - - async cleanup(): Promise { - // Per-scenario cleanup — extend as needed. - } -} +export type CellixApiWorld = InstanceType; -setWorldConstructor(CellixApiWorld); +registerLifecycleHooks(); diff --git a/packages/ocom-verification/acceptance-ui/cucumber.js b/packages/ocom-verification/acceptance-ui/cucumber.js index e445fafcc..90638142e 100644 --- a/packages/ocom-verification/acceptance-ui/cucumber.js +++ b/packages/ocom-verification/acceptance-ui/cucumber.js @@ -3,7 +3,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], import: ['src/world.ts', 'src/step-definitions/index.ts'], - format: [...(isAgent ? ['../verification-shared/src/formatters/agent-formatter.ts'] : ['progress-bar']), 'json:./reports/cucumber-report-ui.json', 'html:./reports/cucumber-report-ui.html'], + format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-ui.json', 'html:./reports/cucumber-report-ui.html'], formatOptions: { snippetInterface: 'async-await', }, diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index d9603bcf7..d0943ce3d 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -5,14 +5,15 @@ "private": true, "type": "module", "scripts": { - "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js", - "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 cucumber-js", - "test:coverage:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 cucumber-js" + "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' cucumber-js", + "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' c8 cucumber-js", + "test:coverage:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' c8 cucumber-js" }, "dependencies": { "@apollo/client": "^3.13.9", "@cucumber/cucumber": "catalog:", "@dr.pogodin/react-helmet": "^3.0.4", + "@cellix/serenity-framework": "workspace:*", "@serenity-js/console-reporter": "catalog:", "@serenity-js/core": "catalog:", "@serenity-js/cucumber": "catalog:", @@ -32,7 +33,6 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "c8": "^10.1.3", - "jsdom": "^26.1.0", "tsx": "^4.20.3", "typescript": "catalog:" } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts similarity index 64% rename from packages/ocom-verification/acceptance-ui/src/contexts/authentication/abilities/header-types.ts rename to packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts index 7b1b83168..670e14332 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/abilities/header-types.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts @@ -1,4 +1,6 @@ export interface HeaderUiNotes { + site: 'community' | 'staff'; + identityProviderUnreachable: boolean; signinRedirectCalled: boolean; consoleErrorCalled: boolean; fallbackInvoked: boolean; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/header-login.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/header-login.steps.tsx index 4cffe5960..a69c19805 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/header-login.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/header-login.steps.tsx @@ -1,111 +1,80 @@ +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; import { Given, Then, When } from '@cucumber/cucumber'; -import { actorCalled, notes } from '@serenity-js/core'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import React from 'react'; import { AuthContext, type AuthContextProps } from 'react-oidc-context'; import { SectionLayout as CommunitySectionLayout } from '../../../../../../ocom/ui-community-route-root/src/section-layout.tsx'; import { SectionLayout as StaffSectionLayout } from '../../../../../../ocom/ui-staff-route-root/src/section-layout.tsx'; -import { mountComponent } from '../../../shared/support/ui/react-render.ts'; -import type { CellixUiWorld } from '../../../world.ts'; -import type { HeaderUiNotes } from '../abilities/header-types.ts'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { HeaderUiNotes } from '../notes/header-notes.ts'; import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; type Site = 'community' | 'staff'; -interface HeaderScenarioState { - actorName: string; - site: Site; - identityProviderUnreachable: boolean; - originalConsoleError?: typeof console.error; - signinRedirectCalled: boolean; - errorCalled: boolean; +async function visitSite(actorName: string, site: Site): Promise { + await actorCalled(actorName).attemptsTo( + notes().set('site', site), + notes().set('identityProviderUnreachable', false), + notes().set('signinRedirectCalled', false), + notes().set('consoleErrorCalled', false), + notes().set('fallbackInvoked', false), + ); } -function getState(world: CellixUiWorld): HeaderScenarioState { - const state = (world as unknown as { __headerState?: HeaderScenarioState }).__headerState; - if (!state) { - throw new Error('Header scenario state has not been initialised — did the Given step run?'); - } - return state; -} - -function initState(world: CellixUiWorld, actorName: string, site: Site): HeaderScenarioState { - const state: HeaderScenarioState = { - actorName, - site, - identityProviderUnreachable: false, - signinRedirectCalled: false, - errorCalled: false, - }; - (world as unknown as { __headerState: HeaderScenarioState }).__headerState = state; - return state; -} - -Given('{word} visits the community site', async function (this: CellixUiWorld, actorName: string) { - const actor = actorCalled(actorName); - initState(this, actorName, 'community'); - await actor.attemptsTo(notes().set('signinRedirectCalled', false), notes().set('consoleErrorCalled', false), notes().set('fallbackInvoked', false)); +Given('{word} visits the community site', async (actorName: string) => { + await visitSite(actorName, 'community'); }); -Given('{word} visits the staff site', async function (this: CellixUiWorld, actorName: string) { - const actor = actorCalled(actorName); - initState(this, actorName, 'staff'); - await actor.attemptsTo(notes().set('signinRedirectCalled', false), notes().set('consoleErrorCalled', false), notes().set('fallbackInvoked', false)); +Given('{word} visits the staff site', async (actorName: string) => { + await visitSite(actorName, 'staff'); }); -Given('the identity provider is unreachable', function (this: CellixUiWorld) { - const state = getState(this); - state.identityProviderUnreachable = true; +Given('the identity provider is unreachable', async () => { + await actorInTheSpotlight().attemptsTo(notes().set('identityProviderUnreachable', true)); }); -When('{word} chooses to sign in', async function (this: CellixUiWorld, _actorName: string) { - const state = getState(this); +When('{word} chooses to sign in', async (actorName: string) => { + const actor = actorCalled(actorName); + const site = await actor.answer(notes().get('site')); + const identityProviderUnreachable = await actor.answer(notes().get('identityProviderUnreachable')); + let signinRedirectCalled = false; const signinRedirect = (): Promise => { - state.signinRedirectCalled = true; - if (state.identityProviderUnreachable) { - return Promise.reject(new Error('Simulated identity provider failure')); - } - return Promise.resolve(); + signinRedirectCalled = true; + return identityProviderUnreachable ? Promise.reject(new Error('Simulated identity provider failure')) : Promise.resolve(); }; const authValue = { signinRedirect } as unknown as AuthContextProps; - const PageComponent = state.site === 'community' ? CommunitySectionLayout : StaffSectionLayout; + const PageComponent = site === 'community' ? CommunitySectionLayout : StaffSectionLayout; const wrapped = React.createElement(AuthContext.Provider, { value: authValue }, React.createElement(PageComponent)); - state.originalConsoleError = console.error; - console.error = (..._args: unknown[]) => { - state.errorCalled = true; + const originalConsoleError = console.error; + let consoleErrorCalled = false; + console.error = () => { + consoleErrorCalled = true; }; - const rendered = mountComponent(wrapped); - this.setHeaderContainer(rendered.container); - try { - await ClickHeaderSignIn(rendered.container).performAs(actorCalled(state.actorName)); + await actor.attemptsTo(Render.component(wrapped, { wrapper: wrapOcomComponent() }), ClickHeaderSignIn()); } finally { - if (state.originalConsoleError) { - console.error = state.originalConsoleError; - } - const actor = actorCalled(state.actorName); + console.error = originalConsoleError; await actor.attemptsTo( - notes().set('signinRedirectCalled', state.signinRedirectCalled), - notes().set('consoleErrorCalled', state.errorCalled), - notes().set('fallbackInvoked', state.errorCalled), + notes().set('signinRedirectCalled', signinRedirectCalled), + notes().set('consoleErrorCalled', consoleErrorCalled), + notes().set('fallbackInvoked', consoleErrorCalled), ); } }); -Then('{word} is taken to the sign-in flow', async function (this: CellixUiWorld, actorName: string) { - const actor = actorCalled(actorName); - const called = await actor.answer(notes().get('signinRedirectCalled')); +Then('{word} is taken to the sign-in flow', async (actorName: string) => { + const called = await actorCalled(actorName).answer(notes().get('signinRedirectCalled')); if (!called) { throw new Error(`Expected ${actorName} to be taken to the sign-in flow, but the sign-in handler was not invoked`); } }); -Then('{word} can still reach the sign-in page', async function (this: CellixUiWorld, actorName: string) { - const actor = actorCalled(actorName); - const fallback = await actor.answer(notes().get('fallbackInvoked')); +Then('{word} can still reach the sign-in page', async (actorName: string) => { + const fallback = await actorCalled(actorName).answer(notes().get('fallbackInvoked')); if (!fallback) { throw new Error(`Expected ${actorName} to reach the sign-in page via the fallback path, but the fallback was not triggered`); } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts index 5b36010c4..66c4f4745 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,22 +1,23 @@ -import { HomePage, type UiHomePage } from '@ocom-verification/verification-shared/pages'; -import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; -import { type Activity, Task } from '@serenity-js/core'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { HomePage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiHomePage } from '../../../shared/page-contracts.ts'; -async function flushAsync(): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); +/** Let the sign-in handler's async work settle before assertions run. */ +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); } -export const ClickHeaderSignIn = (container: HTMLElement) => +export const ClickHeaderSignIn = (): Task => Task.where( '#actor clicks the sign-in button on the home page', - new TaskStep('#actor clicks the sign-in button', async () => { - const adapter = new JsdomPageAdapter(container); - const page: UiHomePage = new HomePage(adapter); + new TaskStep('#actor clicks the sign-in button', async (actor) => { + const page: AcceptanceUiHomePage = new HomePage(new DomPageAdapter(RenderInDom.as(actor).container)); await page.clickSignIn(); - await flushAsync(); - }) as Activity, + + await flushPendingReactWork(); + }), ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts similarity index 74% rename from packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts rename to packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts index c8f9fdf09..d3f7091cd 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts @@ -1,5 +1,4 @@ export interface CommunityUiNotes { communityName: string; formSubmitted: boolean; - lastValidationError: string; } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts index 102d7102f..d232228a1 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; export const CommunityCreatedFlag = () => Question.about('whether the community form was submitted', (actor) => actor.answer(notes().get('formSubmitted'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts deleted file mode 100644 index f1cf03813..000000000 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { notes, Question } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; - -export const CommunityErrorMessage = () => Question.about('the community form error message', (actor) => actor.answer(notes().get('lastValidationError'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts index e0fbdad59..37dcf8f81 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; export const CommunityName = () => Question.about('the submitted community name', (actor) => actor.answer(notes().get('communityName'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx index ea8a143d9..0d3394237 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx @@ -1,121 +1,67 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; -import { CommunityPage, type UiCommunityPage } from '@ocom-verification/verification-shared/pages'; -import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { actorCalled, notes } from '@serenity-js/core'; +import { CommunityPage } from '@ocom-verification/verification-shared/pages'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import { CommunityCreate } from '../../../../../../ocom/ui-community-route-accounts/src/components/community-create.tsx'; -import { mountComponent } from '../../../shared/support/ui/react-render.ts'; -import type { CellixUiWorld } from '../../../world.ts'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { AcceptanceUiCommunityPage } from '../../../shared/page-contracts.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; -import { CommunityErrorMessage } from '../questions/community-error-message.ts'; import { CommunityName } from '../questions/community-name.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; -Given('{word} is an authenticated community owner', async function (this: CellixUiWorld, actorName: string) { - this.setCommunityActorName(actorName); +Given('{word} is an authenticated community owner', async (actorName: string) => { const actor = actorCalled(actorName); const onSave = async (values: { name: string }): Promise => { - await actor.attemptsTo(notes().set('formSubmitted', true), notes().set('communityName', values.name ?? ''), notes().set('lastValidationError', '')); + await actor.attemptsTo(notes().set('formSubmitted', true), notes().set('communityName', values.name ?? '')); }; - const rendered = mountComponent(); - this.setCommunityContainer(rendered.container); - - await actor.attemptsTo(notes().set('formSubmitted', false), notes().set('communityName', ''), notes().set('lastValidationError', '')); + await actor.attemptsTo(notes().set('formSubmitted', false), notes().set('communityName', ''), Render.component(, { wrapper: wrapOcomComponent() })); }); -When('{word} creates a community with:', async function (this: CellixUiWorld, actorName: string, dataTable: DataTable) { - this.setCommunityActorName(actorName); - const actor = actorCalled(actorName); - const { name: communityName = '' } = dataTable.rowsHash() as { name?: string }; - - await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); +When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { + const { name: communityName = '' } = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); + await actorCalled(actorName).attemptsTo(CreateCommunity(communityName)); }); -When('{word} attempts to create a community with:', async function (this: CellixUiWorld, actorName: string, dataTable: DataTable) { - this.setCommunityActorName(actorName); - const actor = actorCalled(actorName); - const { name: communityName = '' } = dataTable.rowsHash() as { name?: string }; - - await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); +When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { + const { name: communityName = '' } = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); + await actorCalled(actorName).attemptsTo(CreateCommunity(communityName)); }); -Then('the community should be created successfully', async function (this: CellixUiWorld) { - const actor = actorCalled(this.getCommunityActorName()); - const submitted = await actor.answer(CommunityCreatedFlag()); - +Then('the community should be created successfully', async () => { + const submitted = await actorInTheSpotlight().answer(CommunityCreatedFlag()); if (!submitted) { throw new Error('Expected community form to be submitted'); } }); -Then('the community name should be {string}', async function (this: CellixUiWorld, expectedName: string) { - const actor = actorCalled(this.getCommunityActorName()); - const name = await actor.answer(CommunityName()); - +Then('the community name should be {string}', async (expectedName: string) => { + const name = await actorInTheSpotlight().answer(CommunityName()); if (name !== expectedName) { throw new Error(`Expected community name "${expectedName}" but got "${name}"`); } }); -Then('{word} should see a community error for {string}', async function (this: CellixUiWorld, actorName: string, fieldName: string) { - const resolvedName = /^(she|he|they)$/i.test(actorName) ? this.getCommunityActorName() : actorName; +Then('{word} should see a community error for {string}', async (_actorName: string, fieldName: string) => { + const actor = actorInTheSpotlight(); + const page: AcceptanceUiCommunityPage = new CommunityPage(new DomPageAdapter(RenderInDom.as(actor).container)); + const errorText = (await page.firstValidationError.textContent()) ?? ''; - const container = this.getCommunityContainer(); - const adapter = new JsdomPageAdapter(container); - const page = new CommunityPage(adapter) as UiCommunityPage; + const isFieldMentioned = errorText.toLowerCase().includes(fieldName.toLowerCase()); + const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|please input/i.test(errorText); - let storedError: string | undefined; - try { - const errorEl = await page.firstValidationError; - if (errorEl) { - storedError = (await errorEl.textContent()) ?? undefined; - } - } catch { - const actor = actorCalled(resolvedName); - try { - storedError = await actor.answer(CommunityErrorMessage()); - } catch { - // No error found - } + if (!errorText || (!isFieldMentioned && !isValidationPattern)) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${errorText}"`); } - - if (storedError) { - const lowerError = storedError.toLowerCase(); - const lowerField = fieldName.toLowerCase(); - const isFieldMentioned = lowerError.includes(lowerField); - const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|please input/i.test(storedError); - - if (!isFieldMentioned && !isValidationPattern) { - throw new Error(`Expected a validation error related to "${fieldName}", but got: "${storedError}"`); - } - return; - } - - const errorElements = container.querySelectorAll('.ant-form-item-explain-error'); - if (errorElements.length > 0) { - return; - } - - throw new Error(`Expected a validation error for "${fieldName}" but none was found`); }); -Then('no community should be created', async function (this: CellixUiWorld) { - let hasValidationError = false; - try { - const actor = actorCalled(this.getCommunityActorName()); - const storedError = await actor.answer(CommunityErrorMessage()); - hasValidationError = !!storedError; - } catch { - // No error stored — check DOM - } - - if (!hasValidationError) { - const container = this.getCommunityContainer(); - const errorElements = container.querySelectorAll('.ant-form-item-explain-error'); - if (errorElements.length === 0) { - throw new Error('Expected a validation error to prevent community creation, but no error was found.'); - } +Then('no community should be created', async () => { + const submitted = await actorInTheSpotlight().answer(CommunityCreatedFlag()); + if (submitted) { + throw new Error('Expected no community to be created, but the form was submitted'); } }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts index 320723c11..39168253b 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts @@ -1,27 +1,25 @@ -import { CommunityPage, type UiCommunityPage } from '@ocom-verification/verification-shared/pages'; -import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; -import { type Activity, Task } from '@serenity-js/core'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { CommunityPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiCommunityPage } from '../../../shared/page-contracts.ts'; -async function flushAsync(): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); +/** Let the form's async `onFinish`/`onSave` handlers settle before assertions run. */ +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); } -export const CreateCommunity = (container: HTMLElement, name: string) => +export const CreateCommunity = (name: string): Task => Task.where( - `#actor fills community name "${name}" and submits`, - new TaskStep(`#actor submits community name "${name}"`, async () => { - const adapter = new JsdomPageAdapter(container); - const page: UiCommunityPage = new CommunityPage(adapter); + `#actor creates a community named "${name}"`, + new TaskStep(`#actor fills the community name "${name}" and submits`, async (actor) => { + const page: AcceptanceUiCommunityPage = new CommunityPage(new DomPageAdapter(RenderInDom.as(actor).container)); await page.fillName(name); await page.clickCreate(); - await flushAsync(); - }) as Activity, + await flushPendingReactWork(); + }), ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..70791d089 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffUiNotes { + targetRoute: string; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts new file mode 100644 index 000000000..4687fc54c --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const StaffTargetRoute = () => Question.about('staff landing target route', (actor) => actor.answer(notes().get('targetRoute'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts new file mode 100644 index 000000000..f50c14577 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; +import { StaffTargetRoute } from '../questions/staff-target-route.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const targetRoute = await actor.answer(StaffTargetRoute()); + + if (targetRoute !== expectedRoute) { + throw new Error(`Expected route to be "${expectedRoute}", but got "${targetRoute}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..8b998c9ea --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Staff context step definitions +import './create-staff-landing.steps.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..cade8e92a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,7 @@ +import { type Actor, Interaction, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where('#actor opens the staff app landing', async (actor) => { + await (actor as Actor).attemptsTo(notes().set('targetRoute', targetRoute)); + }); diff --git a/packages/ocom-verification/acceptance-ui/src/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/acceptance-ui/src/cucumber-lifecycle-hooks.ts new file mode 100644 index 000000000..4c8e10126 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/cucumber-lifecycle-hooks.ts @@ -0,0 +1,25 @@ +import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { getTimeout } from '@cellix/serenity-framework/settings'; +import { After } from '@cucumber/cucumber'; +import { actorInTheSpotlight } from '@serenity-js/core'; +import type { CellixUiWorld } from './world.ts'; + +/** Register the Cucumber Before/After hooks for the component acceptance suite. */ +export function registerLifecycleHooks(): void { + registerWorldLifecycleHooks({ + scenarioTimeout: getTimeout('scenario'), + beforeTimeout: getTimeout('uiInit'), + before: async (world) => { + await world.init(); + }, + }); + + After({ timeout: getTimeout('uiCleanup') }, () => { + try { + RenderInDom.as(actorInTheSpotlight()).unmount(); + } catch { + /* No component was rendered in this scenario. */ + } + }); +} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts new file mode 100644 index 000000000..10b34268b --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts @@ -0,0 +1,13 @@ +import { MockedProvider, type MockedResponse } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import { App, ConfigProvider } from 'antd'; +import React from 'react'; + +interface OcomComponentWrapperOptions { + mocks?: MockedResponse[]; +} + +export function wrapOcomComponent(options?: OcomComponentWrapperOptions) { + return (children: React.ReactElement): React.ReactElement => + React.createElement(HelmetProvider, null, React.createElement(ConfigProvider, null, React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, children)))); +} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts new file mode 100644 index 000000000..b7512789f --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts @@ -0,0 +1,5 @@ +import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; + +export type AcceptanceUiHomePage = Pick; + +export type AcceptanceUiCommunityPage = Pick; diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/cast.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/cast.ts deleted file mode 100644 index 53124613e..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/cast.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type Actor, Cast, Notepad, TakeNotes } from '@serenity-js/core'; - -/** - * Cast for acceptance-ui tests — each actor gets a Notepad to share - * state between steps. No server abilities needed because UI tests - * render React components directly in jsdom. - */ -export class CellixUiCast extends Cast { - prepare(actor: Actor): Actor { - return actor.whoCan(TakeNotes.using(Notepad.empty())); - } -} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts deleted file mode 100644 index 212d84a85..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { After, Before, setDefaultTimeout } from '@cucumber/cucumber'; -import { getTimeout } from '@ocom-verification/verification-shared/settings'; -import type { CellixUiWorld } from '../../world.ts'; -import { unmountComponent } from './ui/react-render.ts'; - -/** Default scenario timeout from centralized configuration */ -setDefaultTimeout(getTimeout('scenario')); - -Before({ timeout: getTimeout('uiInit') }, async function (this: CellixUiWorld) { - await this.init(); -}); - -After({ timeout: getTimeout('uiCleanup') }, () => { - unmountComponent(); -}); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs deleted file mode 100644 index 472a0c864..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs +++ /dev/null @@ -1,34 +0,0 @@ -/** - * ESM loader hooks that intercept CSS, image, and other non-JS imports so - * they resolve to empty modules instead of throwing in Node.js. - * - * Usage: `NODE_OPTIONS='--import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js` - */ - -const ASSET_RE = /\.(css|less|scss|sass|svg|png|jpe?g|gif|webp|woff2?|ttf|eot|ico)$/i; - -/** - * @param {string} specifier - * @param {{ parentURL?: string }} context - * @param {Function} nextResolve - */ -export async function resolve(specifier, context, nextResolve) { - if (ASSET_RE.test(specifier)) { - return { - shortCircuit: true, - url: `data:text/javascript,export default ''`, - }; - } - - // Redirect antd/es/* to antd/lib/* for CJS/ESM compatibility in Node.js - if (specifier.includes('antd/es/')) { - const redirected = specifier.replace('antd/es/', 'antd/lib/'); - try { - return await nextResolve(redirected, context); - } catch { - // fall through to default - } - } - - return nextResolve(specifier, context); -} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts deleted file mode 100644 index 6b7116689..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * JSDOM environment setup — initialises global browser APIs that libraries like - * antd or React Router rely on at import time. - * - * Must be imported before any React component code runs. - */ - -import { JSDOM } from 'jsdom'; - -const dom = new JSDOM('
', { - url: 'http://localhost:3000', - pretendToBeVisual: true, -}); -// biome-ignore lint/complexity/useLiteralKeys: `dom.window` is exposed via JSDOM's index signature, requiring bracket access under strict TypeScript -const domGlobal = dom['window'] as unknown as Window & typeof globalThis; - -// biome-ignore lint/suspicious/noExplicitAny: attaching browser globals requires dynamic property assignment -const g = globalThis as any; - -/** - * Safely assign a global — falls back to Object.defineProperty when the - * property is read-only (e.g. `navigator` in Node 22). - */ -const safeAssign = (name: string, value: unknown) => { - try { - g[name] = value; - } catch { - Object.defineProperty(globalThis, name, { - value, - writable: true, - configurable: true, - }); - } -}; - -safeAssign('window', domGlobal); -safeAssign('document', domGlobal.document); -safeAssign('navigator', domGlobal.navigator); -safeAssign('HTMLElement', domGlobal.HTMLElement); -safeAssign('HTMLInputElement', domGlobal.HTMLInputElement); -safeAssign('HTMLTextAreaElement', domGlobal.HTMLTextAreaElement); -safeAssign('HTMLFormElement', domGlobal.HTMLFormElement); -safeAssign('HTMLButtonElement', domGlobal.HTMLButtonElement); -safeAssign('HTMLSelectElement', domGlobal.HTMLSelectElement); -safeAssign('HTMLAnchorElement', domGlobal.HTMLAnchorElement); -safeAssign('Element', domGlobal.Element); -safeAssign('SVGElement', domGlobal.SVGElement); -safeAssign('ShadowRoot', domGlobal.ShadowRoot ?? class ShadowRoot {}); -safeAssign('Node', domGlobal.Node); -safeAssign('NodeList', domGlobal.NodeList); -safeAssign('Event', domGlobal.Event); -safeAssign('CustomEvent', domGlobal.CustomEvent); -safeAssign('KeyboardEvent', domGlobal.KeyboardEvent); -safeAssign('MouseEvent', domGlobal.MouseEvent); -safeAssign('getComputedStyle', domGlobal.getComputedStyle); -safeAssign('requestAnimationFrame', (cb: () => void) => setTimeout(cb, 0)); -safeAssign('cancelAnimationFrame', (id: number) => clearTimeout(id)); -safeAssign('location', domGlobal.location); -safeAssign('history', domGlobal.history); -safeAssign('MutationObserver', domGlobal.MutationObserver); -safeAssign('URL', domGlobal.URL); -safeAssign('URLSearchParams', domGlobal.URLSearchParams); -safeAssign('SubmitEvent', domGlobal.SubmitEvent); - -/* --- Stubs for APIs not supported by jsdom --- */ - -domGlobal.matchMedia = - domGlobal.matchMedia || - (() => ({ - matches: false, - addListener: () => { - /* noop stub */ - }, - removeListener: () => { - /* noop stub */ - }, - addEventListener: () => { - /* noop stub */ - }, - removeEventListener: () => { - /* noop stub */ - }, - dispatchEvent: () => false, - media: '', - onchange: null, - })); - -g.ResizeObserver = - g.ResizeObserver || - class { - observe() { - /* noop stub */ - } - unobserve() { - /* noop stub */ - } - disconnect() { - /* noop stub */ - } - }; - -g.IntersectionObserver = - g.IntersectionObserver || - class { - observe() { - /* noop stub */ - } - unobserve() { - /* noop stub */ - } - disconnect() { - /* noop stub */ - } - }; - -domGlobal.scrollTo = - domGlobal.scrollTo || - (() => { - /* noop stub */ - }); -domGlobal.scroll = - domGlobal.scroll || - (() => { - /* noop stub */ - }); -domGlobal.resizeTo = - domGlobal.resizeTo || - (() => { - /* noop stub */ - }); - -domGlobal.getComputedStyle = - domGlobal.getComputedStyle || - (() => ({ - getPropertyValue: () => '', - })); - -g.document.elementFromPoint = g.document.elementFromPoint || (() => null); -g.document.elementsFromPoint = g.document.elementsFromPoint || (() => []); - -// jsdom does not implement form.requestSubmit(), but clicking a submit button -// uses it internally. Dispatch a cancelable submit event so form handlers can -// drive test state the same way a browser-backed form flow would. -g.HTMLFormElement.prototype.requestSubmit = function requestSubmit(submitter?: HTMLElement) { - if (typeof this.checkValidity === 'function' && !this.checkValidity()) { - return; - } - - const submitEvent = new g.Event('submit', { - bubbles: true, - cancelable: true, - }); - - Object.defineProperty(submitEvent, 'submitter', { - value: submitter ?? null, - configurable: true, - }); - - this.dispatchEvent(submitEvent); -}; diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom.d.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom.d.ts deleted file mode 100644 index b8f49557a..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module 'jsdom' { - export class JSDOM { - constructor(html?: string, options?: Record); - readonly [key: string]: Window & typeof globalThis; - } -} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts deleted file mode 100644 index d48b7eee8..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { MockedProvider, type MockedResponse } from '@apollo/client/testing'; -import { HelmetProvider } from '@dr.pogodin/react-helmet'; -import { type RenderResult, render } from '@testing-library/react'; -import { App, ConfigProvider } from 'antd'; -import React from 'react'; - -let rendered: RenderResult | null = null; - -export interface MountOptions { - mocks?: MockedResponse[]; -} - -export function mountComponent(ui: React.ReactElement, options?: MountOptions): RenderResult { - unmountComponent(); - - const wrapped = React.createElement(HelmetProvider, null, React.createElement(ConfigProvider, null, React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, ui)))); - - rendered = render(wrapped); - return rendered; -} - -export function unmountComponent(): void { - if (rendered) { - rendered.unmount(); - rendered = null; - } -} - -export function getRendered(): RenderResult | null { - return rendered; -} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts deleted file mode 100644 index 917bda93a..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Registers the asset-loader ESM hooks so CSS/image imports resolve - * without errors in Node.js. - * - * Use via NODE_OPTIONS: `NODE_OPTIONS='--import ./src/shared/support/ui/register-asset-loader.ts'` - * or by adding `--import` to the cucumber-js invocation. - */ -import { register } from 'node:module'; - -register(new URL('./asset-loader-hooks.mjs', import.meta.url).href, import.meta.url); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts deleted file mode 100644 index 67f8ee07d..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Side-effect import that initialises the jsdom environment. - * Consumed by cucumber.js --import or NODE_OPTIONS --import. - */ -import './jsdom-setup.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts index 2107af436..585f6f459 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -3,7 +3,6 @@ * Cucumber imports this file, which then loads all context-specific step definitions. */ -import '../shared/support/ui/setup-jsdom.ts'; -import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/world.ts b/packages/ocom-verification/acceptance-ui/src/world.ts index 79e0fd932..df5d9648f 100644 --- a/packages/ocom-verification/acceptance-ui/src/world.ts +++ b/packages/ocom-verification/acceptance-ui/src/world.ts @@ -1,48 +1,20 @@ -import { setWorldConstructor, World } from '@cucumber/cucumber'; -import { type Cast, serenity } from '@serenity-js/core'; -import { CellixUiCast } from './shared/support/cast.ts'; - -export class CellixUiWorld extends World { - private cast!: Cast; - private communityContainer: HTMLElement | null = null; - private communityActorName = ''; - private headerContainer: HTMLElement | null = null; - - init(): Promise { - this.cast = new CellixUiCast(); - serenity.engage(this.cast); - return Promise.resolve(); - } - - setCommunityContainer(container: HTMLElement): void { - this.communityContainer = container; - } - - getCommunityContainer(): HTMLElement { - if (!this.communityContainer) { - throw new Error('No community container available — did the Given step run?'); - } - return this.communityContainer; - } - - setCommunityActorName(actorName: string): void { - this.communityActorName = actorName; - } - - getCommunityActorName(): string { - return this.communityActorName; - } - - setHeaderContainer(container: HTMLElement): void { - this.headerContainer = container; - } - - getHeaderContainer(): HTMLElement { - if (!this.headerContainer) { - throw new Error('No header container available — did the Given step run?'); - } - return this.headerContainer; - } -} - -setWorldConstructor(CellixUiWorld); +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; +import { registerLifecycleHooks } from './cucumber-lifecycle-hooks.ts'; + +export const CellixUiWorld = registerManagedSerenityWorld>({ + infrastructure: { + ensureStarted: () => Promise.resolve(), + getState: () => ({}), + }, + createCast: () => + new SerenityCast({ + useNotepad: true, + abilities: [() => new RenderInDom()], + }), +}); + +export type CellixUiWorld = InstanceType; + +registerLifecycleHooks(); diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index 026eb254a..5966e2dba 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -13,5 +13,5 @@ "rootDir": "../..", "outDir": "./dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx", "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] } diff --git a/packages/ocom-verification/e2e-tests/cucumber.js b/packages/ocom-verification/e2e-tests/cucumber.js index e548d91e5..38c29160d 100644 --- a/packages/ocom-verification/e2e-tests/cucumber.js +++ b/packages/ocom-verification/e2e-tests/cucumber.js @@ -3,7 +3,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], import: ['src/world.ts', 'src/step-definitions/index.ts'], - format: [...(isAgent ? ['../verification-shared/src/formatters/agent-formatter.ts'] : ['progress-bar']), 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'], + format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'], formatOptions: { snippetInterface: 'async-await', }, diff --git a/packages/ocom-verification/e2e-tests/package.json b/packages/ocom-verification/e2e-tests/package.json index d0653152b..7cd34c0a0 100644 --- a/packages/ocom-verification/e2e-tests/package.json +++ b/packages/ocom-verification/e2e-tests/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@cucumber/cucumber": "catalog:", + "@cellix/serenity-framework": "workspace:*", "@serenity-js/assertions": "catalog:", "@serenity-js/console-reporter": "catalog:", "@serenity-js/core": "catalog:", diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/notes/header-notes.ts similarity index 100% rename from packages/ocom-verification/e2e-tests/src/contexts/authentication/abilities/header-types.ts rename to packages/ocom-verification/e2e-tests/src/contexts/authentication/notes/header-notes.ts diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts index 998ec4dcd..c5860ec97 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -1,9 +1,9 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actorCalled, notes } from '@serenity-js/core'; import type { BrowserContext, Page } from 'playwright'; -import * as infra from '../../../shared/support/shared-infrastructure.ts'; +import * as infra from '../../../infrastructure.ts'; import type { CellixE2EWorld } from '../../../world.ts'; -import type { HeaderE2ENotes, HeaderE2ESite } from '../abilities/header-types.ts'; +import type { HeaderE2ENotes, HeaderE2ESite } from '../notes/header-notes.ts'; import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; interface HeaderE2EState { @@ -62,17 +62,10 @@ When('{word} chooses to sign in', async function (this: HeaderE2EWorld, actorNam s.actorName = actorName; const actor = actorCalled(actorName); - const { browser } = infra.getState(); - if (!browser) throw new Error('Browser not launched'); - - const baseUrl = s.site === 'community' ? (infra.getState().communityBaseUrl ?? 'https://ownercommunity.localhost:1355') : (infra.getState().staffBaseUrl ?? 'https://staff.ownercommunity.localhost:1355'); - - // Fresh unauthenticated context — isolated from the pre-auth context - // used by other test suites. Cleaned up in the Then step after verification. - const context = await browser.newContext({ - baseURL: baseUrl, - ignoreHTTPSErrors: true, - }); + // Fresh unauthenticated context for the portal under test — its baseURL is the + // portal's own URL. Isolated from the pre-auth context used by other suites; + // cleaned up in the Then step after verification. + const context = await infra.newPortalContext(s.site); s.context = context; if (s.identityProviderUnreachable) { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts index bdf690de1..bdbc54a93 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,9 +1,10 @@ -import { type E2EHomePage, HomePage } from '@ocom-verification/verification-shared/pages'; -import { PlaywrightPageAdapter } from '@ocom-verification/verification-shared/pages/playwright'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { HomePage } from '@ocom-verification/verification-shared/pages'; import { type Activity, type Actor, notes, Task } from '@serenity-js/core'; import type { Page } from 'playwright'; -import type { HeaderE2ENotes, HeaderE2ESite } from '../abilities/header-types.ts'; +import type { E2EHomePage } from '../../../shared/page-contracts.ts'; +import type { HeaderE2ENotes, HeaderE2ESite } from '../notes/header-notes.ts'; const portalCredentials: Record = { community: { username: 'test@example.com', password: 'password' }, diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts similarity index 100% rename from packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-types.ts rename to packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts index f89c53ceb..89b92e21a 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; export const CommunityCreatedFlag = () => Question.about('whether the community was created', (actor) => actor.answer(notes().get('communityCreated'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts index edc11de1a..9d0fa77d8 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; export const CommunityErrorMessage = () => Question.about('the community error message', (actor) => actor.answer(notes().get('errorMessage'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts index 8d4cf0a69..9858c66cb 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; export const CommunityName = () => Question.about('the name of the created community', (actor) => actor.answer(notes().get('communityName'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts index a8d602956..a13835647 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts @@ -1,8 +1,10 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; -import { OAuth2Login } from '../../../shared/support/oauth2-login.ts'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; import { CommunityErrorMessage } from '../questions/community-error-message.ts'; import { CommunityName } from '../questions/community-name.ts'; @@ -13,13 +15,13 @@ let lastActorName = actors.CommunityOwner.name; Given('{word} is an authenticated community owner', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - await actor.attemptsTo(OAuth2Login(actors.CommunityOwner.email)); + await actor.attemptsTo(LogInWithOAuth2(actors.CommunityOwner.email)); }); When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash(); + const details = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); const name = details['name'] ?? ''; await actor.attemptsTo(CreateCommunity(name)); @@ -28,7 +30,7 @@ When('{word} creates a community with:', async (actorName: string, dataTable: Da When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash(); + const details = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); const name = details['name'] ?? ''; try { @@ -58,7 +60,7 @@ Then('the community name should be {string}', async (expectedName: string) => { }); Then('{word} should see a community error for {string}', async (actorName: string, fieldName: string) => { - const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const resolvedName = ActorName.resolve(actorName, { defaultName: lastActorName }); const actor = actorCalled(resolvedName); const errorMessage = await actor.answer(CommunityErrorMessage()); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts index 1f67af90e..fa2dbab08 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts @@ -1,10 +1,11 @@ -import { CommunityPage, type E2ECommunityPage } from '@ocom-verification/verification-shared/pages'; -import { PlaywrightPageAdapter } from '@ocom-verification/verification-shared/pages/playwright'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { CommunityPage } from '@ocom-verification/verification-shared/pages'; import { type Activity, type Actor, notes, Task, the } from '@serenity-js/core'; import type { Response } from 'playwright'; -import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { E2ECommunityPage } from '../../../shared/page-contracts.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; const createCommunityOperationName = 'AccountsCommunityCreateContainerCommunityCreate'; const communityListOperationName = 'AccountsCommunityListContainerCommunitiesForCurrentEndUser'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..e7dab9ca2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffE2ENotes { + currentPath: string; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts new file mode 100644 index 000000000..1ff8c76b0 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const StaffCurrentPath = () => Question.about('current staff app path', (actor) => actor.answer(notes().get('currentPath'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..954f3a337 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-landing.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts new file mode 100644 index 000000000..8ea22e414 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; +import { StaffCurrentPath } from '../questions/staff-current-path.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const currentPath = await actor.answer(StaffCurrentPath()); + + if (currentPath !== expectedRoute) { + throw new Error(`Expected path "${expectedRoute}", but got "${currentPath}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..d777aba21 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,8 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where(the`#actor opens staff landing`, async (actor) => { + const fullActor = actor as unknown as Actor; + await fullActor.attemptsTo(notes().set('currentPath', targetRoute)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts new file mode 100644 index 000000000..511fc56e0 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; +import { registerScreenshotOnFailureHook } from '@cellix/serenity-framework/cucumber/screenshot'; +import { getTimeout } from '@cellix/serenity-framework/settings'; +import type { IWorld } from '@cucumber/cucumber'; +import * as infra from './infrastructure.ts'; +import type { CellixE2EWorld } from './world.ts'; + +const currentDir = fileURLToPath(new URL('.', import.meta.url)); + +/** Register the Cucumber Before/After/AfterAll and screenshot hooks for the E2E suite. */ +export function registerLifecycleHooks(): void { + registerWorldLifecycleHooks({ + scenarioTimeout: getTimeout('scenario'), + before: async (world) => { + await world.init(); + }, + after: async (world) => { + await world.cleanup(); + }, + afterAll: () => infra.stopAll(), + }); + + registerScreenshotOnFailureHook({ + reportsDir: path.resolve(currentDir, '..', '..', 'reports', 'screenshots'), + }); +} diff --git a/packages/ocom-verification/e2e-tests/src/infrastructure.ts b/packages/ocom-verification/e2e-tests/src/infrastructure.ts new file mode 100644 index 000000000..89d401faa --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/infrastructure.ts @@ -0,0 +1,44 @@ +import { E2EInfrastructure, type E2EInfrastructureState } from '@cellix/serenity-framework/infrastructure/e2e'; +import { MongoMemoryTestServer } from '@cellix/serenity-framework/servers'; +import { getMongoPort } from '@ocom-verification/verification-shared/environment'; +import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; +import type { BrowserContext } from 'playwright'; +import { cleanupTestEnvironment, createCommunityUiPortalServer, createStaffUiPortalServer, createTestApiServer, createTestAzuriteServer, createTestOAuth2Server, initTestEnvironment } from './test-server-factories.ts'; + +const apiDbName = 'owner-community'; + +const infrastructure = E2EInfrastructure.create({ + // baseURL is supplied per portal by the framework; only shared options here. + browserContextOptions: { ignoreHTTPSErrors: true }, + cleanupEnvironment: cleanupTestEnvironment, + setupEnvironment: initTestEnvironment, +}) + .addServer('mongo', () => new MongoMemoryTestServer({ dbName: apiDbName, port: getMongoPort(), replSetName: 'globaldb', seedData: seedDatabase }), { + resetForScenario: (server) => (server as MongoMemoryTestServer).resetForScenario(), + }) + .addServer('azurite', () => createTestAzuriteServer()) + .addServer('auth', () => createTestOAuth2Server()) + .addServer('api', (ctx) => createTestApiServer(() => ctx.server('mongo').getConnectionString()), { dependsOn: ['mongo'] }) + .addUiPortal('community', () => createCommunityUiPortalServer()) + .addUiPortal('staff', () => createStaffUiPortalServer()); + +export function getState(): E2EInfrastructureState { + return infrastructure.getState(); +} + +export function newPortalContext(site: 'community' | 'staff'): Promise { + return infrastructure.newPortalContext(site); +} + +export async function resetScenarioState(): Promise { + await infrastructure.resetScenarioState(); +} + +export async function stopAll(): Promise { + await infrastructure.stopAll(); +} + +export async function ensureE2EServers(): Promise { + infrastructure.registerProcessShutdownHandlers(); + await infrastructure.ensureStarted(); +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts new file mode 100644 index 000000000..2425aa4f9 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts @@ -0,0 +1,108 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { Ability, type Activity, type Actor, Task, the } from '@serenity-js/core'; +import type { Page } from 'playwright'; + +/** Credentials used by the E2E OAuth2 login flow. */ +export interface OAuth2Credentials { + /** Email or username submitted to the mock OAuth2 login form. */ + email: string; + + /** Password submitted to the mock OAuth2 login form. */ + password: string; +} + +/** Options that configure the E2E OAuth2 login ability. */ +export interface OAuth2LoginOptions { + /** Protected route used to trigger the OIDC redirect flow. */ + protectedPath: string; +} + +/** + * URL predicate that resolves once the OIDC redirect chain has settled — + * i.e. we are no longer on the mock-auth hostname or the /auth-redirect + * callback path. + */ +const isPostAuthUrl = (url: URL) => !url.hostname.includes('mock-auth') && !url.pathname.includes('auth-redirect'); + +/** + * Authenticates the browser session via the OIDC auto-redirect flow. + * + * The app uses RequireAuth + react-oidc-context. When an unauthenticated + * user hits a protected route, RequireAuth calls `signinRedirect()` which + * navigates to the mock OAuth2 server's `/authorize` endpoint. The mock + * server redirects to `/login` (since userStore is configured). This + * function fills in the test user credentials and submits the form. + */ +export async function performOAuth2Login(page: Page, credentials: OAuth2Credentials, protectedPath: string): Promise { + // Navigate to a protected route to trigger the OIDC signinRedirect flow. + try { + await page.goto(protectedPath, { + waitUntil: 'networkidle', + timeout: 60_000, + }); + } catch { + // Navigation may be interrupted by OIDC redirect — this is expected + } + + // Wait for redirects to settle on either the login page or the app + await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => undefined); + + // If the mock OAuth2 login form is shown, fill credentials and submit. + // CommunityOwner is defined in mock-oidc.users.json with password "password". + if (page.url().includes('/login')) { + await page.fill('input[name="username"]', credentials.email); + await page.fill('input[name="password"]', credentials.password); + await page.click('button[type="submit"]'); + } + + // Wait for the redirect chain to settle on an authenticated page + await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); + await page.waitForLoadState('networkidle'); +} + +/** Serenity ability that authenticates an E2E actor through the OCOM OAuth2 flow. */ +export class OAuth2Login extends Ability { + /** + * @param options Route and flow options for the OAuth2 login ability. + */ + constructor(private readonly options: OAuth2LoginOptions) { + super(); + } + + /** + * Create an OAuth2 login ability for the supplied protected route. + * + * @param protectedPath Protected route used to trigger the OIDC redirect flow. + */ + static throughProtectedRoute(protectedPath: string): OAuth2Login { + return new OAuth2Login({ protectedPath }); + } + + /** + * Authenticate the actor's current browser page. + * + * @param actor Actor that has the `BrowseTheWeb` ability. + * @param credentials Credentials submitted to the mock OAuth2 form. + */ + async authenticate(actor: Actor, credentials: OAuth2Credentials): Promise { + const { page } = BrowseTheWeb.withActor(actor); + await performOAuth2Login(page, credentials, this.options.protectedPath); + } +} + +/** + * Screenplay Task — confirms the actor is authenticated. + * + * The browser context is pre-authenticated by {@link performOAuth2Login} + * during server setup. This task navigates to a protected route and + * verifies the page loads without being kicked to the auth provider. + */ +export const LogInWithOAuth2 = (email = actors.CommunityOwner.email, password = 'password') => + Task.where( + the`#actor logs in via OAuth2`, + new TaskStep('#actor confirms the OAuth2 session is active', async (actor) => { + await OAuth2Login.as(actor as Actor).authenticate(actor as Actor, { email, password }); + }) as Activity, + ); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/app-paths.ts similarity index 86% rename from packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts rename to packages/ocom-verification/e2e-tests/src/shared/environment/app-paths.ts index d8ea7b022..9a5e789f7 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/app-paths.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const currentDir = dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = resolve(currentDir, '../../../../../../..'); +const workspaceRoot = resolve(currentDir, '../../../../../..'); export const appPaths = { apiDir: resolve(workspaceRoot, 'apps/api'), diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/dev-script.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/dev-script.ts similarity index 100% rename from packages/ocom-verification/e2e-tests/src/shared/support/servers/dev-script.ts rename to packages/ocom-verification/e2e-tests/src/shared/environment/dev-script.ts diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/resolve-portless.ts similarity index 89% rename from packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts rename to packages/ocom-verification/e2e-tests/src/shared/environment/resolve-portless.ts index 42c7f7827..9c5f978f9 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/resolve-portless.ts @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const currentDir = dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = resolve(currentDir, '../../../../../../..'); +const workspaceRoot = resolve(currentDir, '../../../../../..'); let resolvedPath: string | undefined; diff --git a/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts new file mode 100644 index 000000000..cfb488e4c --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getPortlessPath } from './resolve-portless.ts'; + +let proxyInitialized = false; + +loadE2EEnvDefaults(); + +export type OcomPortlessHostKey = 'api' | 'mockAuth' | 'uiCommunity' | 'uiStaff'; + +export function getHostnames(): Record { + const hostnames = resolvePortlessHostnames({ + keys: { + api: 'VITE_COMMON_API_ENDPOINT', + mockAuth: 'VITE_APP_UI_COMMUNITY_B2C_AUTHORITY', + uiCommunity: 'VITE_APP_UI_COMMUNITY_BASE_URL', + uiStaff: 'VITE_APP_UI_STAFF_AAD_REDIRECT_URI', + }, + }); + + return { + ...hostnames, + docs: `docs.${hostnames.uiCommunity}`, + }; +} + +const hostnames = getHostnames(); + +export const mockOidcAudience = 'mock-client'; +export const mockOidcIssuer = buildUrl(hostnames.mockAuth, '/community'); +export const mockOidcEndpoint = `${mockOidcIssuer}/.well-known/jwks.json`; +export const mockStaffOidcIssuer = buildUrl(hostnames.mockAuth, '/staff'); + +/** + * Ensure the portless proxy is running for the PR's worktree-scoped hostnames. + */ +export function initTestEnvironment() { + if (proxyInitialized) return; + + execFileSync(getPortlessPath(), ['prune'], { + timeout: 10_000, + stdio: 'pipe', + }); + execFileSync(getPortlessPath(), ['proxy', 'start', '--https', '-p', '1355'], { + timeout: 15_000, + stdio: 'pipe', + }); + + proxyInitialized = true; +} + +export function buildUrl(hostname: string, path = ''): string { + return `https://${hostname}:1355${path}`; +} + +export function cleanupTestEnvironment(): void { + proxyInitialized = false; +} + +function loadE2EEnvDefaults(): void { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const workspaceRoot = resolve(currentDir, '../../../../../..'); + for (const filePath of [resolve(workspaceRoot, 'apps/ui-community/.env.e2e'), resolve(workspaceRoot, 'apps/ui-staff/.env.e2e')]) { + if (!existsSync(filePath)) continue; + for (const line of readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + const key = trimmed.slice(0, idx); + process.env[key] ??= trimmed.slice(idx + 1); + } + } +} + +interface ResolvePortlessHostnamesOptions { + keys: Record; + env?: NodeJS.ProcessEnv; + worktreeName?: string; +} + +function resolvePortlessHostnames(options: ResolvePortlessHostnamesOptions): Record { + const env = options.env ?? process.env; + const worktreeName = options.worktreeName ?? env['WORKTREE_NAME'] ?? ''; + const hostnames = {} as Record; + + for (const [logicalName, envName] of Object.entries(options.keys) as Array<[TKey, string]>) { + hostnames[logicalName] = applyWorktreeSuffix(requireHostname(envName, env), worktreeName); + } + + return hostnames; +} + +function applyWorktreeSuffix(hostname: string, worktreeName: string): string { + if (!worktreeName) return hostname; + return hostname.replace('.localhost', `.${worktreeName}.localhost`); +} + +function requireHostname(key: string, env: NodeJS.ProcessEnv): string { + const url = env[key] ?? ''; + try { + return new URL(url).hostname; + } catch { + throw new Error(`e2e test environment: required env var ${key} is missing or invalid`); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts new file mode 100644 index 000000000..677ab56e7 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts @@ -0,0 +1,5 @@ +import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; + +export type E2EHomePage = Pick; + +export type E2ECommunityPage = Pick; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/cast.ts b/packages/ocom-verification/e2e-tests/src/shared/support/cast.ts deleted file mode 100644 index a88926e1c..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/cast.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; -import type { BrowseTheWeb } from '../abilities/browse-the-web.ts'; - -export class CellixE2ECast implements Cast { - constructor(private readonly browseTheWeb?: BrowseTheWeb) {} - - prepare(actor: Actor): Actor { - if (!this.browseTheWeb) { - throw new Error('E2E tests require a browser'); - } - return actor.whoCan(TakeNotes.using(Notepad.empty()), this.browseTheWeb); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts b/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts deleted file mode 100644 index 477877fc7..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts +++ /dev/null @@ -1,47 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { ITestCaseHookParameter, IWorld } from '@cucumber/cucumber'; -import { After, AfterAll, Before, Status, setDefaultTimeout } from '@cucumber/cucumber'; -import { getTimeout } from '@ocom-verification/verification-shared/settings'; -import { type CellixE2EWorld, stopSharedServers } from '../../world.ts'; -import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; - -const currentDir = fileURLToPath(new URL('.', import.meta.url)); - -/** Default scenario timeout from centralized configuration */ -setDefaultTimeout(getTimeout('scenario')); - -Before(async function (this: IWorld) { - const world = this as IWorld & CellixE2EWorld; - await world.init(); -}); - -After(async function (this: IWorld, { result, pickle }: ITestCaseHookParameter) { - const world = this as IWorld & CellixE2EWorld; - - if (result?.status === Status.FAILED) { - try { - const browseTheWeb = BrowseTheWeb.current(); - if (browseTheWeb) { - const reportsDir = path.resolve(currentDir, '..', '..', '..', 'reports', 'screenshots'); - fs.mkdirSync(reportsDir, { recursive: true }); - - const safeName = pickle.name.replaceAll(/[^a-zA-Z0-9-_]/g, '_').slice(0, 80); - const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); - const screenshotPath = path.join(reportsDir, `${safeName}-${timestamp}.png`); - - await browseTheWeb.page.screenshot({ path: screenshotPath, fullPage: true }); - this.attach(fs.readFileSync(screenshotPath), 'image/png'); - } - } catch { - /* Screenshot capture is best-effort */ - } - } - - await world.cleanup(); -}); - -AfterAll(async () => { - await stopSharedServers(); -}); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts deleted file mode 100644 index 92911f16d..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; -import { actors } from '@ocom-verification/verification-shared/test-data'; -import { type Activity, type Actor, Task, the } from '@serenity-js/core'; -import type { Page } from 'playwright'; -import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; - -/** - * URL predicate that resolves once the OIDC redirect chain has settled — - * i.e. we are no longer on the mock-auth hostname or the /auth-redirect - * callback path. - */ -const isPostAuthUrl = (url: URL) => !url.hostname.includes('mock-auth') && !url.pathname.includes('auth-redirect'); - -/** - * Authenticates the browser session via the OIDC auto-redirect flow. - * - * The app uses RequireAuth + react-oidc-context. When an unauthenticated - * user hits a protected route, RequireAuth calls `signinRedirect()` which - * navigates to the mock OAuth2 server's `/authorize` endpoint. The mock - * server redirects to `/login` (since userStore is configured). This - * function fills in the test user credentials and submits the form. - */ -export async function performOAuth2Login(page: Page): Promise { - // Navigate to a protected route to trigger the OIDC signinRedirect flow. - try { - await page.goto('/community/accounts', { - waitUntil: 'networkidle', - timeout: 60_000, - }); - } catch { - // Navigation may be interrupted by OIDC redirect — this is expected - } - - // Wait for redirects to settle on either the login page or the app - await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => undefined); - - // If the mock OAuth2 login form is shown, fill credentials and submit. - // CommunityOwner is defined in mock-oidc.users.json with password "password". - if (page.url().includes('/login')) { - await page.fill('input[name="username"]', actors.CommunityOwner.email); - await page.fill('input[name="password"]', 'password'); - await page.click('button[type="submit"]'); - } - - // Wait for the redirect chain to settle on an authenticated page - await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); - await page.waitForLoadState('networkidle'); -} - -/** - * Screenplay Task — confirms the actor is authenticated. - * - * The browser context is pre-authenticated by {@link performOAuth2Login} - * during server setup. This task navigates to a protected route and - * verifies the page loads without being kicked to the auth provider. - */ -export const OAuth2Login = (_email?: string, _password?: string) => - Task.where( - the`#actor logs in via OAuth2`, - new TaskStep('#actor confirms the OAuth2 session is active', async (actor) => { - const { page } = BrowseTheWeb.withActor(actor as Actor); - - // Session tokens live in sessionStorage from pre-auth. - try { - await page.goto('/community/accounts', { - waitUntil: 'networkidle', - timeout: 30_000, - }); - } catch { - // Navigation may be interrupted by OIDC redirect on first access - } - - if (page.url().includes('/login')) { - await page.fill('input[name="username"]', actors.CommunityOwner.email); - await page.fill('input[name="password"]', 'password'); - await page.click('button[type="submit"]'); - } - - await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); - }) as Activity, - ); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts deleted file mode 100644 index 1ce570cd1..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function spawnEnv(overrides: Record = {}): NodeJS.ProcessEnv { - const { NODE_OPTIONS: _ignored, ...baseEnv } = process.env; - return { ...baseEnv, ...overrides }; -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts deleted file mode 100644 index 3f7b41971..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; -export { PortlessServer } from './portless-server.ts'; -export { TestApiServer } from './test-api-server.ts'; -export { TestAzuriteServer } from './test-azurite-server.ts'; -export { TestCommunityViteServer } from './test-community-vite-server.ts'; -export { - buildUrl, - cleanupTestEnvironment, - initTestEnvironment, - mockOidcAudience, - mockOidcEndpoint, - mockOidcIssuer, - mockStaffOidcIssuer, - setMongoConnectionString, -} from './test-environment.ts'; -export { TestOAuth2Server } from './test-oauth2-server.ts'; -export { TestStaffViteServer } from './test-staff-vite-server.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts deleted file mode 100644 index feb84d100..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import type { TestServer } from '@ocom-verification/verification-shared/servers'; -import { getTimeout } from '@ocom-verification/verification-shared/settings'; -import { spawnEnv } from './child-process-env.ts'; -import { getPortlessPath } from './resolve-portless.ts'; - -/** - * Abstract base class for portless-proxied servers. - * Subclasses define the hostname, command, ready marker, probe URL, and working directory. - */ -export abstract class PortlessServer implements TestServer { - private process: ChildProcess | null = null; - private startedByUs = false; - private readonly useDetachedProcessGroup = process.platform !== 'win32'; - - protected abstract get probeUrl(): string; - protected abstract get readyMarker(): string; - protected abstract get serverName(): string; - protected abstract get spawnArgs(): string[]; - protected abstract get cwd(): string; - - protected get executable(): string { - return getPortlessPath(); - } - - protected get probeRequestInit(): RequestInit { - return {}; - } - - protected get extraEnv(): Record { - return {}; - } - - protected get startupTimeoutMs(): number { - return getTimeout('serverStartup'); - } - - protected isProbeHealthy(response: Response): boolean | Promise { - return response.ok; - } - - isAlreadyRunning(): Promise { - return this.isProbeReadyWithin(getTimeout('healthProbe')); - } - - abstract getUrl(): string; - - async start(): Promise { - if (this.process || this.startedByUs) return; - if (await this.isAlreadyRunning()) return; - - this.process = spawn(this.executable, this.spawnArgs, { - cwd: this.cwd, - env: spawnEnv(this.extraEnv), - detached: this.useDetachedProcessGroup, - stdio: ['ignore', 'pipe', 'pipe'], - }); - this.startedByUs = true; - - await this.waitForReady(); - } - - async stop(): Promise { - if (!this.process || !this.startedByUs) return; - - const proc = this.process; - this.process = null; - this.startedByUs = false; - - this.killProcess(proc, 'SIGINT'); - - const shutdownTimeout = getTimeout('serverShutdown'); - await new Promise((resolve) => { - const timeout = setTimeout(() => { - this.killProcess(proc, 'SIGKILL'); - resolve(); - }, shutdownTimeout); - - proc.on('exit', () => { - clearTimeout(timeout); - resolve(); - }); - }); - } - - isRunning(): boolean { - return this.process !== null; - } - - private waitForReady(): Promise { - return new Promise((resolve, reject) => { - const proc = this.process; - if (!proc) { - reject(new Error(`${this.serverName} process not started`)); - return; - } - - const startupTimeout = this.startupTimeoutMs; - const startupDeadline = Date.now() + startupTimeout; - const timeout = setTimeout(() => { - reject(new Error(`${this.serverName} did not start within ${startupTimeout}ms`)); - }, startupTimeout); - - let stderrOutput = ''; - let ready = false; - - const resolveWhenReachable = () => { - if (ready) { - return; - } - ready = true; - - this.waitForProbeReady(startupDeadline, startupTimeout) - .then(() => { - clearTimeout(timeout); - resolve(); - }) - .catch((error: unknown) => { - clearTimeout(timeout); - reject(error); - }); - }; - - proc.stdout?.on('data', (data: Buffer) => { - const text = data.toString(); - if (text.includes(this.readyMarker)) { - resolveWhenReachable(); - } - }); - - proc.stderr?.on('data', (data: Buffer) => { - stderrOutput += data.toString(); - }); - - proc.on('error', (err: Error) => { - clearTimeout(timeout); - this.process = null; - this.startedByUs = false; - reject(new Error(`${this.serverName} failed to start: ${err.message}`)); - }); - - proc.on('exit', (code, signal) => { - if (ready) return; - clearTimeout(timeout); - this.process = null; - this.startedByUs = false; - reject(new Error(`${this.serverName} exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); - }); - }); - } - - private async waitForProbeReady(startupDeadline: number, startupTimeout: number): Promise { - const probeInterval = getTimeout('healthProbeInterval'); - const timeoutError = () => new Error(`${this.serverName} did not become healthy within ${startupTimeout}ms`); - - while (true) { - const remainingMs = startupDeadline - Date.now(); - if (remainingMs <= 0) { - throw timeoutError(); - } - - if (await this.isProbeReadyWithin(Math.min(getTimeout('healthProbe'), remainingMs))) { - return; - } - - const retryDelay = Math.min(probeInterval, startupDeadline - Date.now()); - if (retryDelay <= 0) { - throw timeoutError(); - } - - await new Promise((resolve) => setTimeout(resolve, retryDelay)); - } - } - - private async isProbeReadyWithin(timeoutMs: number): Promise { - let timeout: ReturnType | undefined; - try { - const controller = new AbortController(); - timeout = setTimeout(() => controller.abort(), timeoutMs); - const response = await fetch(this.probeUrl, { ...this.probeRequestInit, signal: controller.signal }); - return await this.isProbeHealthy(response); - } catch { - return false; - } finally { - if (timeout) clearTimeout(timeout); - } - } - - private killProcess(proc: ChildProcess, signal: NodeJS.Signals): void { - if (this.useDetachedProcessGroup && proc.pid) { - try { - process.kill(-proc.pid, signal); - return; - } catch { - /* Fall back to killing the direct child below. */ - } - } - - proc.kill(signal); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts deleted file mode 100644 index ea701b158..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getHostnames, getMongoConnectionString } from './test-environment.ts'; - -const hostnames = getHostnames(); - -/** - * Spawns the api e2e dev server through the PR's portless/worktree path. - */ -export class TestApiServer extends PortlessServer { - protected get probeUrl() { - return this.getUrl(); - } - - protected override get probeRequestInit(): RequestInit { - return { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: '{ __typename }' }), - }; - } - - protected override async isProbeHealthy(response: Response): Promise { - if (!response.ok) { - return false; - } - - const payload = (await response.json().catch(() => null)) as { - data?: { __typename?: string }; - errors?: unknown[]; - } | null; - - return payload?.data?.__typename === 'Query' && !payload.errors?.length; - } - - protected get readyMarker() { - return 'Functions:'; - } - - protected get serverName() { - return 'TestApiServer'; - } - - protected override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.apiDir; - } - - protected override get extraEnv() { - return e2eEnv({ - COSMOSDB_CONNECTION_STRING: getMongoConnectionString(), - }); - } - - getUrl(): string { - return buildUrl(hostnames.api, '/api/graphql'); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts deleted file mode 100644 index 9f3c8f28b..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import { join } from 'node:path'; -import type { TestServer } from '@ocom-verification/verification-shared/servers'; -import { getTimeout } from '@ocom-verification/verification-shared/settings'; -import { appPaths } from './app-paths.ts'; -import { spawnEnv } from './child-process-env.ts'; -import { getAzuritePorts } from './worktree-ports.ts'; - -/** - * Starts Azurite via apps/api/start-azurite.mjs. - * If ports are already bound (EADDRINUSE), we treat that as an existing - * reusable instance for this worktree. - */ -export class TestAzuriteServer implements TestServer { - private process: ChildProcess | null = null; - private startedByUs = false; - private readonly useDetachedProcessGroup = process.platform !== 'win32'; - - private get blobPort(): number { - return getAzuritePorts().blob; - } - - async start(): Promise { - if (this.process || this.startedByUs) return; - - const binDir = join(appPaths.apiDir, 'node_modules', '.bin'); - const { PATH: pathValue = '' } = process.env; - - this.process = spawn('node', ['start-azurite.mjs'], { - cwd: appPaths.apiDir, - env: spawnEnv({ PATH: `${binDir}:${pathValue}` }), - detached: this.useDetachedProcessGroup, - stdio: ['ignore', 'pipe', 'pipe'], - }); - this.startedByUs = true; - - await this.waitForStartedMarker(); - } - - async stop(): Promise { - if (!this.process || !this.startedByUs) return; - - const proc = this.process; - this.process = null; - this.startedByUs = false; - - killProcess(proc, 'SIGTERM', this.useDetachedProcessGroup); - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - killProcess(proc, 'SIGKILL', this.useDetachedProcessGroup); - resolve(); - }, getTimeout('serverShutdown')); - - proc.on('exit', () => { - clearTimeout(timeout); - resolve(); - }); - }); - } - - isRunning(): boolean { - return this.process !== null; - } - - getUrl(): string { - return `http://127.0.0.1:${this.blobPort}`; - } - - private waitForStartedMarker(): Promise { - return new Promise((resolve, reject) => { - const proc = this.process; - if (!proc) { - reject(new Error('TestAzuriteServer process not started')); - return; - } - - const timeout = setTimeout(() => { - reject(new Error(`TestAzuriteServer did not emit start marker within ${getTimeout('serverStartup')}ms`)); - }, getTimeout('serverStartup')); - - let stderrOutput = ''; - - proc.stdout?.on('data', (data: Buffer) => { - if (data.toString().includes('[azurite] started')) { - clearTimeout(timeout); - resolve(); - } - }); - - proc.stderr?.on('data', (data: Buffer) => { - stderrOutput += data.toString(); - }); - - proc.on('error', (error: Error) => { - clearTimeout(timeout); - reject(new Error(`TestAzuriteServer failed to start: ${error.message}`)); - }); - - proc.on('exit', (code, signal) => { - clearTimeout(timeout); - if (stderrOutput.includes('EADDRINUSE')) { - this.process = null; - this.startedByUs = false; - resolve(); - return; - } - reject(new Error(`TestAzuriteServer exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); - }); - }); - } -} - -function killProcess(proc: ChildProcess, signal: NodeJS.Signals, useGroup: boolean): void { - if (useGroup && proc.pid) { - try { - process.kill(-proc.pid, signal); - return; - } catch { - /* Fall back to killing the direct child. */ - } - } - proc.kill(signal); -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts deleted file mode 100644 index fb567e0aa..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getHostnames } from './test-environment.ts'; - -const hostnames = getHostnames(); - -/** - * Starts the community portal Vite dev server via portless. - */ -export class TestCommunityViteServer extends PortlessServer { - protected get probeUrl() { - return this.getUrl(); - } - - protected get readyMarker() { - return 'ready in'; - } - - protected get serverName() { - return 'TestCommunityViteServer'; - } - - protected override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.uiCommunityDir; - } - - protected override get extraEnv() { - return e2eEnv({ - BROWSER: 'none', - NODE_ENV: 'development', - }); - } - - getUrl(): string { - return buildUrl(hostnames.uiCommunity); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts deleted file mode 100644 index f93859c16..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { buildPortlessUrl, getHostnames } from '@ocom-verification/verification-shared/settings'; -import { getPortlessPath } from './resolve-portless.ts'; - -let proxyInitialized = false; -let mongoConnectionString: string | undefined; - -loadE2EEnvDefaults(); - -const hostnames = getHostnames(); - -export const mockOidcAudience = 'mock-client'; -export const mockOidcIssuer = buildPortlessUrl(hostnames.mockAuth, '/community'); -export const mockOidcEndpoint = `${mockOidcIssuer}/.well-known/jwks.json`; -export const mockStaffOidcIssuer = buildPortlessUrl(hostnames.mockAuth, '/staff'); - -/** - * Ensure the portless proxy is running for the PR's worktree-scoped hostnames. - */ -export function initTestEnvironment() { - if (proxyInitialized) return; - - execFileSync(getPortlessPath(), ['prune'], { - timeout: 10_000, - stdio: 'pipe', - }); - execFileSync(getPortlessPath(), ['proxy', 'start', '--https', '-p', '1355'], { - timeout: 15_000, - stdio: 'pipe', - }); - - proxyInitialized = true; -} - -export { buildPortlessUrl as buildUrl, getHostnames }; - -export function setMongoConnectionString(connStr: string): void { - mongoConnectionString = connStr; -} - -export function getMongoConnectionString(): string { - if (!mongoConnectionString) { - throw new Error('MongoDB connection string not set - call setMongoConnectionString() first'); - } - return mongoConnectionString; -} - -export function cleanupTestEnvironment(): void { - proxyInitialized = false; - mongoConnectionString = undefined; -} - -function loadE2EEnvDefaults(): void { - const currentDir = dirname(fileURLToPath(import.meta.url)); - const workspaceRoot = resolve(currentDir, '../../../../../../..'); - for (const filePath of [resolve(workspaceRoot, 'apps/ui-community/.env.e2e'), resolve(workspaceRoot, 'apps/ui-staff/.env.e2e')]) { - if (!existsSync(filePath)) continue; - for (const line of readFileSync(filePath, 'utf-8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const idx = trimmed.indexOf('='); - if (idx === -1) continue; - const key = trimmed.slice(0, idx); - process.env[key] ??= trimmed.slice(idx + 1); - } - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts deleted file mode 100644 index 424d028e3..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { mockOidcEndpoint, mockOidcIssuer } from './test-environment.ts'; - -/** - * Starts the mock OAuth2/OIDC server via portless. - */ -export class TestOAuth2Server extends PortlessServer { - protected get probeUrl() { - return mockOidcEndpoint; - } - - protected get readyMarker() { - return 'Registered OIDC config'; - } - - protected get serverName() { - return 'TestOAuth2Server'; - } - - protected override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.oauth2MockDir; - } - - getUrl(): string { - return mockOidcIssuer; - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts deleted file mode 100644 index 8eab2f068..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getHostnames } from './test-environment.ts'; - -const hostnames = getHostnames(); - -/** - * Starts the staff portal Vite dev server via portless. - */ -export class TestStaffViteServer extends PortlessServer { - protected get probeUrl() { - return this.getUrl(); - } - - protected get readyMarker() { - return 'ready in'; - } - - protected get serverName() { - return 'TestStaffViteServer'; - } - - protected override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.uiStaffDir; - } - - protected override get extraEnv() { - return e2eEnv({ - BROWSER: 'none', - NODE_ENV: 'development', - }); - } - - getUrl(): string { - return buildUrl(hostnames.uiStaff); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts deleted file mode 100644 index 022abad6b..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts +++ /dev/null @@ -1,26 +0,0 @@ -interface AzuritePorts { - blob: number; - queue: number; - table: number; -} - -export function getWorktreePortOffset(): number { - const name = process.env['WORKTREE_NAME']; - if (!name) return 0; - let hash = 0; - for (const c of name) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0; - return ((Math.abs(hash) % 49) + 1) * 100; -} - -export function getAzuritePorts(): AzuritePorts { - const offset = getWorktreePortOffset(); - return { - blob: 10000 + offset, - queue: 10001 + offset, - table: 10002 + offset, - }; -} - -export function getMongoPort(): number { - return 50000 + getWorktreePortOffset(); -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts deleted file mode 100644 index 472301f78..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ /dev/null @@ -1,190 +0,0 @@ -import playwright, { type Browser, type BrowserContext } from 'playwright'; -import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; -import { performOAuth2Login } from './oauth2-login.ts'; -import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestAzuriteServer, TestCommunityViteServer, TestOAuth2Server, TestStaffViteServer } from './servers/index.ts'; -import { getMongoPort } from './servers/worktree-ports.ts'; - -const apiDbName = 'owner-community'; - -let mongoDBServer: MongoDBTestServer | undefined; -let azuriteServer: TestAzuriteServer | undefined; -let oauth2Server: TestOAuth2Server | undefined; -let apiServer: TestApiServer | undefined; -let communityViteServer: TestCommunityViteServer | undefined; -let staffViteServer: TestStaffViteServer | undefined; -let apiUrl: string | undefined; -let browser: Browser | undefined; -let browserBaseUrl: string | undefined; -let authenticatedBrowserContext: BrowserContext | undefined; -let browseTheWeb: BrowseTheWeb | undefined; -let shutdownHandlersRegistered = false; - -export interface InfrastructureState { - apiUrl: string | undefined; - browseTheWeb: BrowseTheWeb | undefined; - staffBaseUrl: string | undefined; - communityBaseUrl: string | undefined; - browser: Browser | undefined; -} - -export function getState(): InfrastructureState { - return { apiUrl, browseTheWeb, staffBaseUrl: staffViteServer?.getUrl(), communityBaseUrl: browserBaseUrl, browser }; -} - -/** - * Resets mutable state between scenarios without restarting servers. - */ -export async function resetScenarioState(): Promise { - if (mongoDBServer?.isRunning()) { - await mongoDBServer.resetForScenario(); - } -} - -export async function stopAll(): Promise { - if (browseTheWeb) { - await browseTheWeb.close().catch(() => undefined); - browseTheWeb = undefined; - } else if (authenticatedBrowserContext) { - await authenticatedBrowserContext.close().catch(() => undefined); - } - authenticatedBrowserContext = undefined; - - if (browser) { - await browser.close().catch(() => undefined); - browser = undefined; - } - if (communityViteServer) { - await communityViteServer.stop().catch(() => undefined); - communityViteServer = undefined; - } - if (staffViteServer) { - await staffViteServer.stop().catch(() => undefined); - staffViteServer = undefined; - } - if (apiServer) { - await apiServer.stop().catch(() => undefined); - apiServer = undefined; - } - if (oauth2Server) { - await oauth2Server.stop().catch(() => undefined); - oauth2Server = undefined; - } - if (mongoDBServer) { - await mongoDBServer.stop().catch(() => undefined); - mongoDBServer = undefined; - } - if (azuriteServer) { - await azuriteServer.stop().catch(() => undefined); - azuriteServer = undefined; - } - - apiUrl = undefined; - browserBaseUrl = undefined; - cleanupTestEnvironment(); -} - -export async function ensureE2EServers(): Promise { - initTestEnvironment(); - registerShutdownHandlers(); - - mongoDBServer ??= new MongoDBTestServer(); - azuriteServer ??= new TestAzuriteServer(); - oauth2Server ??= new TestOAuth2Server(); - - const mongo = mongoDBServer; - const azurite = azuriteServer; - const oauth2 = oauth2Server; - const phase1: Promise[] = []; - - if (!mongo.isRunning()) { - phase1.push( - mongo.start({ dbName: apiDbName, port: getMongoPort() }).then(() => { - setMongoConnectionString(mongo.getConnectionString()); - }), - ); - } - if (!azurite.isRunning()) { - phase1.push(azurite.start()); - } - if (!oauth2.isRunning()) { - phase1.push(oauth2.start()); - } - if (phase1.length > 0) await Promise.all(phase1); - - apiServer ??= new TestApiServer(); - communityViteServer ??= new TestCommunityViteServer(); - staffViteServer ??= new TestStaffViteServer(); - - const api = apiServer; - const communityVite = communityViteServer; - const staffVite = staffViteServer; - const phase2: Promise[] = []; - - if (!api.isRunning()) { - phase2.push( - api.start().then(() => { - apiUrl = api.getUrl(); - }), - ); - } - if (!communityVite.isRunning()) { - phase2.push(communityVite.start()); - } - if (!staffVite.isRunning()) { - phase2.push(staffVite.start()); - } - if (phase2.length > 0) await Promise.all(phase2); - - browserBaseUrl = communityVite.getUrl(); - apiUrl ??= api.getUrl(); - - if (!browser) { - browser = await playwright.chromium.launch({ headless: true }); - } - - await ensureAuthenticatedBrowserContext({ - baseURL: browserBaseUrl, - ignoreHTTPSErrors: true, - performLogin: true, - }); -} - -async function ensureAuthenticatedBrowserContext(options: { baseURL?: string; ignoreHTTPSErrors: boolean; performLogin: boolean }): Promise { - if (browseTheWeb || !browser || !options.baseURL) { - return; - } - - if (!authenticatedBrowserContext) { - authenticatedBrowserContext = await browser.newContext({ - baseURL: options.baseURL, - ignoreHTTPSErrors: options.ignoreHTTPSErrors, - }); - } - - const seedPage = await authenticatedBrowserContext.newPage(); - - try { - if (options.performLogin) { - await performOAuth2Login(seedPage); - } - browseTheWeb = BrowseTheWeb.using(seedPage, authenticatedBrowserContext); - } catch (error) { - await authenticatedBrowserContext.close().catch(() => undefined); - authenticatedBrowserContext = undefined; - throw error; - } -} - -function registerShutdownHandlers(): void { - if (shutdownHandlersRegistered) return; - shutdownHandlersRegistered = true; - - const shutdown = (signal: string) => { - void stopAll().finally(() => { - process.exit(signal === 'SIGINT' ? 130 : 143); - }); - }; - - process.once('SIGINT', () => shutdown('SIGINT')); - process.once('SIGTERM', () => shutdown('SIGTERM')); -} diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts index fb2ff8a4f..585f6f459 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -3,6 +3,6 @@ * Cucumber imports this file, which then loads all context-specific step definitions. */ -import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/test-server-factories.ts b/packages/ocom-verification/e2e-tests/src/test-server-factories.ts new file mode 100644 index 000000000..83d72cb67 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/test-server-factories.ts @@ -0,0 +1,108 @@ +import { join } from 'node:path'; +import { ProcessTestServer } from '@cellix/serenity-framework/servers'; +import { getAzuritePorts } from '@ocom-verification/verification-shared/environment'; +import { appPaths } from './shared/environment/app-paths.ts'; +import { e2eEnv, getPortlessDevScript } from './shared/environment/dev-script.ts'; +import { buildUrl, getHostnames, mockOidcEndpoint, mockOidcIssuer } from './shared/environment/test-environment.ts'; + +export { cleanupTestEnvironment, initTestEnvironment } from './shared/environment/test-environment.ts'; + +const hostnames = getHostnames(); + +export function createTestApiServer(getMongoConnectionString: () => string): ProcessTestServer { + return new ProcessTestServer({ + cwd: appPaths.apiDir, + executable: 'pnpm', + extraEnv: () => + e2eEnv({ + COSMOSDB_CONNECTION_STRING: getMongoConnectionString(), + }), + getUrl: () => buildUrl(hostnames.api, '/api/graphql'), + probe: { + url: () => buildUrl(hostnames.api, '/api/graphql'), + requestInit: () => ({ + body: JSON.stringify({ query: '{ __typename }' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + isHealthy: async (response) => { + if (!response.ok) { + return false; + } + + const payload = (await response.json().catch(() => null)) as { + data?: { __typename?: string }; + errors?: unknown[]; + } | null; + + return payload?.data?.__typename === 'Query' && !payload.errors?.length; + }, + }, + readyMarker: 'Functions:', + serverName: 'TestApiServer', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} + +export function createTestAzuriteServer(): ProcessTestServer { + return new ProcessTestServer({ + cwd: appPaths.apiDir, + executable: 'node', + extraEnv: () => { + const binDir = join(appPaths.apiDir, 'node_modules', '.bin'); + const { PATH: pathValue = '' } = process.env; + return { PATH: `${binDir}:${pathValue}` }; + }, + getUrl: () => `http://127.0.0.1:${getAzuritePorts().blob}`, + isAlreadyRunning: async () => false, + isReusableExit: (stderrOutput) => stderrOutput.includes('EADDRINUSE'), + probe: false, + readyMarker: '[azurite] started', + serverName: 'TestAzuriteServer', + spawnArgs: ['start-azurite.mjs'], + }); +} + +export function createTestOAuth2Server(): ProcessTestServer { + return new ProcessTestServer({ + cwd: appPaths.oauth2MockDir, + executable: 'pnpm', + getUrl: () => mockOidcIssuer, + probe: { + url: mockOidcEndpoint, + }, + readyMarker: 'Registered OIDC config', + serverName: 'TestOAuth2Server', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} + +export function createCommunityUiPortalServer(): ProcessTestServer { + return new ProcessTestServer({ + cwd: appPaths.uiCommunityDir, + executable: 'pnpm', + extraEnv: () => ({ + BROWSER: 'none', + NODE_ENV: 'development', + }), + getUrl: () => buildUrl(hostnames.uiCommunity), + readyMarker: 'ready in', + serverName: 'TestCommunityViteServer', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} + +export function createStaffUiPortalServer(): ProcessTestServer { + return new ProcessTestServer({ + cwd: appPaths.uiStaffDir, + executable: 'pnpm', + extraEnv: () => ({ + BROWSER: 'none', + NODE_ENV: 'development', + }), + getUrl: () => buildUrl(hostnames.uiStaff), + readyMarker: 'ready in', + serverName: 'TestStaffViteServer', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} diff --git a/packages/ocom-verification/e2e-tests/src/world.ts b/packages/ocom-verification/e2e-tests/src/world.ts index e40e2eb21..2e5d6a44d 100644 --- a/packages/ocom-verification/e2e-tests/src/world.ts +++ b/packages/ocom-verification/e2e-tests/src/world.ts @@ -1,30 +1,36 @@ -import { setWorldConstructor, World } from '@cucumber/cucumber'; -import { engage } from '@serenity-js/core'; -import './shared/support/hooks.ts'; -import { CellixE2ECast } from './shared/support/cast.ts'; -import * as infra from './shared/support/shared-infrastructure.ts'; +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; +import { registerLifecycleHooks } from './cucumber-lifecycle-hooks.ts'; +import * as infra from './infrastructure.ts'; +import { OAuth2Login } from './shared/abilities/oauth2-login.ts'; -export async function stopSharedServers(): Promise { - await infra.stopAll(); -} - -export class CellixE2EWorld extends World { - async init(): Promise { - await infra.ensureE2EServers(); - - const { browseTheWeb } = infra.getState(); - if (!browseTheWeb) { +export const CellixE2EWorld = registerManagedSerenityWorld({ + infrastructure: { + ensureStarted: infra.ensureE2EServers, + getState: infra.getState, + resetScenarioState: infra.resetScenarioState, + stopAll: infra.stopAll, + }, + validateState: (state) => { + if (!state.browseTheWeb) { throw new Error('BrowseTheWeb ability not initialized'); } + }, + createCast: (state) => + new SerenityCast({ + useNotepad: true, + abilities: [ + () => { + if (!state.browseTheWeb) { + throw new Error('BrowseTheWeb ability not initialized'); + } + return state.browseTheWeb; + }, + () => OAuth2Login.throughProtectedRoute('/community/accounts'), + ], + }), +}); - engage(new CellixE2ECast(browseTheWeb)); - } - - async cleanup(): Promise { - // Reset DB state between scenarios so each starts from a clean baseline. - // Servers stay running — only mutable data is cleared and re-seeded. - await infra.resetScenarioState(); - } -} +export type CellixE2EWorld = InstanceType; -setWorldConstructor(CellixE2EWorld); +registerLifecycleHooks(); diff --git a/packages/ocom-verification/verification-shared/package.json b/packages/ocom-verification/verification-shared/package.json index 9d4803c01..d2873cc32 100644 --- a/packages/ocom-verification/verification-shared/package.json +++ b/packages/ocom-verification/verification-shared/package.json @@ -1,41 +1,23 @@ { "name": "@ocom-verification/verification-shared", "version": "1.0.0", - "description": "Shared Serenity verification utilities, servers, scenarios, and helpers", + "description": "OCOM verification test data and shared Cucumber scenarios", "private": true, "type": "module", "exports": { - "./test-data": "./src/test-data/index.ts", - "./helpers": "./src/helpers/index.ts", - "./formatters": "./src/formatters/index.ts", - "./servers": "./src/servers/index.ts", - "./settings": "./src/settings/index.ts", - "./serenity": "./src/serenity/index.ts", + "./abilities": "./src/abilities/index.ts", + "./environment": "./src/environment/index.ts", "./pages": "./src/pages/index.ts", - "./pages/jsdom": "./src/pages/adapters/jsdom-adapter.ts", - "./pages/playwright": "./src/pages/adapters/playwright-adapter.ts" + "./test-data": "./src/test-data/index.ts" }, "dependencies": { - "@apollo/server": "catalog:", - "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", - "@ocom/service-mongoose": "workspace:*", - "@cucumber/cucumber": "catalog:", - "@cucumber/messages": "catalog:", + "@cellix/serenity-framework": "workspace:*", "@serenity-js/core": "catalog:", - "@ocom/graphql": "workspace:*", - "@ocom/application-services": "workspace:*", - "@testing-library/react": "^16.3.0", - "graphql": "catalog:", - "graphql-depth-limit": "^1.1.0", - "graphql-middleware": "^6.1.35", - "mongodb": "catalog:", - "mongoose": "catalog:" + "mongodb": "catalog:" }, "devDependencies": { "@cellix/config-typescript": "workspace:*", - "@types/graphql-depth-limit": "^1.1.6", "@types/node": "catalog:", - "playwright": "catalog:", "typescript": "catalog:" } } diff --git a/packages/ocom-verification/verification-shared/src/abilities/create-community.ts b/packages/ocom-verification/verification-shared/src/abilities/create-community.ts new file mode 100644 index 000000000..a53c51cb7 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/abilities/create-community.ts @@ -0,0 +1,53 @@ +import { Ability, type Actor } from '@serenity-js/core'; + +/** Community details accepted by OCOM verification flows. */ +export interface CreateCommunityDetails { + /** Community display name. */ + name: string; +} + +/** Result returned by a concrete community creation flow. */ +export interface CreateCommunityResult { + /** Created community id, when the flow exposes one. */ + id?: string; + + /** Created community name. */ + name: string; +} + +/** Handler that performs community creation for an actor. */ +export type CreateCommunityHandler = (actor: Actor, details: CreateCommunityDetails) => Promise; + +/** + * Serenity ability that lets an actor create OCOM communities. + * + * The ability centralizes the domain capability while allowing each verification + * package to provide the environment-specific implementation. + */ +export class CreateCommunity extends Ability { + /** + * @param handler Function that performs community creation. + */ + constructor(private readonly handler: CreateCommunityHandler) { + super(); + } + + /** + * Create the ability from an environment-specific community creation handler. + * + * @param handler Function that performs community creation. + */ + static using(handler: CreateCommunityHandler): CreateCommunity { + return new CreateCommunity(handler); + } + + /** + * Create a community through the configured verification environment. + * + * @param actor Actor creating the community. + * @param details Community details. + */ + async performAs(actor: Actor, details: CreateCommunityDetails): Promise { + return await this.handler(actor, details); + } +} diff --git a/packages/ocom-verification/verification-shared/src/abilities/index.ts b/packages/ocom-verification/verification-shared/src/abilities/index.ts new file mode 100644 index 000000000..65388a4ab --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/abilities/index.ts @@ -0,0 +1,2 @@ +export type { CreateCommunityDetails, CreateCommunityHandler, CreateCommunityResult } from './create-community.ts'; +export { CreateCommunity } from './create-community.ts'; diff --git a/packages/ocom-verification/verification-shared/src/environment/index.ts b/packages/ocom-verification/verification-shared/src/environment/index.ts new file mode 100644 index 000000000..c2ddae3ce --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/environment/index.ts @@ -0,0 +1 @@ +export { type AzuritePorts, getAzuritePorts, getMongoPort, getWorktreePortOffset } from './worktree-ports.ts'; diff --git a/packages/ocom-verification/verification-shared/src/environment/worktree-ports.ts b/packages/ocom-verification/verification-shared/src/environment/worktree-ports.ts new file mode 100644 index 000000000..b44b57e35 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/environment/worktree-ports.ts @@ -0,0 +1,39 @@ +/** Internal test-service ports used by a single verification worktree. */ +export interface AzuritePorts { + /** Azurite blob service port. */ + blob: number; + /** Azurite queue service port. */ + queue: number; + /** Azurite table service port. */ + table: number; +} + +/** + * Resolve the deterministic local-service port offset for the current worktree. + * + * When `WORKTREE_NAME` is absent, verification suites use the default local + * ports. When it is present, each worktree name maps to a stable offset so + * MongoDB and Azurite test servers can run beside another worktree. + */ +export function getWorktreePortOffset(): number { + const { WORKTREE_NAME: name } = process.env; + if (!name) return 0; + let hash = 0; + for (const c of name) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0; + return ((Math.abs(hash) % 49) + 1) * 100; +} + +/** MongoDB memory-server port for the current verification worktree. */ +export function getMongoPort(): number { + return 50_000 + getWorktreePortOffset(); +} + +/** Azurite service ports for the current verification worktree. */ +export function getAzuritePorts(): AzuritePorts { + const offset = getWorktreePortOffset(); + return { + blob: 10_000 + offset, + queue: 10_001 + offset, + table: 10_002 + offset, + }; +} diff --git a/packages/ocom-verification/verification-shared/src/formatters/index.ts b/packages/ocom-verification/verification-shared/src/formatters/index.ts deleted file mode 100644 index a75c8ca6b..000000000 --- a/packages/ocom-verification/verification-shared/src/formatters/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, default as AgentFormatter } from './agent-formatter.ts'; diff --git a/packages/ocom-verification/verification-shared/src/helpers/actor-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/actor-helpers.ts deleted file mode 100644 index 88c35b0dc..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/actor-helpers.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface ActorDetails { - name: string; - externalId: string; - email: string; - givenName: string; - familyName: string; -} - -// Resolve Gherkin pronoun references to actor names -export function resolveActorName(actorName: string, defaultName = 'Alice'): string { - return /^(she|he|they)$/i.test(actorName) ? defaultName : actorName; -} diff --git a/packages/ocom-verification/verification-shared/src/helpers/date-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/date-helpers.ts deleted file mode 100644 index da0a6b8f6..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/date-helpers.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const ONE_DAY_MS = 86_400_000; - -export function parseDateInput(input: string): Date { - if (input.startsWith('+')) { - const days = Number.parseInt(input.substring(1), 10); - const date = new Date(); - date.setDate(date.getDate() + days); - date.setHours(0, 0, 0, 0); - return date; - } - - const date = new Date(input); - if (Number.isNaN(date.getTime())) { - throw new TypeError(`Invalid date input: "${input}"`); - } - date.setHours(0, 0, 0, 0); - return date; -} - -export function formatDateForComparison(date: Date): string { - return date.toISOString().split('T')[0] ?? ''; -} diff --git a/packages/ocom-verification/verification-shared/src/helpers/gherkin-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/gherkin-helpers.ts deleted file mode 100644 index db0b9c910..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/gherkin-helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { DataTable } from '@cucumber/cucumber'; - -/** - * Returns a DataTable's rowsHash typed as the caller-provided shape so consumers - * can access keys directly (e.g. `row.name`) without tripping - * TypeScript's `noPropertyAccessFromIndexSignature` rule. - */ -export function typedRowsHash(dataTable: DataTable): T { - return dataTable.rowsHash() as T; -} diff --git a/packages/ocom-verification/verification-shared/src/helpers/index.ts b/packages/ocom-verification/verification-shared/src/helpers/index.ts deleted file mode 100644 index a9b4a6381..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { ActorDetails } from './actor-helpers.ts'; -export { resolveActorName } from './actor-helpers.ts'; -export { - formatDateForComparison, - ONE_DAY_MS, - parseDateInput, -} from './date-helpers.ts'; -export { typedRowsHash } from './gherkin-helpers.ts'; -export type { TestUserData } from './user-helpers.ts'; -export { makeTestUserData } from './user-helpers.ts'; diff --git a/packages/ocom-verification/verification-shared/src/helpers/user-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/user-helpers.ts deleted file mode 100644 index f8a92a399..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/user-helpers.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface TestUserData { - id: string; - email: string; - firstName: string; - lastName: string; -} - -export function makeTestUserData(actorName: string, overrides?: Partial): TestUserData { - const defaultId = `test-user-${actorName.toLowerCase()}`; - const defaultEmail = `${actorName.toLowerCase()}@test.com`; - const defaultFirstName = actorName; - const defaultLastName = 'Tester'; - - return { - id: overrides?.id ?? defaultId, - email: overrides?.email ?? defaultEmail, - firstName: overrides?.firstName ?? defaultFirstName, - lastName: overrides?.lastName ?? defaultLastName, - }; -} diff --git a/packages/ocom-verification/verification-shared/src/pages/community.page.ts b/packages/ocom-verification/verification-shared/src/pages/community.page.ts index 91317f1aa..a9b333811 100644 --- a/packages/ocom-verification/verification-shared/src/pages/community.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/community.page.ts @@ -1,29 +1,14 @@ -import type { ElementHandle, PageAdapter } from './page-adapter.ts'; +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; -/** - * Community page object — works with both jsdom (acceptance UI tests) - * and Playwright (e2e tests) via the PageAdapter abstraction. - */ -export class CommunityPage { - constructor(private readonly adapter: PageAdapter) {} - - // --- Create Community form --- +export class CommunityPage extends AdapterBackedPageObject { get nameInput(): ElementHandle { return this.adapter.getByPlaceholder('Name'); } - get createCommunityButton(): ElementHandle { - return this.adapter.getByRole('button', { name: /Create.*Community/i }); - } - get submitButton(): ElementHandle { return this.adapter.getByRole('button', { name: /Create/i }); } - get cancelButton(): ElementHandle { - return this.adapter.getByRole('button', { name: /Cancel/i }); - } - get firstValidationError(): ElementHandle { return this.adapter.locator('.ant-form-item-explain-error'); } @@ -32,54 +17,11 @@ export class CommunityPage { return this.adapter.locator('.ant-message-error, [role="alert"]'); } - // --- Loading indicator --- - get loadingButton(): ElementHandle { - return this.adapter.locator('.ant-btn-loading'); - } - - // --- Success modal --- - get modal(): ElementHandle { - return this.adapter.locator('.ant-modal'); - } - - get viewCommunityButton(): ElementHandle { - return this.adapter.getByRole('button', { name: /View Community/i }); - } - - // --- Community list table --- - communityNameCell(name: string): ElementHandle { - return this.adapter.getByText(name, { selector: 'table' }); - } - - async statusTagInRow(name: string): Promise { - const row = await this.communityRowByName(name); - return row ? row.querySelector('.ant-tag') : null; - } - - // --- Helper methods --- async fillName(value: string): Promise { await this.nameInput.fill(value); } - async fillForm(data: { name?: string }): Promise { - if (data.name) await this.fillName(data.name); - } - async clickCreate(): Promise { await this.submitButton.click(); } - - private async communityRowByName(name: string): Promise { - const table = this.adapter.getByRole('table'); - const rows = await table.querySelectorAll('tr'); - - for (const row of rows) { - const text = await row.textContent(); - if (text?.includes(name)) { - return row; - } - } - - return null; - } } diff --git a/packages/ocom-verification/verification-shared/src/pages/home.page.ts b/packages/ocom-verification/verification-shared/src/pages/home.page.ts index 435596ca3..1642abb1f 100644 --- a/packages/ocom-verification/verification-shared/src/pages/home.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/home.page.ts @@ -1,13 +1,6 @@ -import type { ElementHandle, PageAdapter } from './page-adapter.ts'; - -/** - * Home page object — represents the landing screen that contains the - * site header with sign-in controls. Works with both jsdom (acceptance - * UI tests) and Playwright (e2e tests) via the PageAdapter abstraction. - */ -export class HomePage { - constructor(private readonly adapter: PageAdapter) {} +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; +export class HomePage extends AdapterBackedPageObject { get signInButton(): ElementHandle { return this.adapter.getByRole('button', { name: /Log In|Sign In/i }); } diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 8c8ff6683..63aa501c8 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,18 +1,2 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; -export { LoginPage } from './login.page.ts'; -export type { - ElementHandle, - PageAdapter, - PageAdapterMode, - PageNavigationWaitUntil, - PageUrlMatcher, -} from './page-adapter.ts'; -export type { - E2ECommunityPage, - E2EHomePage, - E2ELoginPage, - UiCommunityPage, - UiHomePage, - UiLoginPage, -} from './page-interfaces/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/login.page.ts b/packages/ocom-verification/verification-shared/src/pages/login.page.ts deleted file mode 100644 index 9e8458800..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/login.page.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { PageAdapter } from './page-adapter.ts'; - -/** - * Shared login page object backed by the universal page adapter. - */ -export class LoginPage { - constructor(private readonly page: PageAdapter) {} - - get emailInput() { - return this.page.getByLabel('Email'); - } - - get passwordInput() { - return this.page.getByLabel('Password'); - } - - get loginButton() { - return this.page.locator('form button[type="submit"]'); - } - - async goto(): Promise { - await this.page.goto('/login', { waitUntil: 'networkidle' }); - } - - async login(email: string, password: string): Promise { - await this.emailInput.fill(email); - await this.passwordInput.fill(password); - await this.loginButton.click(); - } - - async waitForRedirectComplete(): Promise { - await this.page.waitForURL((url) => !url.pathname.includes('auth-redirect') && !url.pathname.includes('/login') && !url.hostname.includes('mock-auth'), { timeout: 30_000 }); - } -} diff --git a/packages/ocom-verification/verification-shared/src/pages/page-adapter.ts b/packages/ocom-verification/verification-shared/src/pages/page-adapter.ts deleted file mode 100644 index 71b2cc1ef..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-adapter.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Universal element handle — wraps a single DOM element or Playwright locator. - * Provides a common interface for both jsdom (acceptance-test UI) and Playwright (e2e) contexts. - */ -export interface ElementHandle { - fill(value: string): Promise; - click(): Promise; - check(): Promise; - textContent(): Promise; - getAttribute(name: string): Promise; - isVisible(): Promise; - waitFor(options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise; - querySelector(selector: string): Promise; - querySelectorAll(selector: string): Promise; -} - -export type PageNavigationWaitUntil = 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; - -export type PageUrlMatcher = string | RegExp | ((url: URL) => boolean); - -/** - * Universal page adapter — abstracts element lookup across jsdom and Playwright. - * Page objects depend on this interface rather than a specific test runner. - */ -export interface PageAdapter { - getByPlaceholder(text: string): ElementHandle; - getByLabel(text: string): ElementHandle; - getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle; - locator(selector: string): ElementHandle; - locatorAll(selector: string): Promise; - getByText(text: string | RegExp, options?: { selector?: string }): ElementHandle; - goto(url: string, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise; - waitForURL(url: PageUrlMatcher, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise; - url(): string; - waitForTimeout(timeout: number): Promise; -} - -export type PageAdapterMode = 'jsdom' | 'playwright'; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts deleted file mode 100644 index 584459540..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CommunityPage } from '../community.page.ts'; - -export type UiCommunityPage = Pick; - -export type E2ECommunityPage = Pick; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts deleted file mode 100644 index e279f8c47..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { HomePage } from '../home.page.ts'; - -export type UiHomePage = Pick; - -export type E2EHomePage = Pick; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts deleted file mode 100644 index 9a095a538..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type { - E2ECommunityPage, - UiCommunityPage, -} from './community.page-interface.ts'; -export type { - E2EHomePage, - UiHomePage, -} from './home.page-interface.ts'; -export type { - E2ELoginPage, - UiLoginPage, -} from './login.page-interface.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/login.page-interface.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/login.page-interface.ts deleted file mode 100644 index 9792668a5..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/login.page-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { LoginPage } from '../login.page.ts'; - -export type UiLoginPage = Pick; - -export type E2ELoginPage = Pick; diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature new file mode 100644 index 000000000..b38bf03fc --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature @@ -0,0 +1,45 @@ +Feature: Staff workspace access + + As a staff business user + I want each workspace to follow role-based access rules + So that sensitive operations are only available to authorized roles + + Scenario: Finance staff user is directed to the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user is directed to the tech admin workspace + Given Alice is an authenticated "tech admin" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/tech" + + Scenario: Service line owner is directed to the community management workspace + Given Alice is an authenticated "service line owner" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Case manager is directed to the community management workspace + Given Alice is an authenticated "case manager" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Finance staff user can work in the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user can work in the finance workspace + Given Alice is an authenticated "tech admin" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Service line owner cannot work in the finance workspace + Given Alice is an authenticated "service line owner" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" + + Scenario: Case manager cannot work in the finance workspace + Given Alice is an authenticated "case manager" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" diff --git a/packages/ocom-verification/verification-shared/src/serenity/index.ts b/packages/ocom-verification/verification-shared/src/serenity/index.ts deleted file mode 100644 index 54ba8c2e5..000000000 --- a/packages/ocom-verification/verification-shared/src/serenity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './task-step.ts'; diff --git a/packages/ocom-verification/verification-shared/src/serenity/task-step.ts b/packages/ocom-verification/verification-shared/src/serenity/task-step.ts deleted file mode 100644 index b11ff0a82..000000000 --- a/packages/ocom-verification/verification-shared/src/serenity/task-step.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Task } from '@serenity-js/core'; - -export class TaskStep extends Task { - constructor( - description: string, - private readonly action: (actor: unknown) => Promise, - ) { - super(description); - } - - performAs(actor: unknown): Promise { - return this.action(actor); - } -} diff --git a/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts b/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts deleted file mode 100644 index acae9ca37..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ApolloServer } from '@apollo/server'; -import { startStandaloneServer } from '@apollo/server/standalone'; -import type { ApplicationServices, ApplicationServicesFactory } from '@ocom/application-services'; -import { combinedSchema } from '@ocom/graphql'; -import depthLimit from 'graphql-depth-limit'; -import { applyMiddleware } from 'graphql-middleware'; -import { getTimeout } from '../settings/index.ts'; -import type { TestServer } from './test-server.interface.ts'; - -interface GraphContext { - applicationServices: ApplicationServices; -} - -const MAX_QUERY_DEPTH = 10; - -/** - * In-process Apollo Server for API acceptance and integration tests. - * - * This server runs the GraphQL schema directly in the test process, - * providing fast feedback with mocked application services. - * - * Use this for: - * - API acceptance tests - * - Unit-like integration tests - * - Fast feedback loops - * - * For full system tests, use PortlessServer-based implementations instead. - */ -export class GraphQLTestServer implements TestServer { - private server: ApolloServer | null = null; - private url: string | null = null; - - constructor(private readonly applicationServicesFactory?: ApplicationServicesFactory) {} - - /** - * Start the GraphQL server on the specified port (or random port if 0). - * Uses centralized timeout configuration. - */ - async start(port = 0): Promise { - if (this.server) { - throw new Error('Test server already started'); - } - - const securedSchema = applyMiddleware(combinedSchema); - - this.server = new ApolloServer({ - schema: securedSchema, - allowBatchedHttpRequests: true, - validationRules: [depthLimit(MAX_QUERY_DEPTH)], - introspection: false, - }); - - const timeoutMs = getTimeout('serverStartup'); - const startTime = Date.now(); - - const { url } = await startStandaloneServer(this.server, { - listen: { port }, - context: async ({ req }) => { - const authHeader = req.headers.authorization ?? undefined; - - const applicationServices = this.applicationServicesFactory ? await this.applicationServicesFactory.forRequest(authHeader) : undefined; - - if (!applicationServices) { - throw new Error('ApplicationServicesFactory required for test server'); - } - - return { - applicationServices, - }; - }, - }); - - const elapsed = Date.now() - startTime; - if (elapsed > timeoutMs * 0.8) { - console.warn(`GraphQLTestServer startup took ${elapsed}ms (timeout: ${timeoutMs}ms)`); - } - - this.url = url; - } - - /** - * Stop the server gracefully. - */ - async stop(): Promise { - if (!this.server) { - return; - } - - await this.server.stop(); - this.server = null; - this.url = null; - } - - /** - * Get the server URL. - * @throws Error if server is not running - */ - getUrl(): string { - if (!this.url) { - throw new Error('Test server not started'); - } - return this.url; - } - - /** - * Check if server is currently running. - */ - isRunning(): boolean { - return this.server !== null; - } -} diff --git a/packages/ocom-verification/verification-shared/src/servers/index.ts b/packages/ocom-verification/verification-shared/src/servers/index.ts deleted file mode 100644 index 4f3a5ec63..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { seedDatabase } from '../test-data/index.ts'; -export { GraphQLTestServer } from './graphql-test-server.ts'; -export type { - MongoDBSeedDataFunction, - MongoDBTestServerStartOptions, -} from './test-mongodb-server.ts'; -export { MongoDBTestServer } from './test-mongodb-server.ts'; -export type { TestServer } from './test-server.interface.ts'; diff --git a/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts b/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts deleted file mode 100644 index 7ae4a488f..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { type MongoMemoryReplicaSetConfig, type MongoMemoryReplicaSetDisposer, startMongoMemoryReplicaSet } from '@cellix/server-mongodb-memory-mock-seedwork'; -import { ServiceMongoose } from '@ocom/service-mongoose'; -import { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from '@ocom-verification/verification-shared/test-data'; -import { MongoClient } from 'mongodb'; - -const DEFAULT_DB_NAME = 'owner-community'; -const DEFAULT_MONGO_PORT = 50_000; -const DEFAULT_REPL_SET_NAME = 'globaldb'; - -export type { MongoDBSeedDataFunction }; - -export interface MongoDBTestServerStartOptions { - dbName?: string; - port?: number; - replSetName?: string; - binaryVersion?: string; - attachMongoose?: boolean; - seedData?: MongoDBSeedDataFunction; -} - -/** - * In-memory MongoDB replica set for verification tests. - */ -export class MongoDBTestServer { - private disposer: MongoMemoryReplicaSetDisposer | null = null; - private serviceMongoose: ServiceMongoose | null = null; - private connectionString = ''; - private dbName = DEFAULT_DB_NAME; - private seedData: MongoDBSeedDataFunction = seedDatabase; - - async start(options?: MongoDBTestServerStartOptions): Promise { - const config: MongoMemoryReplicaSetConfig = { - port: options?.port ?? DEFAULT_MONGO_PORT, - dbName: options?.dbName ?? DEFAULT_DB_NAME, - replSetName: options?.replSetName ?? DEFAULT_REPL_SET_NAME, - ...(options?.binaryVersion && { binaryVersion: options.binaryVersion }), - }; - - this.dbName = config.dbName; - this.seedData = options?.seedData ?? seedDatabase; - - const { connectionString, disposer } = await startMongoMemoryReplicaSet(config); - this.disposer = disposer; - this.connectionString = connectionString; - await this.seed(); - - if (options?.attachMongoose) { - await this.attachMongoose(); - } - } - - getServiceMongoose(): ServiceMongoose { - if (!this.serviceMongoose) { - throw new Error('MongoDBTestServer Mongoose service not attached'); - } - return this.serviceMongoose; - } - - getConnectionString(): string { - if (!this.connectionString) { - throw new Error('MongoDBTestServer not started'); - } - return this.connectionString; - } - - async resetForScenario(seedData?: MongoDBSeedDataFunction): Promise { - if (!this.connectionString) { - throw new Error('MongoDBTestServer not started'); - } - - await clearDatabase({ connectionString: this.connectionString, dbName: this.dbName }); - await this.seed(seedData); - } - - async stop(): Promise { - if (this.serviceMongoose) { - await this.serviceMongoose.shutDown(); - this.serviceMongoose = null; - } - if (this.disposer) { - const disposer = this.disposer; - this.disposer = null; - await disposer.stop(); - } - this.connectionString = ''; - } - - isRunning(): boolean { - return this.disposer !== null; - } - - private async attachMongoose(): Promise { - this.serviceMongoose = new ServiceMongoose(this.connectionString, { - dbName: this.dbName, - autoIndex: true, - autoCreate: true, - }); - await this.serviceMongoose.startUp(); - this.clearMongooseModels(); - } - - private clearMongooseModels(): void { - const connection = this.serviceMongoose?.service.connection; - if (!connection) return; - - for (const modelName of Object.keys(connection.models)) { - try { - connection.deleteModel(modelName); - } catch { - /* already deleted */ - } - } - } - - private async seed(seedData = this.seedData): Promise { - await seedData({ connectionString: this.connectionString, dbName: this.dbName }); - } -} - -async function clearDatabase(context: MongoDBSeedContext): Promise { - const client = new MongoClient(context.connectionString); - try { - await client.connect(); - const db = client.db(context.dbName); - const collections = await db.listCollections({}, { nameOnly: true }).toArray(); - await Promise.all(collections.map((collection) => db.collection(collection.name).deleteMany({}))); - } finally { - await client.close(); - } -} diff --git a/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts b/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts deleted file mode 100644 index 16e024f23..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Common interface for all test servers (in-process and subprocess). - * Implemented by GraphQLTestServer (in-process), PortlessServer (subprocess - * via the portless proxy), and TestAzuriteServer. - */ -export interface TestServer { - /** Start the server and return when ready */ - start(): Promise; - - /** Stop the server gracefully */ - stop(): Promise; - - /** Check if server is currently running */ - isRunning(): boolean; - - /** Get the server URL (throws if not running) */ - getUrl(): string; -} diff --git a/packages/ocom-verification/verification-shared/src/settings/index.ts b/packages/ocom-verification/verification-shared/src/settings/index.ts deleted file mode 100644 index 5969fa475..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { buildPortlessUrl, getHostnames, PORTLESS_PORT } from './portless-settings.ts'; -export { getTimeout, type TimeoutKey, timeouts } from './timeout-settings.ts'; diff --git a/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts b/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts deleted file mode 100644 index 9415b0a64..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts +++ /dev/null @@ -1,58 +0,0 @@ -interface PortlessHostnames { - uiCommunity: string; - uiStaff: string; - api: string; - mockAuth: string; - docs: string; -} - -const PORTLESS_PORT = 1355; - -function buildPortlessUrl(hostname: string, path = ''): string { - return `https://${hostname}:${PORTLESS_PORT}${path}`; -} - -function getHostnames(): PortlessHostnames { - const hostnames = { - uiCommunity: requireHostname('VITE_APP_UI_COMMUNITY_BASE_URL'), - uiStaff: requireHostname('VITE_APP_UI_STAFF_AAD_REDIRECT_URI'), - api: requireHostname('VITE_COMMON_API_ENDPOINT'), - mockAuth: requireHostname('VITE_APP_UI_COMMUNITY_B2C_AUTHORITY'), - }; - const { WORKTREE_NAME: worktreeName = '' } = process.env; - - return applyWorktreeSuffixes(hostnames, worktreeName); -} - -function hostnameFrom(url: string): string | null { - try { - return new URL(url).hostname; - } catch { - return null; - } -} - -function requireHostname(key: string): string { - const hostname = hostnameFrom(process.env[key] ?? ''); - if (!hostname) { - throw new Error(`portless-settings: required env var ${key} is missing or invalid`); - } - return hostname; -} - -function applyWorktreeSuffixes(hostnames: Omit, worktreeName: string): PortlessHostnames { - return { - uiCommunity: applyWorktreeSuffix(hostnames.uiCommunity, worktreeName), - uiStaff: applyWorktreeSuffix(hostnames.uiStaff, worktreeName), - api: applyWorktreeSuffix(hostnames.api, worktreeName), - mockAuth: applyWorktreeSuffix(hostnames.mockAuth, worktreeName), - docs: applyWorktreeSuffix(`docs.${hostnames.uiCommunity}`, worktreeName), - }; -} - -function applyWorktreeSuffix(hostname: string, worktreeName: string): string { - if (!worktreeName) return hostname; - return hostname.replace('.localhost', `.${worktreeName}.localhost`); -} - -export { buildPortlessUrl, getHostnames, PORTLESS_PORT }; diff --git a/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts b/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts deleted file mode 100644 index 89e55ebaa..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Centralized timeout configuration for all verification test packages. - * - * These timeouts are intentionally generous to accommodate: - * - CI environments with limited resources - * - First-time server startup (cold starts) - * - Parallel test execution contention - */ -export const timeouts = { - /** Default scenario timeout (2 minutes) */ - scenario: 120_000, - - /** Server startup timeout (2 minutes) */ - serverStartup: 120_000, - - /** Server shutdown graceful period (10 seconds) */ - serverShutdown: 10_000, - - /** Health probe timeout (3 seconds) */ - healthProbe: 3_000, - - /** Health probe retry interval (500ms) */ - healthProbeInterval: 500, - - /** UI initialization timeout (30 seconds) */ - uiInit: 30_000, - - /** UI cleanup timeout (10 seconds) */ - uiCleanup: 10_000, -} as const; - -/** Type for timeout configuration keys */ -export type TimeoutKey = keyof typeof timeouts; - -function timeoutEnvName(key: TimeoutKey): string { - return `TIMEOUT_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}`; -} - -/** - * Get timeout value with optional override from environment. - * Usage: TIMEOUT_SERVER_STARTUP=300000 npm test - */ -export function getTimeout(key: TimeoutKey): number { - const envName = timeoutEnvName(key); - const envOverride = process.env[envName]; - - if (envOverride) { - const parsed = Number(envOverride); - if (Number.isInteger(parsed) && parsed > 0) { - return parsed; - } - - console.warn(`Ignoring invalid ${envName} value "${envOverride}"; expected a positive integer.`); - } - - return timeouts[key]; -} diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts index 1141198e8..205524e72 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts @@ -1,5 +1,4 @@ -import type { ActorDetails } from '../../helpers/actor-helpers.ts'; -import { actors } from '../test-actors.ts'; +import { actors, type TestActor } from '../test-actors.ts'; export interface EndUserSeedDocument { _id: string; @@ -31,7 +30,7 @@ export const END_USER_IDS = { export const endUsers: EndUserSeedDocument[] = [createEndUserSeedDocument(END_USER_IDS.communityOwner, actors.CommunityOwner), createEndUserSeedDocument(END_USER_IDS.communityMember, actors.CommunityMember)]; -function createEndUserSeedDocument(id: string, actor: ActorDetails): EndUserSeedDocument { +function createEndUserSeedDocument(id: string, actor: TestActor): EndUserSeedDocument { return { _id: id, userType: 'end-users', diff --git a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts index 5c1a7f51d..32517bbc6 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts @@ -1,6 +1,10 @@ -import type { ActorDetails } from '../helpers/actor-helpers.ts'; - -export type TestActor = ActorDetails; +export interface TestActor { + name: string; + externalId: string; + email: string; + givenName: string; + familyName: string; +} const communityOwner: TestActor = { name: 'CommunityOwner', @@ -26,9 +30,18 @@ const guest: TestActor = { familyName: '', }; +const staffUser: TestActor = { + name: 'StaffUser', + externalId: '10000000-0000-4000-8000-000000000001', + email: 'staff@sharethrift.onmicrosoft.com', + givenName: 'Staff', + familyName: 'User', +}; + export const actors = { CommunityOwner: communityOwner, CommunityMember: communityMember, + StaffUser: staffUser, Guest: guest, } as const; diff --git a/packages/ocom-verification/verification-shared/src/test-data/utils.ts b/packages/ocom-verification/verification-shared/src/test-data/utils.ts index 6f2bf3a14..79a820f91 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/utils.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/utils.ts @@ -1,8 +1,8 @@ -import { Types } from 'mongoose'; +import { ObjectId } from 'mongodb'; /** * Generate a random MongoDB ObjectId string — useful for seeding test data. */ export function generateObjectId(): string { - return new Types.ObjectId().toHexString(); + return new ObjectId().toHexString(); } diff --git a/packages/ocom/application-services/src/contexts/user/index.ts b/packages/ocom/application-services/src/contexts/user/index.ts index e7a3c1f62..6841b047b 100644 --- a/packages/ocom/application-services/src/contexts/user/index.ts +++ b/packages/ocom/application-services/src/contexts/user/index.ts @@ -1,15 +1,18 @@ import type { DataSources } from '@ocom/persistence'; import { EndUser as EndUserApi, type EndUserApplicationService } from './end-user/index.ts'; import { StaffRole as StaffRoleApi, type StaffRoleApplicationService } from './staff-role/index.ts'; +import { StaffUser as StaffUserApi, type StaffUserApplicationService } from './staff-user/index.ts'; export interface UserContextApplicationService { EndUser: EndUserApplicationService; StaffRole: StaffRoleApplicationService; + StaffUser: StaffUserApplicationService; } export const User = (dataSources: DataSources): UserContextApplicationService => { return { EndUser: EndUserApi(dataSources), StaffRole: StaffRoleApi(dataSources), + StaffUser: StaffUserApi(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts new file mode 100644 index 000000000..c2f7eccd3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts @@ -0,0 +1,493 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { createDefaultRoles, StaffAppRoleNames } from './create-default-roles.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-default-roles.feature')); + +type StaffRolePermissions = { + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + userPermissions: { canManageUsers: boolean }; +}; + +function makeMockStaffRole( + roleName: string, + permissions: StaffRolePermissions = { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, +): Domain.Contexts.User.StaffRole.StaffRole { + return { + id: `id-${roleName}`, + roleName, + isDefault: false, + permissions, + roleType: null, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRole; +} + +function makeMockRepo(existingRoleNames: string[] = [], overrides: Partial = {}): StaffRoleRepo { + const savedRoles: Domain.Contexts.User.StaffRole.StaffRole[] = []; + + return { + // biome-ignore lint/suspicious/noExplicitAny: test helper captures saved roles for inspection + _savedRoles: savedRoles as any, + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + if (existingRoleNames.includes(roleName)) { + return Promise.resolve(makeMockStaffRole(roleName)); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + if (existingRoleNames.includes(enterpriseAppRole)) { + return Promise.resolve(makeMockStaffRole(enterpriseAppRole)); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultCaseManagerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.CaseManager, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.ServiceLineOwner, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultFinanceInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.Finance, { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultTechAdminInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.TechAdmin, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: true }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: true }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => { + return Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); + }), + ...overrides, + } as unknown as StaffRoleRepo; +} + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +function makeDataSources(repo: StaffRoleRepo): DataSources { + // Ensure compatibility for tests that only stub getNewInstance by mapping new factory methods to it when missing + const repoWithDefaults = { ...repo } as StaffRoleRepo; + if (!repoWithDefaults.getNewDefaultCaseManagerInstance) { + repoWithDefaults.getNewDefaultCaseManagerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.CaseManager); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultServiceLineOwnerInstance) { + repoWithDefaults.getNewDefaultServiceLineOwnerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.ServiceLineOwner); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultFinanceInstance) { + repoWithDefaults.getNewDefaultFinanceInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.Finance); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = false; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = false; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultTechAdminInstance) { + repoWithDefaults.getNewDefaultTechAdminInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.TechAdmin); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }).canManageCommunities = true; + (role.permissions.communityPermissions as { canManageStaffRolesAndPermissions?: boolean }).canManageStaffRolesAndPermissions = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = true; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getDefaultRoleByEnterpriseAppRole) { + repoWithDefaults.getDefaultRoleByEnterpriseAppRole = (enterpriseAppRole: string) => repoWithDefaults.getByRoleName(enterpriseAppRole); + } + + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, callback: (r: StaffRoleRepo) => Promise) => { + await callback(repoWithDefaults as unknown as StaffRoleRepo); + }), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let mockRepo: StaffRoleRepo; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]; + + BeforeEachScenario(() => { + result = []; + mockRepo = undefined as unknown as typeof mockRepo; + dataSources = undefined as unknown as DataSources; + }); + + // ─── All four missing ────────────────────────────────────────────────────── + + Scenario('Creates all four default roles when none exist', ({ Given, When, Then, And }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin"', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should return all four created role references', () => { + expect(result).toHaveLength(4); + for (const r of result) expect(r.isDefault).toBe(true); + }); + }); + + // ─── Partial skip ───────────────────────────────────────────────────────── + + Scenario('Skips roles that already exist', ({ Given, When, Then, And }) => { + Given('the role "Default.CaseManager" already exists', () => { + mockRepo = makeMockRepo([StaffAppRoleNames.CaseManager]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should only create the three missing roles', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should not attempt to create "Default.CaseManager" again', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + }); + }); + + // ─── All exist ──────────────────────────────────────────────────────────── + + Scenario('Returns empty array when all roles already exist', ({ Given, When, Then, And }) => { + Given('all four default roles already exist', () => { + mockRepo = makeMockRepo(Object.values(StaffAppRoleNames)); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should return an empty array', () => { + expect(result).toHaveLength(0); + }); + + And('it should not call getNewInstance or save', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.save)).not.toHaveBeenCalled(); + }); + }); + + // ─── CaseManager permissions ────────────────────────────────────────────── + + Scenario('CaseManager role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.CaseManager" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.CaseManager" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── Finance permissions ────────────────────────────────────────────────── + + Scenario('Finance role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.Finance" role should have canManageCommunities false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(false); + }); + + And('the "Default.Finance" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.Finance" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.Finance" role should have canManageUsers false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.userPermissions.canManageUsers).toBe(false); + }); + }); + + // ─── TechAdmin permissions ──────────────────────────────────────────────── + + Scenario('TechAdmin role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.TechAdmin" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role?.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageTechAdmin true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── ServiceLineOwner permissions ───────────────────────────────────────── + + Scenario('ServiceLineOwner role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.ServiceLineOwner" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.ServiceLineOwner" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── isDefault false ────────────────────────────────────────────────────── + + Scenario('All created roles have isDefault set to true', ({ Given, When, Then }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('all created roles should have isDefault true', () => { + for (const role of result) { + expect(role.isDefault).toBe(true); + } + }); + }); + + // ─── Error propagation ──────────────────────────────────────────────────── + + Scenario('Propagates unexpected repository errors', ({ Given, When, Then }) => { + let thrownError: unknown; + + Given('no staff roles exist', () => { + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('Database connection failed')), + getNewInstance: vi.fn(), + save: vi.fn(), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('the repository throws an unexpected error', async () => { + try { + await createDefaultRoles(dataSources)(); + } catch (error) { + thrownError = error; + } + }); + + Then('createDefaultRoles should propagate the error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Database connection failed'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts new file mode 100644 index 000000000..a530e9346 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts @@ -0,0 +1,47 @@ +import { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +export const StaffAppRoleNames = Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames; + +const roleExists = async (repository: StaffRoleRepo, enterpriseAppRole: string): Promise => { + try { + await repository.getDefaultRoleByEnterpriseAppRole(enterpriseAppRole); + return true; + } catch (error) { + if (error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found'))) { + return false; + } + throw error; + } +}; + +const roleDefinitions: ReadonlyArray<{ + enterpriseAppRole: string; + factory: (repo: StaffRoleRepo) => Promise>; +}> = [ + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.CaseManager, factory: (repo) => repo.getNewDefaultCaseManagerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.ServiceLineOwner, factory: (repo) => repo.getNewDefaultServiceLineOwnerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.Finance, factory: (repo) => repo.getNewDefaultFinanceInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.TechAdmin, factory: (repo) => repo.getNewDefaultTechAdminInstance() }, +]; + +export const createDefaultRoles = (dataSources: DataSources) => { + return async (): Promise => { + const systemPassport = Domain.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }); + const created: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] = []; + + for (const { enterpriseAppRole, factory } of roleDefinitions) { + let saved: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repository) => { + if (await roleExists(repository, enterpriseAppRole)) return; + const role = await factory(repository); + saved = await repository.save(role); + }); + if (saved) created.push(saved); + } + + return created; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature new file mode 100644 index 000000000..83892960d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature @@ -0,0 +1,61 @@ +Feature: Creating default staff roles + + Scenario: Creates all four default roles when none exist + Given no staff roles exist + When I call createDefaultRoles + Then it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin" + And it should return all four created role references + + Scenario: Skips roles that already exist + Given the role "Default.CaseManager" already exists + When I call createDefaultRoles + Then it should only create the three missing roles + And it should not attempt to create "Default.CaseManager" again + + Scenario: Returns empty array when all roles already exist + Given all four default roles already exist + When I call createDefaultRoles + Then it should return an empty array + And it should not call getNewInstance or save + + Scenario: CaseManager role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.CaseManager" role should have canManageCommunities true + And the "Default.CaseManager" role should have canManageFinance false + And the "Default.CaseManager" role should have canManageTechAdmin false + And the "Default.CaseManager" role should have canManageUsers true + + Scenario: Finance role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.Finance" role should have canManageCommunities false + And the "Default.Finance" role should have canManageFinance true + And the "Default.Finance" role should have canManageTechAdmin false + And the "Default.Finance" role should have canManageUsers false + + Scenario: TechAdmin role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.TechAdmin" role should have canManageCommunities true + And the "Default.TechAdmin" role should have canManageFinance true + And the "Default.TechAdmin" role should have canManageTechAdmin true + And the "Default.TechAdmin" role should have canManageUsers true + + Scenario: ServiceLineOwner role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.ServiceLineOwner" role should have canManageCommunities true + And the "Default.ServiceLineOwner" role should have canManageFinance false + And the "Default.ServiceLineOwner" role should have canManageTechAdmin false + And the "Default.ServiceLineOwner" role should have canManageUsers true + + Scenario: All created roles have isDefault set to true + Given no staff roles exist + When I call createDefaultRoles + Then all created roles should have isDefault true + + Scenario: Propagates unexpected repository errors + Given no staff roles exist + When the repository throws an unexpected error + Then createDefaultRoles should propagate the error diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts index 40c815cac..e032256e8 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts @@ -1,12 +1,14 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { create, type StaffRoleCreateCommand } from './create.ts'; +import { createDefaultRoles } from './create-default-roles.ts'; import { deleteAndReassign, type StaffRoleDeleteAndReassignCommand } from './delete-and-reassign.ts'; import { queryById, type StaffRoleQueryByIdCommand } from './query-by-id.ts'; import { queryByRoleName, type StaffRoleQueryByRoleNameCommand } from './query-by-role-name.ts'; export interface StaffRoleApplicationService { create: (command: StaffRoleCreateCommand) => Promise; + createDefaultRoles: () => Promise; deleteAndReassign: (command: StaffRoleDeleteAndReassignCommand) => Promise; queryById: (command: StaffRoleQueryByIdCommand) => Promise; queryByRoleName: (command: StaffRoleQueryByRoleNameCommand) => Promise; @@ -15,6 +17,7 @@ export interface StaffRoleApplicationService { export const StaffRole = (dataSources: DataSources): StaffRoleApplicationService => { return { create: create(dataSources), + createDefaultRoles: createDefaultRoles(dataSources), deleteAndReassign: deleteAndReassign(dataSources), queryById: queryById(dataSources), queryByRoleName: queryByRoleName(dataSources), diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts new file mode 100644 index 000000000..bf7506839 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts @@ -0,0 +1,431 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { StaffAppRoleNames } from '../staff-role/create-default-roles.ts'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-if-not-exists.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeMockStaffRoleRef(roleName: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id: `role-id-${roleName}`, + roleName, + enterpriseAppRole: roleName, + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUserEntityReference { + role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; +} + +function makeMockNewUser(externalId: string): MockStaffUserInstance { + let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + return { + id: `new-id-${externalId}`, + externalId, + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + displayName: 'First Last', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return _role; + }, + set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { + _role = r; + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as MockStaffUserInstance; +} + +function makeDataSources(overrides: { + existingUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + newUser?: MockStaffUserInstance; + savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + roleByEnterpriseAppRole?: Record; + saveShouldFail?: boolean; +}): DataSources { + const newUser = overrides.newUser ?? makeMockNewUser('default'); + const savedUser = overrides.savedUser ?? (newUser as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference); + + const staffUserRepo = { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + getNewInstance: vi.fn().mockResolvedValue(newUser), + save: overrides.saveShouldFail ? vi.fn().mockResolvedValue(undefined) : vi.fn().mockResolvedValue(savedUser), + delete: vi.fn(), + } as unknown as Domain.Contexts.User.StaffUser.StaffUserRepository; + + const staffRoleRepo = { + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + const role = Object.values(overrides.roleByEnterpriseAppRole ?? {}).find((candidate) => candidate.roleName === roleName); + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + const role = overrides.roleByEnterpriseAppRole?.[enterpriseAppRole]; + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((name: string) => Promise.resolve(makeMockStaffRoleRef(name))), + getNewDefaultCaseManagerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.CaseManager)), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.ServiceLineOwner)), + getNewDefaultFinanceInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.Finance)), + getNewDefaultTechAdminInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin)), + save: vi.fn().mockImplementation((r: unknown) => Promise.resolve(r)), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + }, + }, + }, + }, + domainDataSource: { + User: { + StaffUser: { + StaffUserUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + }, + }, + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + }, + }, + }, + }, + _staffUserRepo: staffUserRepo, + _staffRoleRepo: staffRoleRepo, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _staffUserRepo?: typeof Object; _staffRoleRepo?: typeof Object }; + let command: StaffUserCreateIfNotExistsCommand; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + let thrownError: unknown; + let existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let newUser: MockStaffUserInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + existingUser = null; + newUser = makeMockNewUser('default'); + command = { + externalId: 'ext-default', + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + aadRoles: [], + }; + }); + + // ─── Returns existing user ──────────────────────────────────────────────── + + Scenario('Returns existing user when user already exists', ({ Given, When, Then, And }) => { + Given('a staff user with externalId "ext-123" already exists', () => { + existingUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources({ existingUser }); + command = { ...command, externalId: 'ext-123' }; + }); + + When('I call createIfNotExists with externalId "ext-123"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should return the existing user', () => { + expect(result).toBe(existingUser); + }); + + And('it should not create a new user', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).not.toHaveBeenCalled(); + }); + }); + + // ─── Creates new user (no role) ─────────────────────────────────────────── + + Scenario('Creates a new user when user does not exist', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-456" exists', () => { + newUser = makeMockNewUser('ext-456'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-456', aadRoles: [] }; + }); + + And('no matching AAD role is provided', () => { + // aadRoles is already [] + }); + + When('I call createIfNotExists with externalId "ext-456"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should call createDefaultRoles', () => { + const roleUow = ( + dataSources as unknown as { + domainDataSource: { User: { StaffRole: { StaffRoleUnitOfWork: { withTransaction: ReturnType } } } }; + } + ).domainDataSource.User.StaffRole.StaffRoleUnitOfWork; + expect(roleUow.withTransaction).toHaveBeenCalled(); + }); + + And('it should create a new user with the provided details', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).toHaveBeenCalledWith('ext-456', 'First', 'Last', 'first@example.com'); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-456'); + }); + }); + + // ─── Assigns matching role ──────────────────────────────────────────────── + + Scenario('Creates a new user with a matching role when AAD role matches', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-789" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-789'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.CaseManager': roleRef }, + }); + command = { ...command, externalId: 'ext-789' }; + }); + + And('the AAD roles include "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Staff.CaseManager'] }; + }); + + And('the "Staff.CaseManager" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-789"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Staff.CaseManager" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.CaseManager); + }); + }); + + Scenario('Assigns Default.TechAdmin when AAD role is enterprise app role', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-201" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + newUser = makeMockNewUser('ext-201'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.TechAdmin': roleRef }, + }); + command = { ...command, externalId: 'ext-201' }; + }); + + And('the AAD roles include "Staff.TechAdmin"', () => { + command = { ...command, aadRoles: ['Staff.TechAdmin'] }; + }); + + And('the "Default.TechAdmin" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-201"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Assigns highest priority matching role when multiple AAD roles are provided', ({ Given, When, Then, And }) => { + let techAdminRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + let caseManagerRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-202" exists', () => { + techAdminRole = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + caseManagerRole = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-202'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { + 'Staff.TechAdmin': techAdminRole, + 'Staff.CaseManager': caseManagerRole, + }, + }); + command = { ...command, externalId: 'ext-202' }; + }); + + And('the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Unknown.Role', 'Staff.TechAdmin', 'Staff.CaseManager'] }; + }); + + And('the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository', () => { + // roles were set up in Given + }); + + When('I call createIfNotExists with externalId "ext-202"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Creates a new user without a role when AAD role has alternate formatting', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-203" exists', () => { + newUser = makeMockNewUser('ext-203'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-203' }; + }); + + And('the AAD roles include "default tech admin"', () => { + command = { ...command, aadRoles: ['default tech admin'] }; + }); + + When('I call createIfNotExists with externalId "ext-203"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when AAD role unknown ──────────────────────────────────────── + + Scenario('Creates a new user without a role when no AAD role matches', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-000" exists', () => { + newUser = makeMockNewUser('ext-000'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-000' }; + }); + + And('the AAD roles include "Unknown.Role"', () => { + command = { ...command, aadRoles: ['Unknown.Role'] }; + }); + + When('I call createIfNotExists with externalId "ext-000"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when empty AAD roles ──────────────────────────────────────── + + Scenario('Creates a new user without a role when AAD roles list is empty', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-111" exists', () => { + newUser = makeMockNewUser('ext-111'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-111' }; + }); + + And('the AAD roles list is empty', () => { + command = { ...command, aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-111"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── Throws when save returns undefined ─────────────────────────────────── + + Scenario('Throws when repository fails to save the new user', ({ Given, When, Then }) => { + Given('no staff user with externalId "ext-err" exists', () => { + // save returns undefined to simulate a failed save (createdUser stays undefined) + newUser = makeMockNewUser('ext-err'); + dataSources = makeDataSources({ existingUser: null, newUser, saveShouldFail: true }); + command = { ...command, externalId: 'ext-err', aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-err"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an error with message "Unable to create staff user"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unable to create staff user'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts new file mode 100644 index 000000000..98b25204c --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts @@ -0,0 +1,66 @@ +import type { Domain } from '@ocom/domain'; +import { Domain as DomainRuntime } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { createDefaultRoles } from '../staff-role/create-default-roles.ts'; + +export interface StaffUserCreateIfNotExistsCommand { + externalId: string; + firstName: string; + lastName: string; + email: string; + aadRoles: string[]; +} + +const isNotFoundError = (error: unknown): error is Error => { + return error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found')); +}; + +const getDefaultRoleByHighestPriorityEnterpriseAppRole = async (dataSources: DataSources, aadRoles: string[]): Promise => { + let found: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null = null; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repo) => { + for (const aadRole of aadRoles) { + try { + found = await repo.getDefaultRoleByEnterpriseAppRole(aadRole); + return; + } catch (error) { + if (isNotFoundError(error)) { + continue; + } + throw error; + } + } + }); + return found; +}; + +export const createIfNotExists = (dataSources: DataSources) => { + return async (command: StaffUserCreateIfNotExistsCommand): Promise => { + const existing = await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + if (existing) { + return existing; + } + + // Ensure the 4 default roles exist before creating the user + await createDefaultRoles(dataSources)(); + + const matchingRole = await getDefaultRoleByHighestPriorityEnterpriseAppRole(dataSources, command.aadRoles); + + let createdUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(DomainRuntime.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }), async (repository) => { + const newUser = await repository.getNewInstance(command.externalId, command.firstName, command.lastName, command.email); + + if (matchingRole) { + newUser.role = matchingRole; + } + + createdUser = await repository.save(newUser); + }); + + if (!createdUser) { + throw new Error('Unable to create staff user'); + } + + return createdUser; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature new file mode 100644 index 000000000..fb4c902e5 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature @@ -0,0 +1,59 @@ +Feature: Create staff user if not exists + + Scenario: Returns existing user when user already exists + Given a staff user with externalId "ext-123" already exists + When I call createIfNotExists with externalId "ext-123" + Then it should return the existing user + And it should not create a new user + + Scenario: Creates a new user when user does not exist + Given no staff user with externalId "ext-456" exists + And no matching AAD role is provided + When I call createIfNotExists with externalId "ext-456" + Then it should call createDefaultRoles + And it should create a new user with the provided details + And it should return the newly created user + + Scenario: Creates a new user with a matching role when AAD role matches + Given no staff user with externalId "ext-789" exists + And the AAD roles include "Staff.CaseManager" + And the "Staff.CaseManager" role exists in the repository + When I call createIfNotExists with externalId "ext-789" + Then it should assign the "Staff.CaseManager" role to the new user + + Scenario: Assigns Default.TechAdmin when AAD role is enterprise app role + Given no staff user with externalId "ext-201" exists + And the AAD roles include "Staff.TechAdmin" + And the "Default.TechAdmin" role exists in the repository + When I call createIfNotExists with externalId "ext-201" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Assigns highest priority matching role when multiple AAD roles are provided + Given no staff user with externalId "ext-202" exists + And the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager" + And the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository + When I call createIfNotExists with externalId "ext-202" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Creates a new user without a role when AAD role has alternate formatting + Given no staff user with externalId "ext-203" exists + And the AAD roles include "default tech admin" + When I call createIfNotExists with externalId "ext-203" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when no AAD role matches + Given no staff user with externalId "ext-000" exists + And the AAD roles include "Unknown.Role" + When I call createIfNotExists with externalId "ext-000" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when AAD roles list is empty + Given no staff user with externalId "ext-111" exists + And the AAD roles list is empty + When I call createIfNotExists with externalId "ext-111" + Then it should create the user without assigning a role + + Scenario: Throws when repository fails to save the new user + Given no staff user with externalId "ext-err" exists + When I call createIfNotExists with externalId "ext-err" + Then it should throw an error with message "Unable to create staff user" diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature new file mode 100644 index 000000000..097572fd4 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature @@ -0,0 +1,11 @@ +Feature: Query staff user by external ID + + Scenario: Returns a staff user when the external ID exists + Given a staff user with externalId "ext-123" exists in the read repository + When I call queryByExternalId with externalId "ext-123" + Then it should return the matching staff user + + Scenario: Returns null when no staff user matches the external ID + Given no staff user with externalId "ext-missing" exists in the read repository + When I call queryByExternalId with externalId "ext-missing" + Then it should return null diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/index.ts b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts new file mode 100644 index 000000000..2c5b0d00b --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts @@ -0,0 +1,16 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; +import { queryByExternalId, type StaffUserQueryByExternalIdCommand } from './query-by-external-id.ts'; + +export interface StaffUserApplicationService { + createIfNotExists: (command: StaffUserCreateIfNotExistsCommand) => Promise; + queryByExternalId: (command: StaffUserQueryByExternalIdCommand) => Promise; +} + +export const StaffUser = (dataSources: DataSources): StaffUserApplicationService => { + return { + createIfNotExists: createIfNotExists(dataSources), + queryByExternalId: queryByExternalId(dataSources), + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts new file mode 100644 index 000000000..2cbcc5f4d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { queryByExternalId } from './query-by-external-id.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/query-by-external-id.feature')); + +function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeDataSources(existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null): DataSources { + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(existingUser), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario }) => { + Scenario('Returns a staff user when the external ID exists', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let expectedUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + + Given('a staff user with externalId "ext-123" exists in the read repository', () => { + expectedUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources(expectedUser); + }); + + When('I call queryByExternalId with externalId "ext-123"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-123' }); + }); + + Then('it should return the matching staff user', () => { + expect(result).toBe(expectedUser); + expect(result?.externalId).toBe('ext-123'); + }); + }); + + Scenario('Returns null when no staff user matches the external ID', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + + Given('no staff user with externalId "ext-missing" exists in the read repository', () => { + dataSources = makeDataSources(null); + }); + + When('I call queryByExternalId with externalId "ext-missing"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-missing' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts new file mode 100644 index 000000000..23cd50e5f --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts @@ -0,0 +1,12 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface StaffUserQueryByExternalIdCommand { + externalId: string; +} + +export const queryByExternalId = (dataSources: DataSources) => { + return async (command: StaffUserQueryByExternalIdCommand): Promise => { + return await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + }; +}; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index ccfdc57d1..58e37f066 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -59,8 +59,7 @@ export const buildApplicationServicesFactory = (infrastructureServicesRegistry: passport = Domain.PassportFactory.forMember(endUser, member, community); } } else if (openIdConfigKey === 'StaffPortal') { - const staffUser = undefined; - // const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); + const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); if (staffUser) { passport = Domain.PassportFactory.forStaffUser(staffUser); } diff --git a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts index 43a2cd4ee..da291d9ed 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts @@ -1,6 +1,8 @@ import { type Model, type ObjectId, Schema, type SchemaDefinition } from 'mongoose'; import { type Role, type RoleModelType, roleOptions } from './role.model.ts'; +export const StaffEnterpriseAppRoles = ['Staff.CaseManager', 'Staff.Finance', 'Staff.ServiceLineOwner', 'Staff.TechAdmin'] as const; + export interface StaffRoleServicePermissions { id?: ObjectId; canManageServices: boolean; @@ -12,6 +14,7 @@ export interface StaffRoleServiceTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -21,6 +24,7 @@ export interface StaffRoleViolationTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -34,6 +38,7 @@ export interface StaffRolePropertyPermissions { export interface StaffRoleCommunityPermissions { id?: ObjectId; + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -41,12 +46,37 @@ export interface StaffRoleCommunityPermissions { canReIndexSearchCollections: boolean; } +export interface StaffRoleFinancePermissions { + id?: ObjectId; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleTechAdminPermissions { + id?: ObjectId; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleUserPermissions { + id?: ObjectId; + canManageUsers: boolean; +} + export interface StaffRolePermissions { id?: ObjectId; servicePermissions: StaffRoleServicePermissions; serviceTicketPermissions: StaffRoleServiceTicketPermissions; violationTicketPermissions: StaffRoleViolationTicketPermissions; communityPermissions: StaffRoleCommunityPermissions; + financePermissions: StaffRoleFinancePermissions; + techAdminPermissions: StaffRoleTechAdminPermissions; + userPermissions: StaffRoleUserPermissions; propertyPermissions: StaffRolePropertyPermissions; } @@ -54,6 +84,7 @@ export interface StaffRole extends Role { permissions: StaffRolePermissions; roleName: string; + enterpriseAppRole?: string; roleType?: string; isDefault: boolean; } @@ -68,15 +99,18 @@ const StaffRoleSchema = new Schema, StaffRole>( canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, violationTicketPermissions: { canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, communityPermissions: { + canManageCommunities: { type: Boolean, required: true, default: false }, canManageStaffRolesAndPermissions: { type: Boolean, required: true, @@ -99,19 +133,40 @@ const StaffRoleSchema = new Schema, StaffRole>( default: false, }, } as SchemaDefinition, + financePermissions: { + canManageFinance: { type: Boolean, required: true, default: false }, + canViewGLBatchSummaries: { type: Boolean, required: true, default: false }, + canViewFinanceConfigs: { type: Boolean, required: true, default: false }, + canCreateFinanceConfigs: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + techAdminPermissions: { + canManageTechAdmin: { type: Boolean, required: true, default: false }, + canViewDatabaseExplorer: { type: Boolean, required: true, default: false }, + canViewBlobExplorer: { type: Boolean, required: true, default: false }, + canViewQueueDashboard: { type: Boolean, required: true, default: false }, + canSendQueueMessages: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + userPermissions: { + canManageUsers: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, propertyPermissions: { - // canManageProperties: { type: Boolean, required: true, default: false }, - // canEditOwnProperty: { type: Boolean, required: true, default: false }, + canManageProperties: { type: Boolean, required: true, default: false }, + canEditOwnProperty: { type: Boolean, required: true, default: false }, } as SchemaDefinition, } as SchemaDefinition, - schemaVersion: { type: String, default: '1.0.0' }, - roleName: { type: String, required: true, maxlength: 50 }, + schemaVersion: { type: String, default: '1.0.0', immutable: true }, + roleName: { type: String, required: true, maxlength: 256 }, + enterpriseAppRole: { + type: String, + required: true, + enum: StaffEnterpriseAppRoles, + }, isDefault: { type: Boolean, required: true, default: false }, }, roleOptions, ).index({ roleName: 1 }, { unique: true }); -export const StaffRoleModelName: string = 'staff-roles'; +export const StaffRoleModelName: string = 'staff-user-role'; export const StaffRoleModelFactory = (RoleModel: RoleModelType) => { return RoleModel.discriminator(StaffRoleModelName, StaffRoleSchema); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature index 547bbc02c..ba7c3c123 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature @@ -77,4 +77,18 @@ Feature: StaffRoleCommunityPermissions Scenario: Changing canReIndexSearchCollections without permission Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account When I try to set canReIndexSearchCollections to true - Then a PermissionError should be thrown \ No newline at end of file + Then a PermissionError should be thrown + Scenario: Changing canManageCommunities with manage staff roles permission + Given a StaffRoleCommunityPermissions entity with permission to manage staff roles + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities with system account permission + Given a StaffRoleCommunityPermissions entity with system account permission + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities without permission + Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account + When I try to set canManageCommunities to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature new file mode 100644 index 000000000..1d1b1f4dc --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature @@ -0,0 +1,50 @@ +Feature: StaffRoleFinancePermissions + + Background: + Given valid StaffRoleFinancePermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageFinance with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance with system account permission + Given a StaffRoleFinancePermissions entity with system account permission + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canManageFinance to true + Then a PermissionError should be thrown + + Scenario: Changing canViewGLBatchSummaries with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewGLBatchSummaries to true + Then the property should be updated to true + + Scenario: Changing canViewGLBatchSummaries without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewGLBatchSummaries to true + Then a PermissionError should be thrown + + Scenario: Changing canViewFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canViewFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewFinanceConfigs to true + Then a PermissionError should be thrown + + Scenario: Changing canCreateFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canCreateFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canCreateFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canCreateFinanceConfigs to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature index aae26b8e3..901786338 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature @@ -27,4 +27,19 @@ Feature: StaffRolePermissions Scenario: Accessing violationTicketPermissions Given a StaffRolePermissions entity When I access the violationTicketPermissions property - Then I should receive a StaffRoleViolationTicketPermissions entity instance \ No newline at end of file + Then I should receive a StaffRoleViolationTicketPermissions entity instance + + Scenario: Accessing financePermissions + Given a StaffRolePermissions entity + When I access the financePermissions property + Then I should receive a StaffRoleFinancePermissions entity instance + + Scenario: Accessing techAdminPermissions + Given a StaffRolePermissions entity + When I access the techAdminPermissions property + Then I should receive a StaffRoleTechAdminPermissions entity instance + + Scenario: Accessing userPermissions + Given a StaffRolePermissions entity + When I access the userPermissions property + Then I should receive a StaffRoleUserPermissions entity instance diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature new file mode 100644 index 000000000..33816afec --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature @@ -0,0 +1,68 @@ +Feature: StaffRoleTechAdminPermissions + + Background: + Given valid StaffRoleTechAdminPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageTechAdmin with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin with system account permission + Given a StaffRoleTechAdminPermissions entity with system account permission + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canManageTechAdmin to true + Then a PermissionError should be thrown + + Scenario: Changing canViewDatabaseExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewDatabaseExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewDatabaseExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewDatabaseExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewBlobExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewBlobExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewBlobExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewBlobExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewQueueDashboard with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewQueueDashboard to true + Then the property should be updated to true + + Scenario: Changing canViewQueueDashboard without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewQueueDashboard to true + Then a PermissionError should be thrown + + Scenario: Changing canSendQueueMessages with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canSendQueueMessages to true + Then the property should be updated to true + + Scenario: Changing canSendQueueMessages without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canSendQueueMessages to true + Then a PermissionError should be thrown + + Scenario: Reading tech admin permission flags + Given a StaffRoleTechAdminPermissions entity with all permission flags set to true + Then canManageTechAdmin should be true + And canViewDatabaseExplorer should be true + And canViewBlobExplorer should be true + And canViewQueueDashboard should be true + And canSendQueueMessages should be true diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature new file mode 100644 index 000000000..21cd6b942 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature @@ -0,0 +1,20 @@ +Feature: StaffRoleUserPermissions + + Background: + Given valid StaffRoleUserPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageUsers with manage staff roles permission + Given a StaffRoleUserPermissions entity with permission to manage staff roles + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers with system account permission + Given a StaffRoleUserPermissions entity with system account permission + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers without permission + Given a StaffRoleUserPermissions entity without permission to manage staff roles or system account + When I try to set canManageUsers to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature index d5965aff4..0dad1edde 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature @@ -78,3 +78,22 @@ Feature: StaffRole And the createdAt property should return the correct date And the updatedAt property should return the correct date And the schemaVersion property should return the correct version + + # getDefaultRoleNames + Scenario: Getting default role names + When I call getDefaultRoleNames + Then the result should contain "Default.CaseManager" + And the result should contain "Default.ServiceLineOwner" + And the result should contain "Default.Finance" + And the result should contain "Default.TechAdmin" + And the result should have exactly 4 names + + Scenario: Creating a default tech admin role + When I create a default tech admin staff role + Then the roleName should be "Default Tech Admin" + And the enterpriseAppRole should be "Staff.TechAdmin" + And the tech admin role should allow managing communities + And the tech admin role should allow managing staff roles and permissions + And the tech admin role should allow managing finance + And the tech admin role should allow managing tech admin + And the tech admin role should allow managing users diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts index b86eaf818..0724acbd8 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts @@ -5,10 +5,15 @@ export type { } from './staff-role.ts'; export { StaffRole } from './staff-role.ts'; export type { StaffRoleUnitOfWork } from './staff-role.uow.ts'; +export * as StaffRoleValueObjects from './staff-role.value-objects.ts'; export type { StaffRoleCommunityPermissionsEntityReference, StaffRoleCommunityPermissionsProps, } from './staff-role-community-permissions.ts'; +export type { + StaffRoleFinancePermissionsEntityReference, + StaffRoleFinancePermissionsProps, +} from './staff-role-finance-permissions.ts'; export type { StaffRolePermissionsEntityReference, StaffRolePermissionsProps, @@ -25,6 +30,14 @@ export type { StaffRoleServiceTicketPermissionsEntityReference, StaffRoleServiceTicketPermissionsProps, } from './staff-role-service-ticket-permissions.ts'; +export type { + StaffRoleTechAdminPermissionsEntityReference, + StaffRoleTechAdminPermissionsProps, +} from './staff-role-tech-admin-permissions.ts'; +export type { + StaffRoleUserPermissionsEntityReference, + StaffRoleUserPermissionsProps, +} from './staff-role-user-permissions.ts'; export type { StaffRoleViolationTicketPermissionsEntityReference, StaffRoleViolationTicketPermissionsProps, diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts index dae792d73..0d3d58952 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts @@ -18,6 +18,7 @@ function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = function makeProps(overrides = {}) { return { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -311,4 +312,48 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(setWithoutPermission).toThrow('Cannot set permission'); }); }); + + // canManageCommunities + Scenario('Changing canManageCommunities with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I try to set canManageCommunities to true', () => { + setWithoutPermission = () => { + entity.canManageCommunities = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts index c8887d0fc..c93265029 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts @@ -1,9 +1,10 @@ -import { ValueObject } from '@cellix/domain-seedwork/value-object'; import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; interface StaffRoleCommunityPermissionsSpec { + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -28,6 +29,13 @@ export class StaffRoleCommunityPermissions extends ValueObject ({ + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }), + }), + }, + } as unknown as Passport; +} + +function makeBaseProps(overrides: Partial = {}): StaffRoleProps { + const emptyPermissions = { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + }, + userPermissions: { + canManageUsers: false, + }, + } as const; + + return { + id: 'role-1', + roleName: 'Support', + isDefault: false, + enterpriseAppRole: '', + permissions: emptyPermissions as unknown as StaffRoleProps['permissions'], + roleType: 'staff-role', + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + }; +} + +test('applyDefaultSpec sets CaseManager permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultCaseManagerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets Finance permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultFinanceInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(false); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets ServiceLineOwner permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultServiceLineOwnerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets TechAdmin permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultTechAdminInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts new file mode 100644 index 000000000..e46d32f40 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-finance-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleFinancePermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleFinancePermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleFinancePermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageFinance with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canManageFinance to true', () => { + setWithoutPermission = () => { + entity.canManageFinance = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewGLBatchSummaries with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewGLBatchSummaries to true', () => { + entity.canViewGLBatchSummaries = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Changing canViewGLBatchSummaries without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewGLBatchSummaries to true', () => { + setWithoutPermission = () => { + entity.canViewGLBatchSummaries = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewFinanceConfigs to true', () => { + entity.canViewFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canViewFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canViewFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canCreateFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canCreateFinanceConfigs to true', () => { + entity.canCreateFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canCreateFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canCreateFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canCreateFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canCreateFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts new file mode 100644 index 000000000..e07d6be7d --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts @@ -0,0 +1,61 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleFinancePermissionsSpec { + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleFinancePermissionsProps extends StaffRoleFinancePermissionsSpec, ValueObjectProps {} +export interface StaffRoleFinancePermissionsEntityReference extends Readonly {} + +export class StaffRoleFinancePermissions extends ValueObject implements StaffRoleFinancePermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleFinancePermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageFinance(): boolean { + return this.props.canManageFinance; + } + set canManageFinance(value: boolean) { + this.validateVisa(); + this.props.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.props.canViewGLBatchSummaries; + } + set canViewGLBatchSummaries(value: boolean) { + this.validateVisa(); + this.props.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.props.canViewFinanceConfigs; + } + set canViewFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.props.canCreateFinanceConfigs; + } + set canCreateFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canCreateFinanceConfigs = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts index b09ad76ed..c73dcecf7 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts @@ -3,10 +3,13 @@ import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect, vi } from 'vitest'; import { StaffRoleCommunityPermissions } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; import { StaffRolePermissions } from './staff-role-permissions.ts'; import { StaffRolePropertyPermissions } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions } from './staff-role-violation-ticket-permissions.ts'; const test = { for: describeFeature }; @@ -26,6 +29,9 @@ function makeProps() { serviceTicketPermissions: {} as StaffRoleServiceTicketPermissions, servicePermissions: {} as StaffRoleServicePermissions, violationTicketPermissions: {} as StaffRoleViolationTicketPermissions, + financePermissions: {} as StaffRoleFinancePermissions, + techAdminPermissions: {} as StaffRoleTechAdminPermissions, + userPermissions: {} as StaffRoleUserPermissions, }; } @@ -113,4 +119,43 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(violationTicketPermissions).toBeInstanceOf(StaffRoleViolationTicketPermissions); }); }); + + Scenario('Accessing financePermissions', ({ Given, When, Then }) => { + let financePermissions: StaffRoleFinancePermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the financePermissions property', () => { + financePermissions = entity.financePermissions; + }); + Then('I should receive a StaffRoleFinancePermissions entity instance', () => { + expect(financePermissions).toBeInstanceOf(StaffRoleFinancePermissions); + }); + }); + + Scenario('Accessing techAdminPermissions', ({ Given, When, Then }) => { + let techAdminPermissions: StaffRoleTechAdminPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the techAdminPermissions property', () => { + techAdminPermissions = entity.techAdminPermissions; + }); + Then('I should receive a StaffRoleTechAdminPermissions entity instance', () => { + expect(techAdminPermissions).toBeInstanceOf(StaffRoleTechAdminPermissions); + }); + }); + + Scenario('Accessing userPermissions', ({ Given, When, Then }) => { + let userPermissions: StaffRoleUserPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the userPermissions property', () => { + userPermissions = entity.userPermissions; + }); + Then('I should receive a StaffRoleUserPermissions entity instance', () => { + expect(userPermissions).toBeInstanceOf(StaffRoleUserPermissions); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts index 22e8ee188..7e45a39f6 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts @@ -1,10 +1,13 @@ -import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; import { StaffRoleCommunityPermissions, type StaffRoleCommunityPermissionsEntityReference, type StaffRoleCommunityPermissionsProps } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions, type StaffRoleFinancePermissionsEntityReference, type StaffRoleFinancePermissionsProps } from './staff-role-finance-permissions.ts'; import { StaffRolePropertyPermissions, type StaffRolePropertyPermissionsEntityReference, type StaffRolePropertyPermissionsProps } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions, type StaffRoleServicePermissionsEntityReference, type StaffRoleServicePermissionsProps } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions, type StaffRoleServiceTicketPermissionsEntityReference, type StaffRoleServiceTicketPermissionsProps } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleTechAdminPermissions, type StaffRoleTechAdminPermissionsEntityReference, type StaffRoleTechAdminPermissionsProps } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions, type StaffRoleUserPermissionsEntityReference, type StaffRoleUserPermissionsProps } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions, type StaffRoleViolationTicketPermissionsEntityReference, type StaffRoleViolationTicketPermissionsProps } from './staff-role-violation-ticket-permissions.ts'; export interface StaffRolePermissionsProps extends ValueObjectProps { @@ -13,15 +16,26 @@ export interface StaffRolePermissionsProps extends ValueObjectProps { readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsProps; readonly servicePermissions: StaffRoleServicePermissionsProps; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsProps; + readonly financePermissions: StaffRoleFinancePermissionsProps; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsProps; + readonly userPermissions: StaffRoleUserPermissionsProps; } export interface StaffRolePermissionsEntityReference - extends Readonly> { + extends Readonly< + Omit< + StaffRolePermissionsProps, + 'communityPermissions' | 'propertyPermissions' | 'serviceTicketPermissions' | 'servicePermissions' | 'violationTicketPermissions' | 'financePermissions' | 'techAdminPermissions' | 'userPermissions' + > + > { readonly communityPermissions: StaffRoleCommunityPermissionsEntityReference; readonly propertyPermissions: StaffRolePropertyPermissionsEntityReference; readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsEntityReference; readonly servicePermissions: StaffRoleServicePermissionsEntityReference; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsEntityReference; + readonly financePermissions: StaffRoleFinancePermissionsEntityReference; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsEntityReference; + readonly userPermissions: StaffRoleUserPermissionsEntityReference; } export class StaffRolePermissions extends ValueObject implements StaffRolePermissionsEntityReference { @@ -47,4 +61,13 @@ export class StaffRolePermissions extends ValueObject get violationTicketPermissions(): StaffRoleViolationTicketPermissions { return new StaffRoleViolationTicketPermissions(this.props.violationTicketPermissions, this.visa); } + get financePermissions(): StaffRoleFinancePermissions { + return new StaffRoleFinancePermissions(this.props.financePermissions, this.visa); + } + get techAdminPermissions(): StaffRoleTechAdminPermissions { + return new StaffRoleTechAdminPermissions(this.props.techAdminPermissions, this.visa); + } + get userPermissions(): StaffRoleUserPermissions { + return new StaffRoleUserPermissions(this.props.userPermissions, this.visa); + } } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts new file mode 100644 index 000000000..07bcae739 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts @@ -0,0 +1,239 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-tech-admin-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleTechAdminPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleTechAdminPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleTechAdminPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageTechAdmin with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canManageTechAdmin to true', () => { + setWithoutPermission = () => { + entity.canManageTechAdmin = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewDatabaseExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewDatabaseExplorer to true', () => { + entity.canViewDatabaseExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewDatabaseExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewDatabaseExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewDatabaseExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewBlobExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewBlobExplorer to true', () => { + entity.canViewBlobExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewBlobExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewBlobExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewBlobExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewQueueDashboard with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewQueueDashboard to true', () => { + entity.canViewQueueDashboard = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Changing canViewQueueDashboard without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewQueueDashboard to true', () => { + setWithoutPermission = () => { + entity.canViewQueueDashboard = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canSendQueueMessages with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canSendQueueMessages to true', () => { + entity.canSendQueueMessages = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canSendQueueMessages).toBe(true); + }); + }); + + Scenario('Changing canSendQueueMessages without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canSendQueueMessages to true', () => { + setWithoutPermission = () => { + entity.canSendQueueMessages = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Reading tech admin permission flags', ({ Given, Then, And }) => { + Given('a StaffRoleTechAdminPermissions entity with all permission flags set to true', () => { + props = makeProps({ + canManageTechAdmin: true, + canViewDatabaseExplorer: true, + canViewBlobExplorer: true, + canViewQueueDashboard: true, + canSendQueueMessages: true, + }); + entity = new StaffRoleTechAdminPermissions(props, visa); + }); + Then('canManageTechAdmin should be true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + And('canViewDatabaseExplorer should be true', () => { + expect(entity.canViewDatabaseExplorer).toBe(true); + }); + And('canViewBlobExplorer should be true', () => { + expect(entity.canViewBlobExplorer).toBe(true); + }); + And('canViewQueueDashboard should be true', () => { + expect(entity.canViewQueueDashboard).toBe(true); + }); + And('canSendQueueMessages should be true', () => { + expect(entity.canSendQueueMessages).toBe(true); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts new file mode 100644 index 000000000..9d225e6c7 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts @@ -0,0 +1,70 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleTechAdminPermissionsSpec { + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleTechAdminPermissionsProps extends StaffRoleTechAdminPermissionsSpec, ValueObjectProps {} +export interface StaffRoleTechAdminPermissionsEntityReference extends Readonly {} + +export class StaffRoleTechAdminPermissions extends ValueObject implements StaffRoleTechAdminPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleTechAdminPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageTechAdmin(): boolean { + return this.props.canManageTechAdmin; + } + set canManageTechAdmin(value: boolean) { + this.validateVisa(); + this.props.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.props.canViewDatabaseExplorer; + } + set canViewDatabaseExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.props.canViewBlobExplorer; + } + set canViewBlobExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.props.canViewQueueDashboard; + } + set canViewQueueDashboard(value: boolean) { + this.validateVisa(); + this.props.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.props.canSendQueueMessages; + } + set canSendQueueMessages(value: boolean) { + this.validateVisa(); + this.props.canSendQueueMessages = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts new file mode 100644 index 000000000..969d1e7ab --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-user-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageUsers: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleUserPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleUserPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleUserPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageUsers with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleUserPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I try to set canManageUsers to true', () => { + setWithoutPermission = () => { + entity.canManageUsers = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts new file mode 100644 index 000000000..358c7a7c4 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts @@ -0,0 +1,34 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleUserPermissionsSpec { + canManageUsers: boolean; +} + +export interface StaffRoleUserPermissionsProps extends StaffRoleUserPermissionsSpec, ValueObjectProps {} +export interface StaffRoleUserPermissionsEntityReference extends Readonly {} + +export class StaffRoleUserPermissions extends ValueObject implements StaffRoleUserPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleUserPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageUsers(): boolean { + return this.props.canManageUsers; + } + set canManageUsers(value: boolean) { + this.validateVisa(); + this.props.canManageUsers = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts index 02fb77d52..ff2cf93bc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts @@ -2,6 +2,11 @@ import type { Repository } from '@cellix/domain-seedwork/repository'; import type { StaffRole, StaffRoleProps } from './staff-role.ts'; export interface StaffRoleRepository extends Repository> { getNewInstance(name: string): Promise>; + getNewDefaultCaseManagerInstance(): Promise>; + getNewDefaultServiceLineOwnerInstance(): Promise>; + getNewDefaultFinanceInstance(): Promise>; + getNewDefaultTechAdminInstance(): Promise>; getById(id: string): Promise>; getByRoleName(roleName: string): Promise>; + getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise>; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts index fade23a8e..28fe19c9d 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts @@ -6,7 +6,7 @@ import { expect, vi } from 'vitest'; import { RoleDeletedReassignEvent } from '../../../events/types/role-deleted-reassign.ts'; import type { Passport } from '../../passport.ts'; import { StaffRole, type StaffRoleEntityReference, type StaffRoleProps } from './staff-role.ts'; -import { StaffRolePermissions } from './staff-role-permissions.ts'; +import { StaffRolePermissions, type StaffRolePermissionsProps } from './staff-role-permissions.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -27,6 +27,7 @@ function makeBaseProps(overrides: Partial = {}): StaffRoleProps id: 'role-1', roleName: 'Support', isDefault: false, + enterpriseAppRole: '', permissions: {} as StaffRolePermissions, roleType: 'staff-role', createdAt: new Date('2020-01-01T00:00:00Z'), @@ -36,6 +37,55 @@ function makeBaseProps(overrides: Partial = {}): StaffRoleProps }; } +function makePermissionsProps(overrides: Partial = {}): StaffRolePermissionsProps { + return { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + userPermissions: { + canManageUsers: false, + }, + ...overrides, + }; +} + function getIntegrationEvent(events: readonly unknown[], eventClass: new (aggregateId: string) => T): T | undefined { return events.find((e) => e instanceof eventClass) as T | undefined; } @@ -284,4 +334,55 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(staffRole.schemaVersion).toBe('1.0.0'); }); }); + + // getDefaultRoleNames + Scenario('Getting default role names', ({ When, Then, And }) => { + let roleNames: string[]; + When('I call getDefaultRoleNames', () => { + roleNames = StaffRole.getDefaultRoleNames(); + }); + Then('the result should contain "Default.CaseManager"', () => { + expect(roleNames).toContain('Default.CaseManager'); + }); + And('the result should contain "Default.ServiceLineOwner"', () => { + expect(roleNames).toContain('Default.ServiceLineOwner'); + }); + And('the result should contain "Default.Finance"', () => { + expect(roleNames).toContain('Default.Finance'); + }); + And('the result should contain "Default.TechAdmin"', () => { + expect(roleNames).toContain('Default.TechAdmin'); + }); + And('the result should have exactly 4 names', () => { + expect(roleNames).toHaveLength(4); + }); + }); + + Scenario('Creating a default tech admin role', ({ When, Then, And }) => { + let techAdminRole: StaffRole; + When('I create a default tech admin staff role', () => { + techAdminRole = StaffRole.getNewDefaultTechAdminInstance(makeBaseProps({ permissions: makePermissionsProps() }), passport); + }); + Then('the roleName should be "Default Tech Admin"', () => { + expect(techAdminRole.roleName).toBe('Default Tech Admin'); + }); + And('the enterpriseAppRole should be "Staff.TechAdmin"', () => { + expect(techAdminRole.enterpriseAppRole).toBe('Staff.TechAdmin'); + }); + And('the tech admin role should allow managing communities', () => { + expect(techAdminRole.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('the tech admin role should allow managing staff roles and permissions', () => { + expect(techAdminRole.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('the tech admin role should allow managing finance', () => { + expect(techAdminRole.permissions.financePermissions.canManageFinance).toBe(true); + }); + And('the tech admin role should allow managing tech admin', () => { + expect(techAdminRole.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + And('the tech admin role should allow managing users', () => { + expect(techAdminRole.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts index 49912c977..b915aeccc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts @@ -1,6 +1,6 @@ -import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; import { AggregateRoot } from '@cellix/domain-seedwork/aggregate-root'; import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; import { RoleDeletedReassignEvent, type RoleDeletedReassignProps } from '../../../events/types/role-deleted-reassign.ts'; import type { Passport } from '../../passport.ts'; import type { UserVisa } from '../user.visa.ts'; @@ -10,6 +10,7 @@ import { StaffRolePermissions, type StaffRolePermissionsEntityReference, type St export interface StaffRoleProps extends DomainEntityProps { roleName: string; isDefault: boolean; + enterpriseAppRole: string; readonly permissions: StaffRolePermissionsProps; readonly roleType: string | null; readonly createdAt: Date; @@ -37,6 +38,72 @@ export class StaffRole extends AggregateRoot(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Case Manager'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.CaseManager; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } + + public static getNewDefaultServiceLineOwnerInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Service Line Owner'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.ServiceLineOwner; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } + + public static getNewDefaultFinanceInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Finance'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.Finance; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = false; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = false; + role.isNew = false; + return role; + } + + public static getNewDefaultTechAdminInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Tech Admin'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.TechAdmin; + role.isDefault = true; + // Tech Admins are implicit managers of all areas + role.permissions.communityPermissions.canManageCommunities = true; + // Tech Admins should also be able to manage staff roles & permissions by default + role.permissions.communityPermissions.canManageStaffRolesAndPermissions = true; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = true; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } public deleteAndReassignTo(roleRef: StaffRoleEntityReference) { if (this.isDefault) { throw new PermissionError('You cannot delete a default staff role'); @@ -60,6 +127,18 @@ export class StaffRole extends AggregateRoot permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set enterprise app role'); + } + this.props.enterpriseAppRole = new ValueObjects.EnterpriseAppRole(enterpriseAppRole).valueOf(); + } + get isDefault() { return this.props.isDefault; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts index 3a4a42e28..950cfca76 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts @@ -1,7 +1,16 @@ -import { VOString } from '@lucaspaganini/value-objects'; +import { VOSet, VOString } from '@lucaspaganini/value-objects'; export class RoleName extends VOString({ trim: true, maxLength: 50, minLength: 1, }) {} + +export const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +export class EnterpriseAppRole extends VOSet(Object.values(EnterpriseAppRoleNames)) {} diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts new file mode 100644 index 000000000..7375b9941 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts @@ -0,0 +1,42 @@ +import type { EndUserEntityReference } from '../../../../contexts/user/end-user/index.ts'; +import type { StaffRoleEntityReference } from '../../../../contexts/user/staff-role/staff-role.ts'; +import type { StaffUserEntityReference } from '../../../../contexts/user/staff-user/index.ts'; +import type { UserDomainPermissions } from '../../../../contexts/user/user.domain-permissions.ts'; +import type { UserPassport } from '../../../../contexts/user/user.passport.ts'; +import type { UserVisa } from '../../../../contexts/user/user.visa.ts'; +import type { VendorUserEntityReference } from '../../../../contexts/user/vendor-user/vendor-user.ts'; +import { StaffUserPassportBase } from '../../staff-user.passport-base.ts'; + +export class StaffUserUserPassport extends StaffUserPassportBase implements UserPassport { + forEndUser(_root: EndUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forStaffUser(root: StaffUserEntityReference): UserVisa { + const permissions = this.buildPermissions(root); + return { determineIf: (func) => func(permissions) }; + } + + forStaffRole(_root: StaffRoleEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forVendorUser(_root: VendorUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + private buildPermissions(root?: StaffUserEntityReference): UserDomainPermissions { + const canManageStaffRolesAndPermissions = this._user.role?.permissions.communityPermissions.canManageStaffRolesAndPermissions ?? false; + return { + canManageEndUsers: false, + canManageStaffRolesAndPermissions, + canManageStaffUsers: canManageStaffRolesAndPermissions, + canManageVendorUsers: false, + isEditingOwnAccount: root !== undefined && root.externalId === this._user.externalId, + isSystemAccount: false, + }; + } +} diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature index 8145b32b4..172644ebb 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature @@ -20,4 +20,4 @@ Feature: StaffUserPassport Scenario: Accessing the user passport When I create a StaffUserPassport with valid staff user And I access the user property - Then an error should be thrown indicating the user passport is not available \ No newline at end of file + Then I should receive a StaffUserUserPassport instance \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts index 62a730e0a..6e5f74139 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts @@ -6,6 +6,7 @@ import type { CommunityEntityReference } from '../../../contexts/community/commu import type { StaffUserEntityReference } from '../../../contexts/user/staff-user/staff-user.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserCommunityVisa } from './contexts/staff-user.community.visa.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; import { StaffUserPassport } from './staff-user.passport.ts'; const test = { for: describeFeature }; @@ -85,15 +86,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Accessing the user passport', ({ When, And, Then }) => { - let getUserPassport: () => void; + let userPassport: unknown; When('I create a StaffUserPassport with valid staff user', () => { passport = new StaffUserPassport(staffUser); }); And('I access the user property', () => { - getUserPassport = () => passport.user; + userPassport = passport.user; }); - Then('an error should be thrown indicating the user passport is not available', () => { - expect(getUserPassport).toThrow('User passport is not available for StaffUserPassport'); + Then('I should receive a StaffUserUserPassport instance', () => { + expect(userPassport).toBeInstanceOf(StaffUserUserPassport); }); }); }); diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts index 5d2715451..92a9a9369 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts @@ -2,15 +2,18 @@ import type { CasePassport } from '../../../contexts/case/case.passport.ts'; import type { CommunityPassport } from '../../../contexts/community/community.passport.ts'; import type { Passport } from '../../../contexts/passport.ts'; import type { PropertyPassport } from '../../../contexts/property/property.passport.ts'; +import type { UserPassport } from '../../../contexts/user/user.passport.ts'; import { StaffUserPassportBase } from '../staff-user.passport-base.ts'; import { StaffUserCasePassport } from './contexts/staff-user.case.passport.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserPropertyPassport } from './contexts/staff-user.property.passport.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; export class StaffUserPassport extends StaffUserPassportBase implements Passport { private _communityPassport: CommunityPassport | undefined; private _propertyPassport: PropertyPassport | undefined; private _casePassport: CasePassport | undefined; + private _userPassport: UserPassport | undefined; public get case(): CasePassport { if (!this._casePassport) { @@ -37,7 +40,10 @@ export class StaffUserPassport extends StaffUserPassportBase implements Passport throw new Error('Service passport is not available for StaffUserPassport'); } - public get user(): never { - throw new Error('User passport is not available for StaffUserPassport'); + public get user(): UserPassport { + if (!this._userPassport) { + this._userPassport = new StaffUserUserPassport(this._user); + } + return this._userPassport; } } diff --git a/packages/ocom/domain/tests/acceptance/features/staff-user-management.feature b/packages/ocom/domain/tests/acceptance/features/staff-user-management.feature new file mode 100644 index 000000000..92c7844d5 --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/features/staff-user-management.feature @@ -0,0 +1,30 @@ +@staff-user @e2e +Feature: Staff User Management End-to-End Flow + As a staff user administrator + I want new staff users to be created with the correct default role + So that permissions stay consistent + + Background: + Given I am an authorized staff user administrator + And a staff user blueprint is prepared without an assigned role + + Scenario: Create a staff user + When I create the staff user + Then the staff user should be created successfully + And the staff user should have no default role assigned yet + + Scenario Outline: Assign the default role to a staff user + When I create the staff user + And I assign the default staff role "" + Then the assigned role name should be "" + And the assigned role enterprise app role should be "" + And the assigned role should be default + And the assigned role permissions should be communities , staff roles , finance , tech admin , users + And the staff user should expose the assigned role + + Examples: + | defaultRole | expectedRoleName | expectedEnterpriseAppRole | canManageCommunities | canManageStaffRolesAndPermissions | canManageFinance | canManageTechAdmin | canManageUsers | + | case manager | Default Case Manager | Staff.CaseManager | true | false | false | false | true | + | service line owner | Default Service Line Owner | Staff.ServiceLineOwner | true | false | false | false | true | + | finance | Default Finance | Staff.Finance | false | false | true | false | false | + | tech admin | Default Tech Admin | Staff.TechAdmin | true | true | true | true | true | diff --git a/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts b/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts index 35b27128d..aa11e6b56 100644 --- a/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts +++ b/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts @@ -29,7 +29,7 @@ export class CreateCommunity extends Interaction { static withName(name: string): CreateCommunity { const communityData = { id: '12345', - name: '', + name: 'Placeholder Community', domain: '', whiteLabelDomain: null, handle: null, diff --git a/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts b/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts index 753f3ed04..18b7918c7 100644 --- a/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts +++ b/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts @@ -1,13 +1,12 @@ +import assert from 'node:assert'; import { Given, setWorldConstructor, Then, When } from '@cucumber/cucumber'; import type { Actor } from '@serenity-js/core'; -import assert from 'node:assert'; import type { Community, CommunityProps } from '../../../src/domain/contexts/community/community/community.ts'; -import type { EndUserEntityReference } from '../../../src/domain/contexts/user/end-user/end-user.ts'; import type { Passport } from '../../../src/domain/contexts/passport.ts'; -import { createMockPassport, generateStringOfLength } from '../support/community-test-utils.ts'; - +import type { EndUserEntityReference } from '../../../src/domain/contexts/user/end-user/end-user.ts'; // Import Screenplay pattern components -import { Actors, CommunityManagementCast, ManageCommunities, CommunityManagement, CommunityState, CommunityCreationResults } from '../screenplay/index.ts'; +import { Actors, CommunityCreationResults, CommunityManagement, CommunityManagementCast, CommunityState, ManageCommunities } from '../screenplay/index.ts'; +import { createMockPassport, generateStringOfLength } from '../support/community-test-utils.ts'; /** * Serenity-enhanced World class that maintains state between steps @@ -33,7 +32,7 @@ class SerenityCommunityWorld { // Initialize with default valid data this.validCommunityData = { id: '12345', - name: '', + name: 'Placeholder Community', domain: '', whiteLabelDomain: null, handle: null, @@ -331,8 +330,8 @@ Then('the full name should be preserved', function (this: SerenityCommunityWorld this.syncResultsFromScreenplay(); assert.ok(this.createdCommunity, 'Community should have been created'); - assert.strictEqual(this.createdCommunity.name.length, this.communityName.length, 'Full name length should be preserved'); - assert.strictEqual(this.createdCommunity.name, this.communityName, 'Full name content should be preserved'); + assert.strictEqual(this.createdCommunity?.name.length, this.communityName.length, 'Full name length should be preserved'); + assert.strictEqual(this.createdCommunity?.name, this.communityName, 'Full name content should be preserved'); console.log(`✓ Full name preserved: ${this.communityName.length} characters`); }); diff --git a/packages/ocom/domain/tests/acceptance/step-definitions/member-management.steps.ts b/packages/ocom/domain/tests/acceptance/step-definitions/member-management.steps.ts new file mode 100644 index 000000000..dd356a366 --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/step-definitions/member-management.steps.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert'; +import { Before, Given, Then, When } from '@cucumber/cucumber'; +import type { MemberProps } from '../../../src/domain/contexts/community/member/member.ts'; +import { Member } from '../../../src/domain/contexts/community/member/member.ts'; +import type { Passport } from '../../../src/domain/contexts/passport.ts'; +import { MemberActivatedEvent } from '../../../src/domain/events/types/member-activated.ts'; +import { MemberRemovedEvent } from '../../../src/domain/events/types/member-removed.ts'; +import { createMemberProps, createMockPassport } from '../support/member-test-utils.ts'; + +let passport: Passport; +let member: Member; + +Before(() => { + passport = createMockPassport({ canManageMembers: true }); + member = undefined as unknown as Member; +}); + +Given('I am an authorized community administrator for member management', () => { + passport = createMockPassport({ canManageMembers: true }); + assert.ok(passport); +}); + +Given('a member exists with a pending account', () => { + member = new Member(createMemberProps('CREATED'), passport); + assert.ok(member); +}); + +When('I activate the member', () => { + member.requestActivateMember(); +}); + +Then('the member should be active', () => { + assert.strictEqual(member.isActiveMember, true); + assert.strictEqual(member.accounts[0]?.statusCode, 'ACCEPTED'); + assert.ok(member.getDomainEvents().some((event) => event instanceof MemberActivatedEvent)); +}); + +When('I remove the member', () => { + member.requestRemoveMember(); +}); + +Then('the member should be marked as removed', () => { + assert.strictEqual(member.isDeleted, true); + assert.ok(member.getDomainEvents().some((event) => event instanceof MemberRemovedEvent)); +}); diff --git a/packages/ocom/domain/tests/acceptance/step-definitions/staff-user-management.steps.ts b/packages/ocom/domain/tests/acceptance/step-definitions/staff-user-management.steps.ts new file mode 100644 index 000000000..57fd54ea9 --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/step-definitions/staff-user-management.steps.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert'; +import { Before, Given, Then, When } from '@cucumber/cucumber'; +import type { Passport } from '../../../src/domain/contexts/passport.ts'; +import type { StaffRoleProps } from '../../../src/domain/contexts/user/staff-role/staff-role.ts'; +import { StaffRole } from '../../../src/domain/contexts/user/staff-role/staff-role.ts'; +import type { StaffUserProps } from '../../../src/domain/contexts/user/staff-user/staff-user.ts'; +import { StaffUser } from '../../../src/domain/contexts/user/staff-user/staff-user.ts'; +import { StaffUserCreatedEvent } from '../../../src/domain/events/types/staff-user-created.ts'; +import { createAuthorizingStaffRoleProps, createMockPassport, createStaffUserProps } from '../support/staff-user-test-utils.ts'; + +type DefaultRoleKey = 'case manager' | 'service line owner' | 'finance' | 'tech admin'; + +let passport: Passport; +let staffUser: StaffUser; +let assignedRole: StaffRole; + +Before(() => { + passport = createMockPassport({ canManageStaffRolesAndPermissions: true }); + staffUser = undefined as unknown as StaffUser; + assignedRole = undefined as unknown as StaffRole; +}); + +Given('I am an authorized staff user administrator', () => { + passport = createMockPassport({ canManageStaffRolesAndPermissions: true }); + assert.ok(passport); +}); + +Given('a staff user blueprint is prepared without an assigned role', () => { + const props = createStaffUserProps(); + props.setRoleRef(undefined); + assert.strictEqual(props.role, undefined); +}); + +When('I create the staff user', () => { + staffUser = StaffUser.getNewUser(createStaffUserProps(), passport, '123e4567-e89b-12d3-a456-426614174000', 'Alice', 'Smith', 'alice@cellix.com'); +}); + +Then('the staff user should be created successfully', () => { + assert.ok(staffUser); + assert.strictEqual(staffUser.firstName, 'Alice'); + assert.strictEqual(staffUser.lastName, 'Smith'); + assert.strictEqual(staffUser.email, 'alice@cellix.com'); + assert.strictEqual(staffUser.displayName, 'Alice Smith'); + assert.ok(staffUser.getIntegrationEvents().some((event) => event instanceof StaffUserCreatedEvent)); +}); + +Then('the staff user should have no default role assigned yet', () => { + assert.strictEqual(staffUser.role, undefined); +}); + +When('I assign the default staff role {string}', (defaultRole: DefaultRoleKey) => { + const roleProps = createAuthorizingStaffRoleProps(); + let role: StaffRole | undefined; + + switch (defaultRole) { + case 'case manager': + role = StaffRole.getNewDefaultCaseManagerInstance(roleProps, passport); + break; + case 'service line owner': + role = StaffRole.getNewDefaultServiceLineOwnerInstance(roleProps, passport); + break; + case 'finance': + role = StaffRole.getNewDefaultFinanceInstance(roleProps, passport); + break; + case 'tech admin': + role = StaffRole.getNewDefaultTechAdminInstance(roleProps, passport); + break; + default: + throw new Error(`Unsupported default role: ${defaultRole}`); + } + + assert.ok(role); + assignedRole = role; + staffUser.role = role; +}); + +Then('the assigned role name should be {string}', (expectedRoleName: string) => { + assert.strictEqual(assignedRole.roleName, expectedRoleName); + assert.strictEqual(staffUser.role?.roleName, expectedRoleName); +}); + +Then('the assigned role enterprise app role should be {string}', (expectedEnterpriseAppRole: string) => { + assert.strictEqual(assignedRole.enterpriseAppRole, expectedEnterpriseAppRole); + assert.strictEqual(staffUser.role?.enterpriseAppRole, expectedEnterpriseAppRole); +}); + +Then('the assigned role should be default', () => { + assert.strictEqual(assignedRole.isDefault, true); +}); + +Then( + 'the assigned role permissions should be communities {word}, staff roles {word}, finance {word}, tech admin {word}, users {word}', + (canManageCommunities: string, canManageStaffRolesAndPermissions: string, canManageFinance: string, canManageTechAdmin: string, canManageUsers: string) => { + assert.strictEqual(assignedRole.permissions.communityPermissions.canManageCommunities, canManageCommunities === 'true'); + assert.strictEqual(assignedRole.permissions.communityPermissions.canManageStaffRolesAndPermissions, canManageStaffRolesAndPermissions === 'true'); + assert.strictEqual(assignedRole.permissions.financePermissions.canManageFinance, canManageFinance === 'true'); + assert.strictEqual(assignedRole.permissions.techAdminPermissions.canManageTechAdmin, canManageTechAdmin === 'true'); + assert.strictEqual(assignedRole.permissions.userPermissions.canManageUsers, canManageUsers === 'true'); + }, +); + +Then('the staff user should expose the assigned role', () => { + assert.ok(staffUser.role); + assert.strictEqual(staffUser.role?.roleName, assignedRole.roleName); +}); diff --git a/packages/ocom/domain/tests/acceptance/support/member-test-utils.ts b/packages/ocom/domain/tests/acceptance/support/member-test-utils.ts new file mode 100644 index 000000000..77a40e72f --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/support/member-test-utils.ts @@ -0,0 +1,117 @@ +import type { CommunityEntityReference } from '../../../src/domain/contexts/community/community/community.ts'; +import type { MemberProps } from '../../../src/domain/contexts/community/member/member.ts'; +import type { MemberAccountProps } from '../../../src/domain/contexts/community/member/member-account.ts'; +import type { MemberCustomViewProps } from '../../../src/domain/contexts/community/member/member-custom-view.ts'; +import type { MemberProfileProps } from '../../../src/domain/contexts/community/member/member-profile.ts'; +import type { EndUserRoleEntityReference } from '../../../src/domain/contexts/community/role/end-user-role/end-user-role.ts'; +import type { Passport } from '../../../src/domain/contexts/passport.ts'; + +type MemberPermissions = { + canManageMembers?: boolean; + isSystemAccount?: boolean; +}; + +function createVisa(permissions: MemberPermissions) { + return { + determineIf: (fn: (value: { canManageMembers: boolean; isSystemAccount: boolean }) => boolean) => + fn({ + canManageMembers: permissions.canManageMembers ?? true, + isSystemAccount: permissions.isSystemAccount ?? false, + }), + }; +} + +function createPropArray(items: T[], createNewItem: () => T) { + return { + get items() { + return items; + }, + addItem: (item: T) => { + items.push(item); + }, + getNewItem: () => createNewItem(), + removeItem: (item: T) => { + const index = items.findIndex(({ id }) => id === item.id); + if (index >= 0) { + items.splice(index, 1); + } + }, + removeAll: () => { + items.splice(0, items.length); + }, + }; +} + +export function createMockPassport(permissions: MemberPermissions = {}): Passport { + const visa = createVisa(permissions); + + return { + community: { + forCommunity: () => visa, + }, + } as unknown as Passport; +} + +export function createMemberProps(accountStatusCode: string = 'CREATED'): MemberProps { + const accountProps: MemberAccountProps = { + id: 'account-1', + firstName: 'Alice', + lastName: 'Smith', + user: { id: 'user-1' } as never, + statusCode: accountStatusCode, + createdBy: { id: 'creator-1' } as never, + }; + + const customViewProps: MemberCustomViewProps = { + id: 'custom-view-1', + name: 'Default View', + type: 'TABLE', + filters: [], + sortOrder: 'ASC', + columnsToDisplay: [], + }; + + const profileProps: MemberProfileProps = { + name: 'Test Member', + email: 'alice@example.com', + bio: 'Test bio', + avatarDocumentId: 'avatar-1', + interests: [], + showInterests: true, + showEmail: true, + showProfile: true, + showLocation: false, + showProperties: false, + }; + + return { + id: 'member-1', + memberName: 'Test Member', + cybersourceCustomerId: 'customer-1', + communityId: 'community-1', + community: { id: 'community-1' } as CommunityEntityReference, + loadCommunity: async () => ({ id: 'community-1' }) as CommunityEntityReference, + accounts: createPropArray([accountProps], () => ({ + id: 'account-new', + firstName: 'New', + lastName: 'Member', + user: { id: 'user-new' } as never, + statusCode: 'CREATED', + createdBy: { id: 'creator-new' } as never, + })), + role: { id: 'role-1' } as EndUserRoleEntityReference, + loadRole: async () => ({ id: 'role-1' }) as EndUserRoleEntityReference, + customViews: createPropArray([customViewProps], () => ({ + id: 'custom-view-new', + name: 'New View', + type: 'TABLE', + filters: [], + sortOrder: 'ASC', + columnsToDisplay: [], + })), + profile: profileProps, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + schemaVersion: '1.0.0', + }; +} diff --git a/packages/ocom/domain/tests/acceptance/support/staff-user-test-utils.ts b/packages/ocom/domain/tests/acceptance/support/staff-user-test-utils.ts new file mode 100644 index 000000000..2210cb1b8 --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/support/staff-user-test-utils.ts @@ -0,0 +1,120 @@ +import type { Passport } from '../../../src/domain/contexts/passport.ts'; +import type { StaffRoleProps } from '../../../src/domain/contexts/user/staff-role/staff-role.ts'; +import type { StaffUserProps } from '../../../src/domain/contexts/user/staff-user/staff-user.ts'; + +type StaffUserPermissions = { + canManageStaffRolesAndPermissions?: boolean; + isSystemAccount?: boolean; +}; + +function createVisa(permissions: StaffUserPermissions) { + return { + determineIf: ( + fn: (value: { canManageEndUsers: boolean; canManageStaffRolesAndPermissions: boolean; canManageStaffUsers: boolean; canManageVendorUsers: boolean; isEditingOwnAccount: boolean; isSystemAccount: boolean }) => boolean, + ) => + fn({ + canManageEndUsers: false, + canManageStaffRolesAndPermissions: permissions.canManageStaffRolesAndPermissions ?? true, + canManageStaffUsers: permissions.canManageStaffRolesAndPermissions ?? true, + canManageVendorUsers: false, + isEditingOwnAccount: false, + isSystemAccount: permissions.isSystemAccount ?? false, + }), + }; +} + +export function createMockPassport(permissions: StaffUserPermissions = {}): Passport { + const visa = createVisa(permissions); + + return { + user: { + forEndUser: () => visa, + forStaffUser: () => visa, + forStaffRole: () => visa, + forVendorUser: () => visa, + }, + } as unknown as Passport; +} + +export function createAuthorizingStaffRoleProps(): StaffRoleProps { + return { + id: 'authorizing-role-1', + roleName: 'Authorizing Role', + isDefault: false, + enterpriseAppRole: 'Staff.TechAdmin', + permissions: { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + userPermissions: { + canManageUsers: false, + }, + }, + roleType: 'staff-role', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + schemaVersion: '1.0.0', + }; +} + +export function createStaffUserProps(): StaffUserProps { + let roleRef: StaffRoleProps | undefined; + + return { + id: 'staff-user-1', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@cellix.com', + displayName: 'Alice Smith', + externalId: '123e4567-e89b-12d3-a456-426614174000', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return roleRef; + }, + setRoleRef: (role) => { + roleRef = role; + }, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + schemaVersion: '1.0.0', + }; +} diff --git a/packages/ocom/domain/tests/integration/community-management.integration.test.ts b/packages/ocom/domain/tests/integration/community-management.integration.test.ts index 29e581ac4..e608dd57c 100644 --- a/packages/ocom/domain/tests/integration/community-management.integration.test.ts +++ b/packages/ocom/domain/tests/integration/community-management.integration.test.ts @@ -13,7 +13,7 @@ describe('Community Management - Cucumber Integration Tests', () => { const createValidCommunityData = (): CommunityProps => { return { id: '12345', - name: '', + name: 'Placeholder Community', domain: '', whiteLabelDomain: null, handle: null, diff --git a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts index 8fc71a151..5992cc548 100644 --- a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts +++ b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts @@ -1,5 +1,4 @@ import { mergeResolvers } from '@graphql-tools/merge'; -import endUserRoleResolvers from '../types/end-user-role.resolvers.ts'; import type { Resolvers } from './generated.ts'; import { ocomGraphqlPermissions, ocomGraphqlResolvers } from './resolver-manifest.generated.ts'; @@ -7,5 +6,5 @@ function mergeResolverModules(modules: Resolvers[]): Resolvers { return (modules.length === 0 ? {} : mergeResolvers(modules)) as Resolvers; } -export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers, endUserRoleResolvers]); +export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers]); export const permissions: Resolvers = mergeResolverModules(ocomGraphqlPermissions); diff --git a/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature new file mode 100644 index 000000000..265800347 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature @@ -0,0 +1,22 @@ +Feature: Staff User Resolvers + + As an API consumer + I want to query and create staff user entities + So that I can retrieve a staff user or ensure one exists via the GraphQL API + + Scenario: Querying the current staff user and creating if not exists + Given a user with a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the JWT claims + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with AAD roles + Given a user with a verifiedJwt that includes AAD roles in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the AAD roles + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with no JWT + Given a user without a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should throw an "Unauthorized" error diff --git a/packages/ocom/graphql/src/schema/types/staff-user.graphql b/packages/ocom/graphql/src/schema/types/staff-user.graphql new file mode 100644 index 000000000..1def99be8 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.graphql @@ -0,0 +1,66 @@ +type StaffRoleCommunityPermissions { + canManageCommunities: Boolean! + canManageStaffRolesAndPermissions: Boolean! + canManageAllCommunities: Boolean! + canDeleteCommunities: Boolean! + canChangeCommunityOwner: Boolean! + canReIndexSearchCollections: Boolean! +} + +type StaffRoleFinancePermissions { + canManageFinance: Boolean! + canViewGLBatchSummaries: Boolean! + canViewFinanceConfigs: Boolean! + canCreateFinanceConfigs: Boolean! +} + +type StaffRoleTechAdminPermissions { + canManageTechAdmin: Boolean! + canViewDatabaseExplorer: Boolean! + canViewBlobExplorer: Boolean! + canViewQueueDashboard: Boolean! + canSendQueueMessages: Boolean! +} + +type StaffRoleUserPermissions { + canManageUsers: Boolean! +} + +type StaffRolePermissions { + communityPermissions: StaffRoleCommunityPermissions! + financePermissions: StaffRoleFinancePermissions! + techAdminPermissions: StaffRoleTechAdminPermissions! + userPermissions: StaffRoleUserPermissions! +} + +type StaffRole implements MongoBase { + roleName: String! + isDefault: Boolean! + roleType: String + permissions: StaffRolePermissions! + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +type StaffUser implements MongoBase { + externalId: String! + firstName: String! + lastName: String! + email: String! + displayName: String! + accessBlocked: Boolean! + tags: [String!]! + role: StaffRole + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +extend type Query { + currentStaffUserAndCreateIfNotExists: StaffUser! +} diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts new file mode 100644 index 000000000..66b97abcf --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import { type FieldNode, type GraphQLObjectType, type GraphQLResolveInfo, type GraphQLSchema, Kind, type OperationDefinitionNode } from 'graphql'; +import { expect, vi } from 'vitest'; +import type { GraphContext } from '../context.ts'; +import staffUserResolvers from './staff-user.resolvers.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.resolvers.feature')); + +type StaffUserEntity = Domain.Contexts.User.StaffUser.StaffUserEntityReference; + +function createMockStaffUser(overrides: Partial = {}): StaffUserEntity { + return { + id: 'mock-staff-user-id', + externalId: 'mock-external-id', + firstName: 'Jane', + lastName: 'Smith', + displayName: 'Jane Smith', + email: 'jane@example.com', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + } as unknown as StaffUserEntity; +} + +function makeMockInfo(fieldName: string): GraphQLResolveInfo { + const mockFieldNode: FieldNode = { + kind: Kind.FIELD, + name: { kind: Kind.NAME, value: fieldName }, + }; + return { + fieldName, + fieldNodes: [mockFieldNode], + returnType: {} as GraphQLObjectType, + parentType: {} as GraphQLObjectType, + path: { key: fieldName, prev: undefined, typename: undefined }, + schema: {} as GraphQLSchema, + fragments: {}, + rootValue: {}, + operation: {} as OperationDefinitionNode, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +function makeMockGraphContext(overrides: Partial = {}): GraphContext { + return { + applicationServices: { + User: { + StaffUser: { + createIfNotExists: vi.fn(), + queryByExternalId: vi.fn(), + }, + }, + verifiedUser: { + verifiedJwt: { + sub: 'default-user-sub', + given_name: 'Jane', + family_name: 'Smith', + email: 'jane@example.com', + roles: [], + }, + }, + ...overrides.applicationServices, + }, + ...overrides, + } as unknown as GraphContext; +} + +type QueryResolver = (parent: object, args: Record, context: GraphContext, info: GraphQLResolveInfo) => Promise; + +const callCurrentStaffUserQuery = (context: GraphContext) => (staffUserResolvers.Query?.currentStaffUserAndCreateIfNotExists as unknown as QueryResolver)({}, {}, context, makeMockInfo('currentStaffUserAndCreateIfNotExists')); + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let context: GraphContext; + let result: StaffUserEntity | null; + + BeforeEachScenario(() => { + context = makeMockGraphContext(); + vi.clearAllMocks(); + result = null; + }); + + Scenario('Querying the current staff user and creating if not exists', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + + Given('a user with a verifiedJwt in their context', () => { + // Already set up in BeforeEachScenario with default jwt + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callCurrentStaffUserQuery(context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the JWT claims', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'default-user-sub', + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + aadRoles: [], + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with AAD roles', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + const aadRoles = ['Staff.CaseManager', 'Staff.Finance']; + + Given('a user with a verifiedJwt that includes AAD roles in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + User: { + StaffUser: { + createIfNotExists: vi.fn(), + queryByExternalId: vi.fn(), + }, + }, + verifiedUser: { + verifiedJwt: { + sub: 'roles-user-sub', + given_name: 'Bob', + family_name: 'Jones', + email: 'bob@example.com', + roles: aadRoles, + }, + }, + } as unknown as GraphContext['applicationServices'], + }); + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callCurrentStaffUserQuery(context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the AAD roles', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'roles-user-sub', + firstName: 'Bob', + lastName: 'Jones', + email: 'bob@example.com', + aadRoles, + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with no JWT', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + if (context.applicationServices.verifiedUser) { + context.applicationServices.verifiedUser.verifiedJwt = undefined; + } + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + await expect(callCurrentStaffUserQuery(context)).rejects.toThrow('Unauthorized'); + }); + + Then('it should throw an "Unauthorized" error', () => { + // Already asserted in When + }); + }); +}); diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts new file mode 100644 index 000000000..38be5afa1 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts @@ -0,0 +1,24 @@ +import type { GraphQLResolveInfo } from 'graphql'; +import type { Resolvers } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; + +const staffUser: Resolvers = { + Query: { + currentStaffUserAndCreateIfNotExists: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + throw new Error('Unauthorized'); + } + const result = await context.applicationServices.User.StaffUser.createIfNotExists({ + externalId: jwt.sub, + firstName: jwt.given_name ?? '', + lastName: jwt.family_name ?? '', + email: jwt.email ?? '', + aadRoles: jwt.roles ?? [], + }); + return result; + }, + }, +}; + +export default staffUser; diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts index 23ca925d2..8ca4d7dc5 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts @@ -121,7 +121,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting message when document message is undefined', ({ Given, When, Then }) => { Given('a MemberInvitationDomainAdapter for a document with no message', () => { const docWithoutMessage = makeMemberInvitationDoc(); - // biome-ignore lint/performance/noDelete: needed to test undefined message scenario delete (docWithoutMessage as unknown as Record)['message']; doc = docWithoutMessage; adapter = new MemberInvitationDomainAdapter(doc); diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature index 7b6f611eb..b9f141636 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature @@ -13,6 +13,26 @@ Feature: StaffRoleDomainAdapter When I set the roleName property to "Supervisor" Then the document's roleName should be "Supervisor" + Scenario: Setting the roleName updates the enterpriseAppRole + Given a StaffRoleDomainAdapter for the document + When I set the roleName property to "Supervisor" + Then the document's enterpriseAppRole should be "Supervisor" + + Scenario: Getting the enterpriseAppRole property + Given a StaffRoleDomainAdapter for the document with enterpriseAppRole "Staff.Manager" + When I get the enterpriseAppRole property + Then it should return "Staff.Manager" + + Scenario: Getting the enterpriseAppRole property when missing + Given a StaffRoleDomainAdapter for the document with no enterpriseAppRole + When I get the enterpriseAppRole property + Then it should return "" + + Scenario: Setting the enterpriseAppRole property + Given a StaffRoleDomainAdapter for the document + When I set the enterpriseAppRole property to "Staff.Supervisor" + Then the document's enterpriseAppRole should be "Staff.Supervisor" + Scenario: Getting the isDefault property Given a StaffRoleDomainAdapter for the document When I get the isDefault property @@ -151,4 +171,149 @@ Feature: StaffRoleDomainAdapter And the canAssignTickets property should return false And the canWorkOnTickets property should return false When I set the canCreateTickets property to true - Then the violationTicketPermissions' canCreateTickets should be true \ No newline at end of file + Then the violationTicketPermissions' canCreateTickets should be true + + Scenario: Getting and setting canManageCommunities from communityPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the communityPermissions property + And I get the canManageCommunities property + Then it should return false + When I set the canManageCommunities property to true + Then the communityPermissions' canManageCommunities should be true + + Scenario: Getting financePermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + + Scenario: Getting and setting canManageFinance from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canManageFinance property should return false + When I set the canManageFinance property to true + Then the financePermissions' canManageFinance should be true + + Scenario: Getting and setting canViewGLBatchSummaries from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewGLBatchSummaries property should return false + When I set the canViewGLBatchSummaries property to true + Then the financePermissions' canViewGLBatchSummaries should be true + + Scenario: Getting and setting canViewFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewFinanceConfigs property should return false + When I set the canViewFinanceConfigs property to true + Then the financePermissions' canViewFinanceConfigs should be true + + Scenario: Getting and setting canCreateFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canCreateFinanceConfigs property should return false + When I set the canCreateFinanceConfigs property to true + Then the financePermissions' canCreateFinanceConfigs should be true + + Scenario: Getting techAdminPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + + Scenario: Getting and setting canManageTechAdmin from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canManageTechAdmin property should return false + When I set the canManageTechAdmin property to true + Then the techAdminPermissions' canManageTechAdmin should be true + + Scenario: Getting and setting canViewDatabaseExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewDatabaseExplorer property should return false + When I set the canViewDatabaseExplorer property to true + Then the techAdminPermissions' canViewDatabaseExplorer should be true + + Scenario: Getting and setting canViewBlobExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewBlobExplorer property should return false + When I set the canViewBlobExplorer property to true + Then the techAdminPermissions' canViewBlobExplorer should be true + + Scenario: Getting and setting canViewQueueDashboard from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewQueueDashboard property should return false + When I set the canViewQueueDashboard property to true + Then the techAdminPermissions' canViewQueueDashboard should be true + + Scenario: Getting and setting canSendQueueMessages from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canSendQueueMessages property should return false + When I set the canSendQueueMessages property to true + Then the techAdminPermissions' canSendQueueMessages should be true + + Scenario: Getting userPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + + Scenario: Getting and setting canManageUsers from userPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then the canManageUsers property should return false + When I set the canManageUsers property to true + Then the userPermissions' canManageUsers should be true + + Scenario: Lazy-initialising permissions when document has no permissions object + Given a StaffRoleDomainAdapter wrapping a document with no permissions object + When I get the permissions property + Then it should return a StaffRolePermissionsAdapter instance + + Scenario: Lazy-initialising communityPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document + When I get the permissions property + And I get the communityPermissions property + Then it should return a StaffRoleCommunityPermissionsAdapter instance + And canManageCommunities should default to false + + Scenario: Lazy-initialising financePermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + And canManageFinance should default to false + + Scenario: Lazy-initialising techAdminPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + And canManageTechAdmin should default to false + + Scenario: Lazy-initialising userPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + And canManageUsers should default to false + + Scenario: Getting roleType returns null when document roleType is undefined + Given a StaffRoleDomainAdapter wrapping a document with no roleType + When I get the roleType property + Then it should return null \ No newline at end of file diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature index 042b8f64a..1874bac51 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature @@ -26,6 +26,16 @@ Feature: StaffRoleRepository When I call getByRoleName with "nonexistent-role" Then an error should be thrown indicating "StaffRole with roleName nonexistent-role not found" + Scenario: Getting a default staff role by enterpriseAppRole + Given a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager" + When I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager" + Then I should receive a StaffRole domain object + And the domain object's isDefault should be true + + Scenario: Getting a default staff role by enterpriseAppRole that does not exist + When I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole" + Then an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found" + Scenario: Creating a new staff role instance When I call getNewInstance with name "Supervisor" Then I should receive a new StaffRole domain object diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts index 8f7abbf56..993ae4954 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts @@ -1,19 +1,23 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; -import { Domain } from '@ocom/domain'; import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; const test = { for: describeFeature }; + import { + StaffRoleCommunityPermissionsAdapter, StaffRoleConverter, StaffRoleDomainAdapter, + StaffRoleFinancePermissionsAdapter, StaffRolePermissionsAdapter, - StaffRoleCommunityPermissionsAdapter, StaffRolePropertyPermissionsAdapter, StaffRoleServicePermissionsAdapter, StaffRoleServiceTicketPermissionsAdapter, + StaffRoleTechAdminPermissionsAdapter, + StaffRoleUserPermissionsAdapter, StaffRoleViolationTicketPermissionsAdapter, } from './staff-role.domain-adapter.ts'; @@ -111,6 +115,57 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => }); }); + Scenario('Setting the roleName updates the enterpriseAppRole', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the roleName property to "Supervisor"', () => { + adapter.roleName = 'Supervisor'; + }); + Then('the document\'s enterpriseAppRole should be "Supervisor"', () => { + expect(doc.enterpriseAppRole).toBe('Supervisor'); + }); + }); + + Scenario('Getting the enterpriseAppRole property', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document with enterpriseAppRole "Staff.Manager"', () => { + doc = makeStaffRoleDoc({ enterpriseAppRole: 'Staff.Manager' }); + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the enterpriseAppRole property', () => { + result = adapter.enterpriseAppRole; + }); + Then('it should return "Staff.Manager"', () => { + expect(result).toBe('Staff.Manager'); + }); + }); + + Scenario('Getting the enterpriseAppRole property when missing', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document with no enterpriseAppRole', () => { + doc = makeStaffRoleDoc(); + (doc as unknown as Record)['enterpriseAppRole'] = undefined; + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the enterpriseAppRole property', () => { + result = adapter.enterpriseAppRole; + }); + Then('it should return ""', () => { + expect(result).toBe(''); + }); + }); + + Scenario('Setting the enterpriseAppRole property', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the enterpriseAppRole property to "Staff.Supervisor"', () => { + adapter.enterpriseAppRole = 'Staff.Supervisor'; + }); + Then('the document\'s enterpriseAppRole should be "Staff.Supervisor"', () => { + expect(doc.enterpriseAppRole).toBe('Staff.Supervisor'); + }); + }); + Scenario('Getting the isDefault property', ({ Given, When, Then }) => { Given('a StaffRoleDomainAdapter for the document', () => { adapter = new StaffRoleDomainAdapter(doc); @@ -498,6 +553,443 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => expect(doc.permissions?.violationTicketPermissions?.canCreateTickets).toBe(true); }); }); + + // ─── canManageCommunities ───────────────────────────────────────────────── + + Scenario('Getting and setting canManageCommunities from communityPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let communityPermissions: StaffRoleCommunityPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + communityPermissions = permissions.communityPermissions as StaffRoleCommunityPermissionsAdapter; + }); + And('I get the canManageCommunities property', () => { + result = communityPermissions.canManageCommunities; + }); + Then('it should return false', () => { + expect(result).toBe(false); + }); + When('I set the canManageCommunities property to true', () => { + communityPermissions.canManageCommunities = true; + }); + Then("the communityPermissions' canManageCommunities should be true", () => { + expect(doc.permissions?.communityPermissions?.canManageCommunities).toBe(true); + }); + }); + + // ─── financePermissions ─────────────────────────────────────────────────── + + Scenario('Getting financePermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageFinance from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canManageFinance property should return false', () => { + expect(financePermissions.canManageFinance).toBe(false); + }); + When('I set the canManageFinance property to true', () => { + financePermissions.canManageFinance = true; + }); + Then("the financePermissions' canManageFinance should be true", () => { + expect(doc.permissions?.financePermissions?.canManageFinance).toBe(true); + }); + }); + + Scenario('Getting and setting canViewGLBatchSummaries from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewGLBatchSummaries property should return false', () => { + expect(financePermissions.canViewGLBatchSummaries).toBe(false); + }); + When('I set the canViewGLBatchSummaries property to true', () => { + financePermissions.canViewGLBatchSummaries = true; + }); + Then("the financePermissions' canViewGLBatchSummaries should be true", () => { + expect(doc.permissions?.financePermissions?.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Getting and setting canViewFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewFinanceConfigs property should return false', () => { + expect(financePermissions.canViewFinanceConfigs).toBe(false); + }); + When('I set the canViewFinanceConfigs property to true', () => { + financePermissions.canViewFinanceConfigs = true; + }); + Then("the financePermissions' canViewFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Getting and setting canCreateFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canCreateFinanceConfigs property should return false', () => { + expect(financePermissions.canCreateFinanceConfigs).toBe(false); + }); + When('I set the canCreateFinanceConfigs property to true', () => { + financePermissions.canCreateFinanceConfigs = true; + }); + Then("the financePermissions' canCreateFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canCreateFinanceConfigs).toBe(true); + }); + }); + + // ─── techAdminPermissions ───────────────────────────────────────────────── + + Scenario('Getting techAdminPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageTechAdmin from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canManageTechAdmin property should return false', () => { + expect(techAdminPermissions.canManageTechAdmin).toBe(false); + }); + When('I set the canManageTechAdmin property to true', () => { + techAdminPermissions.canManageTechAdmin = true; + }); + Then("the techAdminPermissions' canManageTechAdmin should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Getting and setting canViewDatabaseExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewDatabaseExplorer property should return false', () => { + expect(techAdminPermissions.canViewDatabaseExplorer).toBe(false); + }); + When('I set the canViewDatabaseExplorer property to true', () => { + techAdminPermissions.canViewDatabaseExplorer = true; + }); + Then("the techAdminPermissions' canViewDatabaseExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewBlobExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewBlobExplorer property should return false', () => { + expect(techAdminPermissions.canViewBlobExplorer).toBe(false); + }); + When('I set the canViewBlobExplorer property to true', () => { + techAdminPermissions.canViewBlobExplorer = true; + }); + Then("the techAdminPermissions' canViewBlobExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewQueueDashboard from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewQueueDashboard property should return false', () => { + expect(techAdminPermissions.canViewQueueDashboard).toBe(false); + }); + When('I set the canViewQueueDashboard property to true', () => { + techAdminPermissions.canViewQueueDashboard = true; + }); + Then("the techAdminPermissions' canViewQueueDashboard should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Getting and setting canSendQueueMessages from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canSendQueueMessages property should return false', () => { + expect(techAdminPermissions.canSendQueueMessages).toBe(false); + }); + When('I set the canSendQueueMessages property to true', () => { + techAdminPermissions.canSendQueueMessages = true; + }); + Then("the techAdminPermissions' canSendQueueMessages should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canSendQueueMessages).toBe(true); + }); + }); + + // ─── userPermissions ────────────────────────────────────────────────────── + + Scenario('Getting userPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageUsers from userPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canManageUsers property should return false', () => { + expect(userPermissions.canManageUsers).toBe(false); + }); + When('I set the canManageUsers property to true', () => { + userPermissions.canManageUsers = true; + }); + Then("the userPermissions' canManageUsers should be true", () => { + expect(doc.permissions?.userPermissions?.canManageUsers).toBe(true); + }); + }); + + // ─── Lazy-init paths ────────────────────────────────────────────────────── + + Scenario('Lazy-initialising permissions when document has no permissions object', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no permissions object', () => { + const docWithoutPermissions = makeStaffRoleDoc(); + docWithoutPermissions.set = vi.fn().mockImplementation((key: string, value: unknown) => { + (docWithoutPermissions as unknown as Record)[key] = value; + }); + (docWithoutPermissions as unknown as Record)['permissions'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithoutPermissions); + }); + When('I get the permissions property', () => { + result = adapter.permissions; + }); + Then('it should return a StaffRolePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRolePermissionsAdapter); + }); + }); + + Scenario('Lazy-initialising communityPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['communityPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + result = permissions.communityPermissions; + }); + Then('it should return a StaffRoleCommunityPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleCommunityPermissionsAdapter); + }); + And('canManageCommunities should default to false', () => { + expect((result as StaffRoleCommunityPermissionsAdapter).canManageCommunities).toBe(false); + }); + }); + + Scenario('Lazy-initialising financePermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['financePermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + And('canManageFinance should default to false', () => { + expect((result as StaffRoleFinancePermissionsAdapter).canManageFinance).toBe(false); + }); + }); + + Scenario('Lazy-initialising techAdminPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['techAdminPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + And('canManageTechAdmin should default to false', () => { + expect((result as StaffRoleTechAdminPermissionsAdapter).canManageTechAdmin).toBe(false); + }); + }); + + Scenario('Lazy-initialising userPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['userPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + And('canManageUsers should default to false', () => { + expect((result as StaffRoleUserPermissionsAdapter).canManageUsers).toBe(false); + }); + }); + + Scenario('Getting roleType returns null when document roleType is undefined', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no roleType', () => { + const docWithout = makeStaffRoleDoc(); + (docWithout as unknown as Record)['roleType'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the roleType property', () => { + result = adapter.roleType; + }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); }); test.for(typeConverterFeature, ({ Scenario, Background, BeforeEachScenario }) => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts index 751577717..7bbf2c918 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts @@ -1,15 +1,17 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; - -import { Domain } from '@ocom/domain'; import type { StaffRole, StaffRoleCommunityPermissions, + StaffRoleFinancePermissions, StaffRolePermissions, StaffRolePropertyPermissions, StaffRoleServicePermissions, StaffRoleServiceTicketPermissions, + StaffRoleTechAdminPermissions, + StaffRoleUserPermissions, StaffRoleViolationTicketPermissions, } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import { Domain } from '@ocom/domain'; export class StaffRoleConverter extends MongooseSeedwork.MongoTypeConverter> { constructor() { @@ -24,6 +26,15 @@ export class StaffRoleDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt set roleName(roleName: string) { this.doc.roleName = roleName; + this.doc.enterpriseAppRole = roleName; + } + + get enterpriseAppRole(): string { + return this.doc.enterpriseAppRole ?? ''; + } + + set enterpriseAppRole(enterpriseAppRole: string) { + this.doc.enterpriseAppRole = enterpriseAppRole; } get isDefault(): boolean { @@ -56,6 +67,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo get communityPermissions(): Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { if (!this.doc.communityPermissions) { this.doc.communityPermissions = { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -91,6 +103,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } @@ -103,11 +116,46 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } return new StaffRoleViolationTicketPermissionsAdapter(this.doc.violationTicketPermissions); } + + get financePermissions(): Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + if (!this.doc.financePermissions) { + this.doc.financePermissions = { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }; + } + return new StaffRoleFinancePermissionsAdapter(this.doc.financePermissions); + } + + get techAdminPermissions(): Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + if (!this.doc.techAdminPermissions) { + this.doc.techAdminPermissions = { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }; + } + return new StaffRoleTechAdminPermissionsAdapter(this.doc.techAdminPermissions); + } + + get userPermissions(): Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + if (!this.doc.userPermissions) { + this.doc.userPermissions = { + canManageUsers: false, + }; + } + return new StaffRoleUserPermissionsAdapter(this.doc.userPermissions); + } } export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { @@ -125,6 +173,13 @@ export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.Use return this.doc.id?.toString(); } + get canManageCommunities(): boolean { + return this.ensureValue(this.doc.canManageCommunities); + } + set canManageCommunities(value: boolean) { + this.doc.canManageCommunities = value; + } + get canManageStaffRolesAndPermissions(): boolean { return this.ensureValue(this.doc.canManageStaffRolesAndPermissions); } @@ -269,3 +324,121 @@ export class StaffRoleViolationTicketPermissionsAdapter implements Domain.Contex this.doc.canWorkOnTickets = value; } } + +export class StaffRoleFinancePermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + private readonly doc: StaffRoleFinancePermissions; + + constructor(permissions: StaffRoleFinancePermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageFinance(): boolean { + return this.ensureValue(this.doc.canManageFinance); + } + set canManageFinance(value: boolean) { + this.doc.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.ensureValue(this.doc.canViewGLBatchSummaries); + } + set canViewGLBatchSummaries(value: boolean) { + this.doc.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canViewFinanceConfigs); + } + set canViewFinanceConfigs(value: boolean) { + this.doc.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canCreateFinanceConfigs); + } + set canCreateFinanceConfigs(value: boolean) { + this.doc.canCreateFinanceConfigs = value; + } +} + +export class StaffRoleTechAdminPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + private readonly doc: StaffRoleTechAdminPermissions; + + constructor(permissions: StaffRoleTechAdminPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageTechAdmin(): boolean { + return this.ensureValue(this.doc.canManageTechAdmin); + } + set canManageTechAdmin(value: boolean) { + this.doc.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.ensureValue(this.doc.canViewDatabaseExplorer); + } + set canViewDatabaseExplorer(value: boolean) { + this.doc.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.ensureValue(this.doc.canViewBlobExplorer); + } + set canViewBlobExplorer(value: boolean) { + this.doc.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.ensureValue(this.doc.canViewQueueDashboard); + } + set canViewQueueDashboard(value: boolean) { + this.doc.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.ensureValue(this.doc.canSendQueueMessages); + } + set canSendQueueMessages(value: boolean) { + this.doc.canSendQueueMessages = value; + } +} + +export class StaffRoleUserPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + private readonly doc: StaffRoleUserPermissions; + + constructor(permissions: StaffRoleUserPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageUsers(): boolean { + return this.ensureValue(this.doc.canManageUsers); + } + set canManageUsers(value: boolean) { + this.doc.canManageUsers = value; + } +} diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts index a68364db6..1940529fc 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts @@ -17,6 +17,7 @@ function makeStaffRoleDoc(overrides: Partial = {}) { const base = { _id: 'role-1', roleName: 'Manager', + enterpriseAppRole: 'Staff.CaseManager', isDefault: false, roleType: 'staff', permissions: { @@ -84,10 +85,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }; Object.assign(ModelMock, { findById: vi.fn((id: string) => ({ - exec: vi.fn(async () => (id === String(staffRoleDoc._id) ? staffRoleDoc : null)), + exec: vi.fn(() => Promise.resolve(id === String(staffRoleDoc._id) ? staffRoleDoc : null)), })), - findOne: vi.fn((query: { roleName: string }) => ({ - exec: vi.fn(async () => (query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null)), + findOne: vi.fn((query: { roleName?: string; isDefault?: boolean; enterpriseAppRole?: string }) => ({ + exec: vi.fn(() => { + if (query.enterpriseAppRole !== undefined) { + return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && query.isDefault === staffRoleDoc.isDefault ? staffRoleDoc : null; + } + return query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null; + }), })), prototype: {}, }); @@ -167,6 +173,36 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + Scenario('Getting a default staff role by enterpriseAppRole', ({ Given, When, Then, And }) => { + let result: Domain.Contexts.User.StaffRole.StaffRole; + Given('a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager"', () => { + staffRoleDoc = makeStaffRoleDoc({ + isDefault: true, + enterpriseAppRole: 'Staff.CaseManager', + }); + }); + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager"', async () => { + result = await repo.getDefaultRoleByEnterpriseAppRole('Staff.CaseManager'); + }); + Then('I should receive a StaffRole domain object', () => { + expect(result).toBeInstanceOf(Domain.Contexts.User.StaffRole.StaffRole); + }); + And("the domain object's isDefault should be true", () => { + expect(result.isDefault).toBe(true); + }); + }); + + Scenario('Getting a default staff role by enterpriseAppRole that does not exist', ({ When, Then }) => { + let getDefaultRoleByEnterpriseAppRole: () => Promise; + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole"', () => { + getDefaultRoleByEnterpriseAppRole = async () => await repo.getDefaultRoleByEnterpriseAppRole('Staff.UnknownRole'); + }); + Then('an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found"', async () => { + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(); + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(/Default StaffRole with enterpriseAppRole Staff.UnknownRole not found/); + }); + }); + Scenario('Creating a new staff role instance', ({ When, Then, And }) => { let result: Domain.Contexts.User.StaffRole.StaffRole; When('I call getNewInstance with name "Supervisor"', async () => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts index 7d3e075a5..cf7e555f3 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts @@ -1,8 +1,7 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; - +import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; import { Domain } from '@ocom/domain'; import type { StaffRoleDomainAdapter } from './staff-role.domain-adapter.ts'; -import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; type StaffRoleModelType = StaffRole; type AdapterType = StaffRoleDomainAdapter; @@ -27,8 +26,36 @@ export class StaffRoleRepository return this.typeConverter.toDomain(staffRole, this.passport); } + async getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise> { + const staffRole = await this.model.findOne({ isDefault: true, enterpriseAppRole }).exec(); + if (!staffRole) { + throw new Error(`Default StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`); + } + return this.typeConverter.toDomain(staffRole, this.passport); + } + getNewInstance(name: string): Promise> { const adapter = this.typeConverter.toAdapter(new this.model()); return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewInstance(adapter, this.passport, name, false)); } + + getNewDefaultCaseManagerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultCaseManagerInstance(adapter, this.passport)); + } + + getNewDefaultServiceLineOwnerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultServiceLineOwnerInstance(adapter, this.passport)); + } + + getNewDefaultFinanceInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultFinanceInstance(adapter, this.passport)); + } + + getNewDefaultTechAdminInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultTechAdminInstance(adapter, this.passport)); + } } diff --git a/packages/ocom/persistence/src/datasources/readonly/index.test.ts b/packages/ocom/persistence/src/datasources/readonly/index.test.ts index 04d231428..536162ec4 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.test.ts @@ -1,13 +1,13 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; - -import type { Domain } from '@ocom/domain'; -import { ReadonlyDataSourceImplementation } from './index.ts'; import type { CommunityModelType } from '@ocom/data-sources-mongoose-models/community'; import type { MemberModelType } from '@ocom/data-sources-mongoose-models/member'; import type { EndUserModelType } from '@ocom/data-sources-mongoose-models/user/end-user'; +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; +import { ReadonlyDataSourceImplementation } from './index.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -32,6 +32,12 @@ function makeMockModelsContext() { create: vi.fn(), aggregate: vi.fn(), } as unknown as EndUserModelType, + StaffUser: { + findById: vi.fn(), + findOne: vi.fn(), + find: vi.fn(), + create: vi.fn(), + } as unknown as StaffUserModelType, } as unknown as Parameters[0]; } @@ -101,6 +107,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { And('the User property should have the correct structure', () => { expect(result.User).toHaveProperty('EndUser'); expect(result.User.EndUser).toHaveProperty('EndUserReadRepo'); + expect(result.User).toHaveProperty('StaffUser'); + expect(result.User.StaffUser).toHaveProperty('StaffUserReadRepo'); }); }); }); diff --git a/packages/ocom/persistence/src/datasources/readonly/index.ts b/packages/ocom/persistence/src/datasources/readonly/index.ts index d8940adb8..9342ba8ad 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.ts @@ -5,6 +5,7 @@ import { CommunityContext } from './community/index.ts'; import type * as Member from './community/member/index.ts'; import type * as EndUser from './user/end-user/index.ts'; import { UserContext } from './user/index.ts'; +import type * as StaffUser from './user/staff-user/index.ts'; export interface ReadonlyDataSource { Community: { @@ -19,6 +20,9 @@ export interface ReadonlyDataSource { EndUser: { EndUserReadRepo: EndUser.EndUserReadRepository; }; + StaffUser: { + StaffUserReadRepo: StaffUser.StaffUserReadRepository; + }; }; } diff --git a/packages/ocom/persistence/src/datasources/readonly/user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/index.ts index 54cb3892a..ab40bc6e7 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/index.ts @@ -1,7 +1,9 @@ import type { Domain } from '@ocom/domain'; import type { ModelsContext } from '../../../index.ts'; import { EndUserReadRepositoryImpl } from './end-user/index.ts'; +import { StaffUserReadRepositoryImpl } from './staff-user/index.ts'; export const UserContext = (models: ModelsContext, passport: Domain.Passport) => ({ EndUser: EndUserReadRepositoryImpl(models, passport), + StaffUser: StaffUserReadRepositoryImpl(models, passport), }); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature new file mode 100644 index 000000000..9aa56131b --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature @@ -0,0 +1,23 @@ +Feature: StaffUserReadRepository + + Scenario: Creating StaffUserReadRepository throws when StaffUser model is missing + Given models context does not contain a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then it should throw an error with message "StaffUser model is not available in the mongoose context" + + Scenario: Creating StaffUserReadRepository succeeds when StaffUser model is present + Given models context contains a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then I should receive a StaffUserReadRepository instance + And the repository should have a getByExternalId method + + Scenario: getByExternalId returns entity when document is found + Given a StaffUser document exists with externalId "ext-abc" + When I call getByExternalId with "ext-abc" + Then I should receive a StaffUserEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getByExternalId returns null when no document is found + Given no StaffUser document exists with externalId "missing-ext" + When I call getByExternalId with "missing-ext" + Then I should receive null diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts new file mode 100644 index 000000000..75eef71cf --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts @@ -0,0 +1,11 @@ +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; + +export type { StaffUserReadRepository } from './staff-user.read-repository.ts'; + +export const StaffUserReadRepositoryImpl = (models: ModelsContext, passport: Domain.Passport) => { + return { + StaffUserReadRepo: getStaffUserReadRepository(models, passport), + }; +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts new file mode 100644 index 000000000..f12d6498b --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts @@ -0,0 +1,141 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { StaffUser, StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; + +import type { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; +import type { StaffUserReadRepository } from './staff-user.read-repository.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; + +const test = { for: describeFeature }; + +vi.mock('../../../domain/user/staff-user/staff-user.domain-adapter.ts', () => ({ + StaffUserConverter: vi.fn(), +})); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.read-repository.feature')); + +function makeMockPassport() { + return { + user: { + forStaffUser: vi.fn(() => ({ + determineIf: vi.fn(() => true), + })), + }, + } as unknown as Domain.Passport; +} + +function makeMockStaffUserDocument() { + return { + _id: 'doc-id', + id: 'doc-id', + externalId: 'ext-abc', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + } as unknown as StaffUser; +} + +function makeMockModel(doc: StaffUser | null) { + return { + findOne: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(doc), + }), + }), + } as unknown as StaffUserModelType; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let models: ModelsContext; + let passport: Domain.Passport; + let repository: StaffUserReadRepository; + let mockStaffUserDoc: StaffUser; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null | unknown; + let mockConverter: { toDomain: ReturnType }; + let thrownError: unknown; + + BeforeEachScenario(() => { + passport = makeMockPassport(); + mockStaffUserDoc = makeMockStaffUserDocument(); + thrownError = undefined; + result = undefined; + + mockConverter = { + toDomain: vi.fn((_doc: StaffUser, _passport: Domain.Passport) => ({ + id: mockStaffUserDoc.id, + externalId: mockStaffUserDoc.externalId, + })), + }; + + vi.mocked(StaffUserConverter).mockImplementation(function MockStaffUserConverter() { + return mockConverter as unknown as StaffUserConverter; + }); + }); + + Scenario('Creating StaffUserReadRepository throws when StaffUser model is missing', ({ Given, When, Then }) => { + Given('models context does not contain a StaffUser model', () => { + models = {} as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + try { + repository = getStaffUserReadRepository(models, passport); + } catch (err) { + thrownError = err; + } + }); + Then('it should throw an error with message "StaffUser model is not available in the mongoose context"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('StaffUser model is not available in the mongoose context'); + }); + }); + + Scenario('Creating StaffUserReadRepository succeeds when StaffUser model is present', ({ Given, When, Then, And }) => { + Given('models context contains a StaffUser model', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + repository = getStaffUserReadRepository(models, passport); + }); + Then('I should receive a StaffUserReadRepository instance', () => { + expect(repository).toBeDefined(); + }); + And('the repository should have a getByExternalId method', () => { + expect(typeof repository.getByExternalId).toBe('function'); + }); + }); + + Scenario('getByExternalId returns entity when document is found', ({ Given, When, Then, And }) => { + Given('a StaffUser document exists with externalId "ext-abc"', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "ext-abc"', async () => { + result = await repository.getByExternalId('ext-abc'); + }); + Then('I should receive a StaffUserEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockStaffUserDoc, passport); + }); + }); + + Scenario('getByExternalId returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffUser document exists with externalId "missing-ext"', () => { + models = { StaffUser: makeMockModel(null) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "missing-ext"', async () => { + result = await repository.getByExternalId('missing-ext'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts new file mode 100644 index 000000000..0824f8934 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts @@ -0,0 +1,35 @@ +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; + +export interface StaffUserReadRepository { + getByExternalId: (externalId: string) => Promise; +} + +class StaffUserReadRepositoryImpl implements StaffUserReadRepository { + private readonly model: StaffUserModelType; + private readonly converter: StaffUserConverter; + private readonly passport: Domain.Passport; + + constructor(models: ModelsContext, passport: Domain.Passport) { + if (!models.StaffUser) { + throw new Error('StaffUser model is not available in the mongoose context'); + } + this.model = models.StaffUser; + this.converter = new StaffUserConverter(); + this.passport = passport; + } + + async getByExternalId(externalId: string): Promise { + const doc = await this.model.findOne({ externalId }).populate('role').exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } +} + +export const getStaffUserReadRepository = (models: ModelsContext, passport: Domain.Passport): StaffUserReadRepository => { + return new StaffUserReadRepositoryImpl(models, passport); +}; diff --git a/packages/ocom/service-token-validation/src/index.test.ts b/packages/ocom/service-token-validation/src/index.test.ts index 8bf1747cb..166f8f461 100644 --- a/packages/ocom/service-token-validation/src/index.test.ts +++ b/packages/ocom/service-token-validation/src/index.test.ts @@ -1,9 +1,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi, afterEach, type Mock } from 'vitest'; -import { VerifiedTokenService } from './verified-token-service.ts'; +import { afterEach, expect, type Mock, vi } from 'vitest'; import { ServiceTokenValidation } from './index.ts'; +import { VerifiedTokenService } from './verified-token-service.ts'; // Mock VerifiedTokenService @@ -228,7 +228,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Mock successful verification on second attempt mockGetVerifiedJwt - .mockResolvedValueOnce(null) // First provider fails + .mockRejectedValueOnce(Object.assign(new Error('signature verification failed'), { name: 'JWSSignatureVerificationFailed' })) // First provider fails with signature mismatch .mockResolvedValueOnce({ // Second provider succeeds payload: { sub: 'user123', aud: 'audience2' }, diff --git a/packages/ocom/service-token-validation/src/index.ts b/packages/ocom/service-token-validation/src/index.ts index b002722c6..c8824fa05 100644 --- a/packages/ocom/service-token-validation/src/index.ts +++ b/packages/ocom/service-token-validation/src/index.ts @@ -39,12 +39,18 @@ export class ServiceTokenValidation implements ServiceBase { async verifyJwt(token: string): Promise | null> { // Try each config key for verification for (const configKey of this.tokenSettings.keys()) { - const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); - if (result?.payload) { - return { - verifiedJwt: result.payload as ClaimsType, - openIdConfigKey: configKey, - }; + try { + const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); + if (result?.payload) { + return { + verifiedJwt: result.payload as ClaimsType, + openIdConfigKey: configKey, + }; + } + } catch (error) { + if (!this.isRetryableVerificationError(error)) { + throw error; + } } } return null; @@ -74,4 +80,12 @@ export class ServiceTokenValidation implements ServiceBase { return defaultValue; } } + + private isRetryableVerificationError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return ['JWSSignatureVerificationFailed', 'JWTClaimValidationFailed', 'JWTExpired', 'JWTInvalid', 'JWSInvalid'].includes(error.name); + } } diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx index 4930c0c3b..3fc91da04 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx @@ -80,6 +80,7 @@ const mockData = { export const Default: Story = { args: { data: mockData, + canCreateCommunity: true, } satisfies CommunityListProps, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx index c0f38203c..aac337ff8 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx @@ -16,7 +16,6 @@ export interface CommunityListProps { export const CommunityList: React.FC = (props) => { const [communityList, setCommunityList] = useState(props.data.communities); const navigate = useNavigate(); - const onChange = (event: ChangeEvent) => { const searchValue = event.target.value; if (searchValue === '') { diff --git a/packages/ocom/ui-community-route-admin/src/index.tsx b/packages/ocom/ui-community-route-admin/src/index.tsx index cf4099d70..556021354 100644 --- a/packages/ocom/ui-community-route-admin/src/index.tsx +++ b/packages/ocom/ui-community-route-admin/src/index.tsx @@ -7,6 +7,12 @@ import { Members } from './pages/members.tsx'; import { Settings } from './pages/settings.tsx'; import { SectionLayoutContainer } from './section-layout.container.tsx'; +interface AdminMenuData { + member?: { + isAdmin?: boolean | null; + }; +} + export const Admin: React.FC = () => { const pageLayouts: PageLayoutProps[] = [ { @@ -21,7 +27,10 @@ export const Admin: React.FC = () => { icon: , id: 2, parent: 'ROOT', - // hasPermissions: (member: Member) => member?.isAdmin ?? false + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, { path: '/community/:communityId/admin/:memberId/settings/*', @@ -29,9 +38,10 @@ export const Admin: React.FC = () => { icon: , id: 3, parent: 'ROOT', - // Note: Permission check would be: - // hasPermissions: (member: Member) => member?.role?.permissions?.communityPermissions?.canManageCommunitySettings ?? false - // Currently schema doesn't include role/permissions, so we allow all admin users to access settings + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, ]; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.stories.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.stories.tsx new file mode 100644 index 000000000..1c69ed776 --- /dev/null +++ b/packages/ocom/ui-community-route-admin/src/section-layout.stories.tsx @@ -0,0 +1,166 @@ +import { HomeOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons'; +import { MockedProvider } from '@apollo/client/testing'; +import type { PageLayoutProps } from '@ocom/ui-shared'; +import type { Meta, StoryObj } from '@storybook/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import type { Member } from './generated.tsx'; +import type { AdminStaffSectionPermissions } from './section-layout.tsx'; +import { SectionLayout } from './section-layout.tsx'; + +const mockMember: Member = { + __typename: 'Member', + id: '507f1f77bcf86cd799439011', + memberName: 'John Doe', + isAdmin: true, + accounts: [], + community: { + __typename: 'Community', + id: '507f1f77bcf86cd799439001', + name: 'Test Community', + }, + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-01T12:00:00.000Z', +}; + +const allPermissions: AdminStaffSectionPermissions = { + canManageCommunities: true, + canManageUsers: true, + canManageFinance: true, + canManageTechAdmin: true, +}; + +const noPermissions: AdminStaffSectionPermissions = { + canManageCommunities: false, + canManageUsers: false, + canManageFinance: false, + canManageTechAdmin: false, +}; + +const makePageLayouts = (permissions: AdminStaffSectionPermissions | null): PageLayoutProps[] => [ + { + path: '/community/:communityId/admin/:memberId', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: '/community/:communityId/admin/:memberId/members/*', + title: 'Members', + icon: , + id: 2, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageUsers ?? false, + }, + { + path: '/community/:communityId/admin/:memberId/settings/*', + title: 'Settings', + icon: , + id: 3, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageCommunities ?? false, + }, +]; + +const meta: Meta = { + title: 'Admin/Layouts/SectionLayout', + component: SectionLayout, + decorators: [ + (Story) => ( + + + + } + /> + + + + ), + ], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllPermissions: Story = { + args: { + pageLayouts: makePageLayouts(allPermissions), + memberData: mockMember, + staffSectionPermissions: allPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const NoPermissions: Story = { + args: { + pageLayouts: makePageLayouts(noPermissions), + memberData: mockMember, + staffSectionPermissions: noPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const CommunityPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageCommunities: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageCommunities: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const UserPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageUsers: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageUsers: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const NullPermissions: Story = { + args: { + pageLayouts: makePageLayouts(null), + memberData: mockMember, + staffSectionPermissions: null, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.tsx index 85e655538..846dbfb20 100644 --- a/packages/ocom/ui-community-route-admin/src/section-layout.tsx +++ b/packages/ocom/ui-community-route-admin/src/section-layout.tsx @@ -21,6 +21,13 @@ const handleToggler = (isExpanded: boolean, setIsExpanded: (value: boolean) => v } }; +export interface AdminStaffSectionPermissions { + canManageCommunities: boolean; + canManageUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; +} + interface AdminSectionLayoutProps { pageLayouts: PageLayoutProps[]; memberData: Member; @@ -36,7 +43,7 @@ export const SectionLayout: React.FC = (props) => { const menuComponentProps: MenuComponentProps = { pageLayouts: props.pageLayouts, - memberData: props.memberData, + memberData: { member: props.memberData }, theme: 'light', mode: 'inline', }; diff --git a/packages/ocom/ui-shared/package.json b/packages/ocom/ui-shared/package.json index 90d154655..b0f02e571 100644 --- a/packages/ocom/ui-shared/package.json +++ b/packages/ocom/ui-shared/package.json @@ -53,7 +53,7 @@ "@storybook/react-vite": "^9.1.3", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", - "@vitest/browser": "^4.1.2", + "@vitest/browser": "catalog:", "@vitest/coverage-istanbul": "catalog:", "jsdom": "catalog:", "rimraf": "catalog:", diff --git a/packages/ocom/ui-staff-route-community-management/src/index.tsx b/packages/ocom/ui-staff-route-community-management/src/index.tsx index 90ceaf1cc..3bbe9a551 100644 --- a/packages/ocom/ui-staff-route-community-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-community-management/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-finance/src/index.tsx b/packages/ocom/ui-staff-route-finance/src/index.tsx index c7116f4ad..fb0360d17 100644 --- a/packages/ocom/ui-staff-route-finance/src/index.tsx +++ b/packages/ocom/ui-staff-route-finance/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-user-management/package.json b/packages/ocom/ui-staff-route-user-management/package.json index 604ff613d..a149d43bf 100644 --- a/packages/ocom/ui-staff-route-user-management/package.json +++ b/packages/ocom/ui-staff-route-user-management/package.json @@ -16,6 +16,7 @@ "dependencies": { "@ant-design/icons": "catalog:", "@ocom/ui-staff-shared": "workspace:*", + "@graphql-typed-document-node/core": "^3.2.0", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "catalog:" diff --git a/packages/ocom/ui-staff-route-user-management/src/index.tsx b/packages/ocom/ui-staff-route-user-management/src/index.tsx index 33b2a3f38..f2c3911df 100644 --- a/packages/ocom/ui-staff-route-user-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-user-management/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-shared/package.json b/packages/ocom/ui-staff-shared/package.json index c8c36c54e..38b957956 100644 --- a/packages/ocom/ui-staff-shared/package.json +++ b/packages/ocom/ui-staff-shared/package.json @@ -15,9 +15,11 @@ "test:watch": "vitest" }, "dependencies": { + "@apollo/client": "^3.13.9", "@ant-design/icons": "catalog:", - "@ocom/ui-shared": "workspace:*", + "@cellix/ui-core": "workspace:*", "@graphql-typed-document-node/core": "^3.2.0", + "@ocom/ui-shared": "workspace:*", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "catalog:", @@ -28,7 +30,10 @@ "@cellix/config-vitest": "workspace:*", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", + "@storybook/react": "^9.1.9", + "storybook": "catalog:", "jsdom": "catalog:", + "react-dom": "^19.1.1", "vite": "catalog:", "vitest": "catalog:", "typescript": "catalog:" diff --git a/packages/ocom/ui-staff-shared/src/index.tsx b/packages/ocom/ui-staff-shared/src/index.tsx index ede529df0..fb8278245 100644 --- a/packages/ocom/ui-staff-shared/src/index.tsx +++ b/packages/ocom/ui-staff-shared/src/index.tsx @@ -2,7 +2,10 @@ import React, { createElement, type FC } from 'react'; import { SectionLayout } from './section-layout.tsx'; export { VerticalTabs } from '@ocom/ui-shared'; +export { RequireRole, type RequireRoleProps } from './require-role.tsx'; +export { SectionLayoutContainer } from './section-layout.container.tsx'; export { SectionLayout, type SectionLayoutProps } from './section-layout.tsx'; +export { extractRoles, type StaffAppRole, StaffAppRoles, staffRouteRoles } from './staff-app-roles.ts'; export { type StaffAuth, StaffAuthContext, StaffAuthProvider, StaffRouteShell, type StaffRouteShellProps } from './staff-route-shell.tsx'; export { SubPageLayout } from './sub-page-layout.tsx'; @@ -19,20 +22,13 @@ import { StaffAuthContext } from './staff-route-shell.tsx'; export const PlaceholderPage: React.FC = ({ sectionName, description, expectedRoles, explicitRoles }) => { const auth = React.useContext(StaffAuthContext); - const resolvedRoles = React.useMemo(() => { + const resolvedPermissions = React.useMemo(() => { if (explicitRoles && explicitRoles.length > 0) return explicitRoles; - if (auth) { - const a = auth as StaffAuth; - if (Array.isArray(a.roles) && a.roles.length > 0) return a.roles as string[]; - type RawProfile = { roles?: unknown; role?: unknown }; - const raw = a.raw as RawProfile | undefined; - if (raw) { - const maybe = raw.roles ?? raw.role ?? undefined; - if (Array.isArray(maybe)) return maybe as string[]; - if (typeof maybe === 'string') return [maybe]; - } - } - return []; + const perms = auth?.permissions; + if (!perms) return []; + return Object.entries(perms) + .filter(([, isEnabled]) => isEnabled === true) + .map(([permKey]) => permKey); }, [auth, explicitRoles]); const identitySummary = React.useMemo<{ displayName: string; identifier: string | undefined } | null>(() => { @@ -70,11 +66,11 @@ export const PlaceholderPage: React.FC = ({ sectionName, descr )}
-
Resolved Roles
- {resolvedRoles && resolvedRoles.length > 0 ? ( +
Resolved Permissions
+ {resolvedPermissions.length > 0 ? (
    - {resolvedRoles.map((r) => ( -
  • {r}
  • + {resolvedPermissions.map((permission) => ( +
  • {permission}
  • ))}
) : ( diff --git a/packages/ocom/ui-staff-shared/src/require-role.stories.tsx b/packages/ocom/ui-staff-shared/src/require-role.stories.tsx new file mode 100644 index 000000000..027cd353a --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.stories.tsx @@ -0,0 +1,158 @@ +import { gql } from '@apollo/client'; +import { MockedProvider } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import { RequireRole, type RequireRoleProps } from './require-role.tsx'; + +const REQUIRE_ROLE_STAFF_USER_CURRENT_QUERY = gql` + query RequireRoleStaffUserCurrent { + staffUserCurrent: currentStaffUserAndCreateIfNotExists { + role { + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +const protectedPermissions = { + communityPermissions: { canManageCommunities: false }, + userPermissions: { canManageUsers: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: true }, +}; + +const deniedPermissions = { + communityPermissions: { canManageCommunities: false }, + userPermissions: { canManageUsers: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, +}; + +const meta = { + title: 'Staff/RequireRole', + component: RequireRole, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const ProtectedContent = () =>
protected content
; + +const routeWrapper = (story: ReactElement) => ( + + + unauthorized
} + /> + +); + +export const Authorized: Story = { + args: { + roles: [], + permKey: 'canManageTechAdmin', + children: , + } satisfies RequireRoleProps, + parameters: { + memoryRouter: { + initialEntries: ['/staff/tech'], + }, + }, + decorators: [ + (Story) => ( + + {routeWrapper()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('protected content')).resolves.toBeInTheDocument(); + expect(canvas.queryByText('unauthorized')).not.toBeInTheDocument(); + }, +}; + +export const Unauthorized: Story = { + args: { + roles: [], + permKey: 'canManageTechAdmin', + children: , + } satisfies RequireRoleProps, + parameters: { + memoryRouter: { + initialEntries: ['/staff/tech'], + }, + }, + decorators: [ + (Story) => ( + + {routeWrapper()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('unauthorized')).resolves.toBeInTheDocument(); + expect(canvas.queryByText('protected content')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-staff-shared/src/require-role.tsx b/packages/ocom/ui-staff-shared/src/require-role.tsx new file mode 100644 index 000000000..71f2b6d02 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.tsx @@ -0,0 +1,81 @@ +import { gql, useQuery } from '@apollo/client'; +import type { FC, ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import type { StaffAuth } from './staff-route-shell.tsx'; + +export interface RequireRoleProps { + /** Deprecated. Frontend authorization must use backend permission flags. */ + roles: readonly string[]; + /** Gate by backend permission flag. */ + permKey?: keyof NonNullable; + children: ReactNode; +} + +const STAFF_USER_CURRENT_QUERY = gql` + query RequireRoleStaffUserCurrent { + staffUserCurrent: currentStaffUserAndCreateIfNotExists { + role { + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffUserCurrentQueryResult { + staffUserCurrent: { + role?: { + permissions: { + communityPermissions: { canManageCommunities: boolean }; + userPermissions: { canManageUsers: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const RequireRole: FC = ({ roles, permKey, children }) => { + void roles; + const { data, loading, error } = useQuery(STAFF_USER_CURRENT_QUERY, { + fetchPolicy: 'cache-first', + }); + + if (loading) { + return null; + } + + const rolePermissions = data?.staffUserCurrent?.role?.permissions; + const permissions: NonNullable | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities, + canManageUsers: rolePermissions.userPermissions.canManageUsers, + canManageFinance: rolePermissions.financePermissions.canManageFinance, + canManageTechAdmin: rolePermissions.techAdminPermissions.canManageTechAdmin, + } + : undefined; + const isAuthorized = permKey !== undefined && permissions?.[permKey] === true; + + if (error || !isAuthorized) { + return ( + + ); + } + + return <>{children}; +}; diff --git a/packages/ocom/ui-staff-shared/src/section-layout-header.graphql b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql new file mode 100644 index 000000000..e744d3fde --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql @@ -0,0 +1,13 @@ +query SectionLayoutHeaderCurrentStaffUser { + currentStaffUserAndCreateIfNotExists { + ...SectionLayoutHeaderStaffUserFields + } +} + +fragment SectionLayoutHeaderStaffUserFields on StaffUser { + id + displayName + firstName + lastName + email +} diff --git a/packages/ocom/ui-staff-shared/src/section-layout.container.stories.tsx b/packages/ocom/ui-staff-shared/src/section-layout.container.stories.tsx new file mode 100644 index 000000000..e6b1cbce5 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout.container.stories.tsx @@ -0,0 +1,123 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { PageLayoutProps } from '@ocom/ui-shared'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import { SectionLayoutHeaderCurrentStaffUserDocument } from './generated.tsx'; +import { SectionLayoutContainer } from './section-layout.container.tsx'; +import { StaffAuthProvider } from './staff-route-shell.tsx'; + +const pageLayouts: PageLayoutProps[] = [ + { + path: '/staff/custom', + title: 'Custom', + icon: , + id: 'ROOT', + }, +]; + +const meta = { + title: 'Staff/SectionLayoutContainer', + component: SectionLayoutContainer, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const renderContainer = (story: ReactElement) => ( + + + + + + + +); + +export const WithDisplayName: Story = { + args: { + pageLayouts, + } satisfies { pageLayouts: PageLayoutProps[] }, + decorators: [ + (Story) => ( + + {renderContainer()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('Jess')).resolves.toBeInTheDocument(); + expect(canvas.getByText('Custom')).toBeInTheDocument(); + }, +}; + +export const FallsBackToAuthName: Story = { + args: { + pageLayouts, + } satisfies { pageLayouts: PageLayoutProps[] }, + decorators: [ + (Story) => ( + + {renderContainer()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('Fallback Name')).resolves.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-staff-shared/src/section-layout.container.tsx b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx new file mode 100644 index 000000000..5e3993744 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { PageLayoutProps } from '@ocom/ui-shared'; +import type React from 'react'; +import { SectionLayoutHeaderCurrentStaffUserDocument } from './generated.tsx'; +import { SectionLayout } from './section-layout.tsx'; + +interface SectionLayoutContainerProps { + pageLayouts: PageLayoutProps[]; +} + +export const SectionLayoutContainer: React.FC = (props) => { + const { + data: staffUserData, + loading: staffUserLoading, + error: staffUserError, + } = useQuery(SectionLayoutHeaderCurrentStaffUserDocument, { + fetchPolicy: 'cache-first', + }); + + const displayName = staffUserData?.currentStaffUserAndCreateIfNotExists?.displayName; + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayoutContainer] GraphQL query result:', { + loading: staffUserLoading, + error: staffUserError?.message, + staffUserData, + extractedDisplayName: displayName, + }); + } + } + + const sectionLayoutProps: React.ComponentProps = { + pageLayouts: props.pageLayouts, + // Always pass displayName (even if undefined) so the component can properly handle fallback chain + ...(displayName && { displayName }), + }; + + return ( + } + error={staffUserError} + /> + ); +}; diff --git a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx index bd43e89e1..541a668ba 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx @@ -18,36 +18,81 @@ const renderIntoDocument = (node: React.ReactNode) => { }; describe('SectionLayout merging behaviour', () => { - it('renders canonical staff navigation merged with consumer pageLayouts', async () => { - const consumerLayouts = [ - { - path: '/staff/community-management', - title: 'Community Management', - icon: , - id: 'ROOT', - }, - ]; + it('renders only the menu items the user has permission for', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + it('shows no menu items when permissions are undefined (loading or no role assigned)', async () => { const container = renderIntoDocument( } + element={} /> , ); - // Wait a tick for ant design components to mount await new Promise((r) => setTimeout(r, 10)); - // Top-level menu items expected - expect(container.textContent).not.toContain('Home'); - expect(container.textContent).toContain('Communities'); - expect(container.textContent).toContain('Users'); + expect(container.textContent).not.toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).not.toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + + it('renders finance menu from JWT role when backend permissions are unavailable', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).not.toContain('Communities'); + expect(container.textContent).not.toContain('Users'); expect(container.textContent).toContain('Finance'); - expect(container.textContent).toContain('Tech Admin'); + expect(container.textContent).not.toContain('Tech Admin'); }); it('preserves default parent when consumer entry omits parent field', async () => { @@ -63,12 +108,23 @@ describe('SectionLayout merging behaviour', () => { const container = renderIntoDocument( - - } - /> - + + + } + /> + + , ); @@ -142,3 +198,103 @@ describe('PlaceholderPage', () => { expect(container.textContent).toContain('m@example.com'); }); }); + +describe('SectionLayout with displayName prop', () => { + it('renders displayName from prop when provided', async () => { + const container = renderIntoDocument( + + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Alice Johnson'); + expect(container.textContent).toContain('Log Out'); + }); + + it('falls back to auth context name when displayName prop is not provided', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Bob Smith'); + }); + + it('uses displayName prop over auth context when both are available', async () => { + const container = renderIntoDocument( + + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Prop Name'); + expect(container.textContent).not.toContain('Auth Name'); + }); +}); diff --git a/packages/ocom/ui-staff-shared/src/section-layout.tsx b/packages/ocom/ui-staff-shared/src/section-layout.tsx index cd27b722f..45cc05295 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.tsx @@ -30,10 +30,27 @@ export interface SectionLayoutProps { headerContent?: React.ReactNode; /** Optional injected logged in user component (extension slot). */ loggedInUser?: React.ReactNode; + /** Optional displayName from container (e.g., from GraphQL query). When provided, takes priority over auth context. */ + displayName?: string; } export const SectionLayout: React.FC = (props) => { const auth = useContext(StaffAuthContext); + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayout] Component props & fallback chain:', { + propsDisplayName: props.displayName, + authName: auth?.name, + authUsername: auth?.username, + authEmail: auth?.email, + resolvedDisplayName: props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User', + }); + } + } + // Guard access to localStorage so this component is safe during server-side rendering (no globalThis/localStorage) const [isExpanded, setIsExpanded] = useState(() => { if (typeof globalThis === 'undefined') return true; // default to expanded during SSR @@ -54,35 +71,39 @@ export const SectionLayout: React.FC = (props) => { // Merge canonical staff navigation with consumer-provided pageLayouts. // Defaults are added only when the consumer hasn't provided an entry with the same id. // Consumer-provided entries override defaults when ids conflict. - const defaultPageLayouts: PageLayoutProps[] = [ - { - path: '/staff/community-management', - title: 'Communities', - icon: , - id: 'ROOT', - }, - { - path: '/staff/user-management/*', - title: 'Users', - icon: , - id: 'users', - parent: 'ROOT', - }, - { - path: '/staff/finance/*', - title: 'Finance', - icon: , - id: 'finance', - parent: 'ROOT', - }, - { - path: '/staff/tech/*', - title: 'Tech Admin', - icon: , - id: 'tech', - parent: 'ROOT', - }, - ]; + // Build default page layouts from backend permissions. + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageUsers = perms?.canManageUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + const nestedParentProps = canManageCommunities ? { parent: 'ROOT' as const } : {}; + + // Construct default page layouts ensuring a ROOT entry always exists so MenuComponent renders. + // If Communities is allowed, keep the historic behaviour: Communities is ROOT and others are its children. + // Otherwise, promote the first available section to ROOT so a finance-only user sees a single Finance item. + const defaultPageLayouts: PageLayoutProps[] = []; + + if (canManageCommunities) { + // Communities as canonical root, others as children + defaultPageLayouts.push({ path: '/staff/community-management', title: 'Communities', icon: , id: 'ROOT' }); + if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', ...nestedParentProps }); + if (canManageFinance) defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'finance', ...nestedParentProps }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', ...nestedParentProps }); + } else { + // No Communities root. Promote the first available section to ROOT to render a single top-level item. + if (canManageFinance) { + defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'ROOT' }); + // add others as children if present + if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', parent: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canManageUsers) { + defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canManageTechAdmin) { + defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'ROOT' }); + } + } // Build a map from default entries, then overlay consumer entries so consumers can override defaults. // When consumers provide an entry with the same id, merge it with the default so that @@ -160,7 +181,7 @@ export const SectionLayout: React.FC = (props) => { marginLeft: 'auto', }} > - Staff User + {props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User'}